Compare commits
17 Commits
1a6ea64e80
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7591eb8cda | |||
| ef4528a441 | |||
| cb379e071c | |||
| 8b80770960 | |||
| c316eff5be | |||
| 1ea863cb2c | |||
| 0f4d10c43c | |||
| cc3bb9cff0 | |||
| e39f7521e2 | |||
| 1c205c3720 | |||
| 11e595b088 | |||
| 72df00f25b | |||
| 6c3d98eaac | |||
| b7f3ae2854 | |||
| 2907acac95 | |||
| 0947009ee6 | |||
| a186066641 |
31
Dockerfile
31
Dockerfile
@@ -1,3 +1,4 @@
|
||||
# 后端构建脚本
|
||||
FROM --platform=linux/amd64 swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/eclipse-temurin:17-jre
|
||||
|
||||
ENV LANG=C.UTF-8
|
||||
@@ -8,12 +9,40 @@ ENV EASYFLOW_JAR_PATH=/app/artifacts/easyflow.jar
|
||||
ENV EASYFLOW_CONFIG_PATH=file:/app/application.yml
|
||||
ENV EASYFLOW_LOG_FILE=/app/logs/app.log
|
||||
ENV EASYFLOW_JAR_RESTART_GRACE_SECONDS=30
|
||||
ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
|
||||
ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
ENV PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN useradd --system --create-home easyflow && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 inotify-tools tini && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
gnupg && \
|
||||
mkdir -p /etc/apt/keyrings && \
|
||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key -o /tmp/nodesource.gpg.key && \
|
||||
gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg /tmp/nodesource.gpg.key && \
|
||||
chmod 644 /etc/apt/keyrings/nodesource.gpg && \
|
||||
printf "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_24.x nodistro main\n" > /etc/apt/sources.list.d/nodesource.list && \
|
||||
rm -f /tmp/nodesource.gpg.key && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
inotify-tools \
|
||||
nodejs \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
tini && \
|
||||
ln -sf /usr/bin/python3 /usr/local/bin/python && \
|
||||
ln -sf /usr/bin/pip3 /usr/local/bin/pip && \
|
||||
npm config set registry "${NPM_CONFIG_REGISTRY}" && \
|
||||
printf "registry=%s\n" "${NPM_CONFIG_REGISTRY}" > /etc/npmrc && \
|
||||
npm install -g pnpm@10.17.1 && \
|
||||
pnpm config set registry "${NPM_CONFIG_REGISTRY}" && \
|
||||
mkdir -p /etc/pip && \
|
||||
printf "[global]\nindex-url = %s\ntrusted-host = %s\n" "${PIP_INDEX_URL}" "${PIP_TRUSTED_HOST}" > /etc/pip.conf && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
mkdir -p /app/logs /app/artifacts /app/data && \
|
||||
chown -R easyflow:easyflow /app
|
||||
|
||||
61
config/proguard/common-keep.pro
Normal file
61
config/proguard/common-keep.pro
Normal file
@@ -0,0 +1,61 @@
|
||||
-dontshrink
|
||||
-dontoptimize
|
||||
-dontpreverify
|
||||
-ignorewarnings
|
||||
-dontnote
|
||||
|
||||
-libraryjars <java.home>/jmods/java.base.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.compiler.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.datatransfer.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.desktop.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.instrument.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.logging.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.management.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.naming.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.net.http.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.prefs.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.rmi.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.scripting.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.security.jgss.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.security.sasl.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.sql.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.transaction.xa.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.xml.jmod(!**.jar;!module-info.class)
|
||||
-libraryjars <java.home>/jmods/java.xml.crypto.jmod(!**.jar;!module-info.class)
|
||||
|
||||
-keepattributes RuntimeVisibleAnnotations,RuntimeInvisibleAnnotations,RuntimeVisibleParameterAnnotations,RuntimeInvisibleParameterAnnotations,AnnotationDefault,Signature,InnerClasses,EnclosingMethod,Record,SourceFile,LineNumberTable,MethodParameters
|
||||
|
||||
-keep @org.springframework.stereotype.Controller class * { *; }
|
||||
-keep @org.springframework.web.bind.annotation.RestController class * { *; }
|
||||
-keep @org.springframework.context.annotation.Configuration class * { *; }
|
||||
-keep @org.springframework.boot.context.properties.ConfigurationProperties class * { *; }
|
||||
-keep @org.springframework.boot.autoconfigure.SpringBootApplication class * { *; }
|
||||
|
||||
-keep class **.*Controller { *; }
|
||||
-keep class **.*Mapper { *; }
|
||||
-keep class **.mapper.** { *; }
|
||||
-keep class **.entity.** { *; }
|
||||
-keep class **.dto.** { *; }
|
||||
-keep class **.vo.** { *; }
|
||||
-keep class **.model.** { *; }
|
||||
-keep class **.config.** { *; }
|
||||
-keep class **.enums.** { *; }
|
||||
-keep class **.annotation.** { *; }
|
||||
-keep class **.*Exception { *; }
|
||||
-keep class **.*ErrorCode { *; }
|
||||
-keep class **.*Properties { *; }
|
||||
-keep class **.*Config { *; }
|
||||
-keep class **.*Configuration { *; }
|
||||
-keep interface tech.easyflow.** { *; }
|
||||
-keep enum tech.easyflow.** { *; }
|
||||
|
||||
-keepclassmembers class * {
|
||||
@jakarta.annotation.Resource <fields>;
|
||||
@org.springframework.beans.factory.annotation.Autowired <fields>;
|
||||
@org.springframework.beans.factory.annotation.Value <fields>;
|
||||
@org.springframework.context.annotation.Bean <methods>;
|
||||
}
|
||||
|
||||
-keepclassmembers class * {
|
||||
public <init>(...);
|
||||
}
|
||||
28
config/proguard/easyflow-module-ai.pro
Normal file
28
config/proguard/easyflow-module-ai.pro
Normal file
@@ -0,0 +1,28 @@
|
||||
-include ../../config/proguard/common-keep.pro
|
||||
|
||||
-keep class tech.easyflow.ai.chattime.** { *; }
|
||||
-keep class tech.easyflow.ai.constants.** { *; }
|
||||
-keep class tech.easyflow.ai.document.** { *; }
|
||||
-keep class tech.easyflow.ai.documentimport.** { *; }
|
||||
-keep class tech.easyflow.ai.easyagents.** { *; }
|
||||
-keep class tech.easyflow.ai.exception.** { *; }
|
||||
-keep class tech.easyflow.ai.mcp.** { *; }
|
||||
-keep class tech.easyflow.ai.node.** { *; }
|
||||
-keep class tech.easyflow.ai.permission.** { *; }
|
||||
-keep class tech.easyflow.ai.plugin.** { *; }
|
||||
-keep class tech.easyflow.ai.publish.** { *; }
|
||||
-keep class tech.easyflow.ai.rag.** { *; }
|
||||
-keep class tech.easyflow.ai.service.** { *; }
|
||||
-keep class tech.easyflow.ai.support.** { *; }
|
||||
-keep class tech.easyflow.ai.utils.** { *; }
|
||||
-keep class tech.easyflow.ai.invoke.service.** { *; }
|
||||
-keep class tech.easyflow.ai.invoke.model.** { *; }
|
||||
-keep class tech.easyflow.ai.invoke.protocol.** { *; }
|
||||
-keep class tech.easyflow.ai.invoke.exception.** { *; }
|
||||
-keep class tech.easyflow.ai.invoke.mapper.OpenAiProtocolMapper { *; }
|
||||
-keep class tech.easyflow.ai.invoke.provider.ModelProviderGateway { *; }
|
||||
-keep class tech.easyflow.ai.invoke.provider.UnifiedChatChunkObserver { *; }
|
||||
-keep class tech.easyflow.ai.easyagentsflow.config.** { *; }
|
||||
-keep class tech.easyflow.ai.easyagentsflow.entity.** { *; }
|
||||
-keep class tech.easyflow.ai.easyagentsflow.service.** { *; }
|
||||
-keep class tech.easyflow.ai.easyagentsflow.support.** { *; }
|
||||
5
config/proguard/easyflow-module-autoconfig.pro
Normal file
5
config/proguard/easyflow-module-autoconfig.pro
Normal file
@@ -0,0 +1,5 @@
|
||||
-include ../../config/proguard/common-keep.pro
|
||||
|
||||
-keep class tech.easyflow.autoconfig.license.EasyflowLicenseBootstrapValidator { *; }
|
||||
-keep class tech.easyflow.autoconfig.license.EasyflowLicenseProperties { *; }
|
||||
-keep class tech.easyflow.autoconfig.license.EasyflowLicenseVerificationResult { *; }
|
||||
10
config/proguard/easyflow-module-datacenter.pro
Normal file
10
config/proguard/easyflow-module-datacenter.pro
Normal file
@@ -0,0 +1,10 @@
|
||||
-include ../../config/proguard/common-keep.pro
|
||||
|
||||
-keep class tech.easyflow.datacenter.connector.DatacenterConnector { *; }
|
||||
-keep class tech.easyflow.datacenter.connector.QueryExecutor { *; }
|
||||
-keep class tech.easyflow.datacenter.connector.WriteExecutor { *; }
|
||||
-keep class tech.easyflow.datacenter.connector.MetadataExplorer { *; }
|
||||
-keep class tech.easyflow.datacenter.connector.SourceHealthChecker { *; }
|
||||
-keep class tech.easyflow.datacenter.connector.SqlDialect { *; }
|
||||
-keep class tech.easyflow.datacenter.execution.model.** { *; }
|
||||
-keep class tech.easyflow.datacenter.meta.enums.** { *; }
|
||||
@@ -20,6 +20,10 @@
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-module-ai</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-module-agent</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-module-chatlog</artifactId>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package tech.easyflow.admin.controller.agent;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.agent.entity.AgentCategory;
|
||||
import tech.easyflow.agent.mapper.AgentMapper;
|
||||
import tech.easyflow.agent.service.AgentCategoryService;
|
||||
import tech.easyflow.common.annotation.UsePermission;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
|
||||
import tech.easyflow.system.enums.CategoryResourceType;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.io.Serializable;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Agent 分类管理控制器。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/agentCategory")
|
||||
@UsePermission(moduleName = "/api/v1/agent")
|
||||
public class AgentCategoryController extends BaseCurdController<AgentCategoryService, AgentCategory> {
|
||||
|
||||
@Resource
|
||||
private AgentMapper agentMapper;
|
||||
@Resource
|
||||
private CategoryPermissionService categoryPermissionService;
|
||||
|
||||
/**
|
||||
* 创建 Agent 分类管理控制器。
|
||||
*
|
||||
* @param service Agent 分类服务
|
||||
*/
|
||||
public AgentCategoryController(AgentCategoryService service) {
|
||||
super(service);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户可见的 Agent 分类。
|
||||
*
|
||||
* @param entity 查询条件
|
||||
* @param asTree 是否转树
|
||||
* @param sortKey 排序字段
|
||||
* @param sortType 排序方式
|
||||
* @return 可见分类列表
|
||||
*/
|
||||
@GetMapping("visibleList")
|
||||
public Result<List<AgentCategory>> visibleList(AgentCategory entity, Boolean asTree, String sortKey, String sortType) {
|
||||
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
|
||||
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.AGENT.getCode());
|
||||
if (access.isRestricted()) {
|
||||
if (access.getCategoryIds().isEmpty()) {
|
||||
return Result.ok(Collections.emptyList());
|
||||
}
|
||||
queryWrapper.in("id", access.getCategoryIds());
|
||||
}
|
||||
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
|
||||
return Result.ok(service.list(queryWrapper));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除分类前校验是否仍被 Agent 使用。
|
||||
*
|
||||
* @param ids 分类 ID 集合
|
||||
* @return 校验结果
|
||||
*/
|
||||
@Override
|
||||
protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
|
||||
for (Serializable id : ids) {
|
||||
QueryWrapper queryWrapper = QueryWrapper.create().eq(Agent::getCategoryId, id);
|
||||
List<Agent> agents = agentMapper.selectListByQuery(queryWrapper);
|
||||
if (agents != null && !agents.isEmpty()) {
|
||||
throw new BusinessException("请先删除该分类下的所有 Agent");
|
||||
}
|
||||
}
|
||||
return super.onRemoveBefore(ids);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package tech.easyflow.admin.controller.agent;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import com.mybatisflex.core.paginate.Page;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||
import tech.easyflow.agent.publish.AgentPublishAppService;
|
||||
import tech.easyflow.agent.runtime.AgentChatRequest;
|
||||
import tech.easyflow.agent.runtime.AgentDraftChatRequest;
|
||||
import tech.easyflow.agent.runtime.AgentRunService;
|
||||
import tech.easyflow.agent.service.AgentApprovalStateService;
|
||||
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
|
||||
import tech.easyflow.agent.service.AgentService;
|
||||
import tech.easyflow.agent.service.AgentToolBindingService;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
|
||||
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 javax.annotation.Resource;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static tech.easyflow.agent.entity.table.AgentTableDef.AGENT;
|
||||
|
||||
/**
|
||||
* Agent 管理端控制器。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/agent")
|
||||
public class AgentController extends BaseCurdController<AgentService, Agent> {
|
||||
|
||||
@Resource
|
||||
private AgentToolBindingService agentToolBindingService;
|
||||
@Resource
|
||||
private AgentKnowledgeBindingService agentKnowledgeBindingService;
|
||||
@Resource
|
||||
private AgentRunService agentRunService;
|
||||
@Resource
|
||||
private AgentPublishAppService agentPublishAppService;
|
||||
@Resource
|
||||
private ResourceAccessService resourceAccessService;
|
||||
@Resource
|
||||
private CategoryPermissionService categoryPermissionService;
|
||||
@Resource
|
||||
private AgentApprovalStateService agentApprovalStateService;
|
||||
@Resource
|
||||
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
|
||||
|
||||
/**
|
||||
* 创建 Agent 控制器。
|
||||
*
|
||||
* @param service Agent 服务
|
||||
*/
|
||||
public AgentController(AgentService service) {
|
||||
super(service);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 详情。
|
||||
*
|
||||
* @param id Agent ID
|
||||
* @return Agent 详情
|
||||
*/
|
||||
@GetMapping("/getDetail")
|
||||
public Result<Agent> getDetail(BigInteger id) {
|
||||
Agent agent = service.getDetail(id);
|
||||
agentApprovalStateService.fillAgentApprovalState(agent);
|
||||
return Result.ok(agent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Agent 草稿。
|
||||
*
|
||||
* @param agent Agent 草稿
|
||||
* @return Agent 详情
|
||||
*/
|
||||
@Override
|
||||
@PostMapping("save")
|
||||
public Result<?> save(@JsonBody Agent agent) {
|
||||
return Result.ok(service.saveDraft(agent));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Agent 草稿。
|
||||
*
|
||||
* @param agent Agent 草稿
|
||||
* @return Agent 详情
|
||||
*/
|
||||
@Override
|
||||
@PostMapping("update")
|
||||
public Result<?> update(@JsonBody Agent agent) {
|
||||
return Result.ok(service.updateDraft(agent));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 Agent 列表。
|
||||
*
|
||||
* @param entity 查询条件
|
||||
* @param asTree 是否转树
|
||||
* @param sortKey 排序字段
|
||||
* @param sortType 排序方式
|
||||
* @return Agent 列表
|
||||
*/
|
||||
@Override
|
||||
public Result<List<Agent>> list(Agent entity, Boolean asTree, String sortKey, String sortType) {
|
||||
HttpServletRequest request = currentRequest();
|
||||
QueryWrapper queryWrapper = request == null ? QueryWrapper.create() : buildQueryWrapper(request);
|
||||
if (!applyCategoryPermission(queryWrapper)) {
|
||||
return Result.ok(Collections.emptyList());
|
||||
}
|
||||
applyPublishedOnlyFilter(queryWrapper);
|
||||
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
|
||||
List<Agent> agents = service.list(queryWrapper);
|
||||
if (isPublishedOnlyRequest()) {
|
||||
agents = agents.stream().map(agent -> service.fromSnapshot(agent.getPublishedSnapshotJson())).toList();
|
||||
}
|
||||
agentApprovalStateService.fillAgentApprovalState(agents);
|
||||
aiResourceCreatorNameSupport.fillAgentCreatorNames(agents);
|
||||
return Result.ok(agents);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行 Agent 纯文本聊天。
|
||||
*
|
||||
* @param request 聊天请求
|
||||
* @return SSE Emitter
|
||||
*/
|
||||
@PostMapping("chat")
|
||||
public SseEmitter chat(@JsonBody AgentChatRequest request) {
|
||||
return agentRunService.chat(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行 Agent 草稿态纯文本试用。
|
||||
*
|
||||
* @param request 草稿试用请求
|
||||
* @return SSE Emitter
|
||||
*/
|
||||
@PostMapping("/chat/draft")
|
||||
public SseEmitter chatDraft(@JsonBody AgentDraftChatRequest request) {
|
||||
return agentRunService.chatDraft(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 Agent 草稿试运行会话。
|
||||
*
|
||||
* @param sessionId 草稿试运行会话 ID
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PostMapping("/chat/draft/clear")
|
||||
public Result<Void> clearDraftSession(@JsonBody(value = "sessionId", required = true) String sessionId) {
|
||||
agentRunService.clearDraftSession(sessionId);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批准工具执行。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
* @param resumeToken 恢复令牌
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PostMapping("/run/approve")
|
||||
public Result<Void> approve(@JsonBody("requestId") String requestId,
|
||||
@JsonBody(value = "resumeToken", required = true) String resumeToken) {
|
||||
agentRunService.approve(requestId, resumeToken);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝工具执行。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
* @param resumeToken 恢复令牌
|
||||
* @param reason 拒绝原因
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PostMapping("/run/reject")
|
||||
public Result<Void> reject(@JsonBody("requestId") String requestId,
|
||||
@JsonBody(value = "resumeToken", required = true) String resumeToken,
|
||||
@JsonBody("reason") String reason) {
|
||||
agentRunService.reject(requestId, resumeToken, reason);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Agent 工具绑定。
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
* @param bindings 工具绑定
|
||||
* @return 保存后的启用绑定
|
||||
*/
|
||||
@PostMapping("/toolBinding/update")
|
||||
@SaCheckPermission("/api/v1/agent/save")
|
||||
public Result<List<AgentToolBinding>> updateToolBinding(@JsonBody(value = "agentId", required = true) BigInteger agentId,
|
||||
@JsonBody("bindings") List<AgentToolBinding> bindings) {
|
||||
return Result.ok(agentToolBindingService.replaceBindings(agentId, bindings));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Agent 知识库绑定。
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
* @param bindings 知识库绑定
|
||||
* @return 保存后的启用绑定
|
||||
*/
|
||||
@PostMapping("/knowledgeBinding/update")
|
||||
@SaCheckPermission("/api/v1/agent/save")
|
||||
public Result<List<AgentKnowledgeBinding>> updateKnowledgeBinding(@JsonBody(value = "agentId", required = true) BigInteger agentId,
|
||||
@JsonBody("bindings") List<AgentKnowledgeBinding> bindings) {
|
||||
return Result.ok(agentKnowledgeBindingService.replaceBindings(agentId, bindings));
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交发布审批。
|
||||
*
|
||||
* @param id Agent ID
|
||||
* @return 审批实例 ID
|
||||
*/
|
||||
@PostMapping("/submitPublishApproval")
|
||||
@SaCheckPermission("/api/v1/agent/save")
|
||||
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
|
||||
return buildApprovalActionResult(agentPublishAppService.submitPublishApproval(id), "已提交发布审批", "已直接发布");
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交下线审批。
|
||||
*
|
||||
* @param id Agent ID
|
||||
* @return 审批实例 ID
|
||||
*/
|
||||
@PostMapping("/submitOfflineApproval")
|
||||
@SaCheckPermission("/api/v1/agent/save")
|
||||
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
|
||||
return buildApprovalActionResult(agentPublishAppService.submitOfflineApproval(id), "已提交下线审批", "已直接下线");
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交删除审批。
|
||||
*
|
||||
* @param id Agent ID
|
||||
* @return 审批实例 ID
|
||||
*/
|
||||
@PostMapping("/submitDeleteApproval")
|
||||
@SaCheckPermission("/api/v1/agent/remove")
|
||||
public Result<BigInteger> submitDeleteApproval(@JsonBody("id") BigInteger id) {
|
||||
return buildApprovalActionResult(agentPublishAppService.submitDeleteApproval(id), "已提交删除审批", "已直接删除");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
|
||||
for (Serializable id : ids) {
|
||||
Agent agent = service.getById(String.valueOf(id));
|
||||
if (agent != null) {
|
||||
resourceAccessService.assertAccess(CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限删除该 Agent");
|
||||
}
|
||||
}
|
||||
agentToolBindingService.remove(QueryWrapper.create().in("agent_id", ids));
|
||||
agentKnowledgeBindingService.remove(QueryWrapper.create().in("agent_id", ids));
|
||||
return super.onRemoveBefore(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 Agent 分页。
|
||||
*
|
||||
* @param page 分页参数
|
||||
* @param queryWrapper 查询条件
|
||||
* @return Agent 分页
|
||||
*/
|
||||
@Override
|
||||
protected Page<Agent> queryPage(Page<Agent> page, QueryWrapper queryWrapper) {
|
||||
if (!applyCategoryPermission(queryWrapper)) {
|
||||
return new Page<>(Collections.emptyList(), page.getPageNumber(), page.getPageSize(), 0L);
|
||||
}
|
||||
applyPublishedOnlyFilter(queryWrapper);
|
||||
Page<Agent> result = super.queryPage(page, queryWrapper);
|
||||
if (isPublishedOnlyRequest()) {
|
||||
result.setRecords(result.getRecords().stream().map(agent -> service.fromSnapshot(agent.getPublishedSnapshotJson())).toList());
|
||||
}
|
||||
agentApprovalStateService.fillAgentApprovalState(result.getRecords());
|
||||
aiResourceCreatorNameSupport.fillAgentCreatorNames(result.getRecords());
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean applyCategoryPermission(QueryWrapper queryWrapper) {
|
||||
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.AGENT.getCode());
|
||||
if (!access.isRestricted()) {
|
||||
return true;
|
||||
}
|
||||
if (access.getCategoryIds().isEmpty()) {
|
||||
queryWrapper.eq(Agent::getCreatedBy, access.getAccountId());
|
||||
return true;
|
||||
}
|
||||
queryWrapper.and(AGENT.CREATED_BY.eq(access.getAccountId()).or(AGENT.CATEGORY_ID.in(access.getCategoryIds())));
|
||||
return true;
|
||||
}
|
||||
|
||||
private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) {
|
||||
if (isPublishedOnlyRequest()) {
|
||||
queryWrapper.eq("publish_status", PublishStatus.PUBLISHED.getCode());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPublishedOnlyRequest() {
|
||||
HttpServletRequest request = currentRequest();
|
||||
if (request == null) {
|
||||
return false;
|
||||
}
|
||||
return "true".equalsIgnoreCase(request.getParameter("publishedOnly"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 HTTP 请求。
|
||||
*
|
||||
* @return 当前请求,不在 Web 请求上下文中时返回 null
|
||||
*/
|
||||
private HttpServletRequest currentRequest() {
|
||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attributes == null) {
|
||||
return null;
|
||||
}
|
||||
return attributes.getRequest();
|
||||
}
|
||||
|
||||
private Result<BigInteger> buildApprovalActionResult(ApprovalActionResult actionResult,
|
||||
String approvalMessage,
|
||||
String directMessage) {
|
||||
return Result.ok(actionResult.isApprovalRequired() ? approvalMessage : directMessage, actionResult.getInstanceId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package tech.easyflow.admin.controller.agent;
|
||||
|
||||
import com.mybatisflex.core.keygen.impl.SnowFlakeIDKeyGenerator;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceConversationView;
|
||||
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionDetailView;
|
||||
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionPage;
|
||||
import tech.easyflow.admin.service.agent.AgentSessionService;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
||||
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Agent 管理端会话控制器。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/agent/session")
|
||||
public class AgentSessionController {
|
||||
|
||||
private final AgentSessionService agentSessionService;
|
||||
|
||||
/**
|
||||
* 创建 Agent 管理端会话控制器。
|
||||
*
|
||||
* @param agentSessionService Agent 会话服务
|
||||
*/
|
||||
public AgentSessionController(AgentSessionService agentSessionService) {
|
||||
this.agentSessionService = agentSessionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Agent 会话 ID。
|
||||
*
|
||||
* @return 会话 ID 字符串
|
||||
*/
|
||||
@GetMapping("/generateId")
|
||||
public Result<String> generateId() {
|
||||
long nextId = new SnowFlakeIDKeyGenerator().nextId();
|
||||
return Result.ok(String.valueOf(nextId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 Agent 会话分页。
|
||||
*
|
||||
* @param agentId Agent ID,可为空
|
||||
* @param query 分页参数
|
||||
* @return 会话分页
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
public Result<ChatWorkspaceSessionPage> list(BigInteger agentId, ChatPageQuery query) {
|
||||
return Result.ok(agentSessionService.queryCurrentUserSessions(currentAccount(), agentId, query));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 Agent 会话详情。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @return 会话详情
|
||||
*/
|
||||
@GetMapping("/{sessionId}")
|
||||
public Result<ChatWorkspaceSessionDetailView> detail(@PathVariable BigInteger sessionId) {
|
||||
return Result.ok(agentSessionService.getCurrentUserSession(currentAccount(), sessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 Agent 会话消息。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param query 分页参数
|
||||
* @return 消息分页
|
||||
*/
|
||||
@GetMapping("/{sessionId}/messages")
|
||||
public Result<ChatHistoryPage> messages(@PathVariable BigInteger sessionId, ChatPageQuery query) {
|
||||
return Result.ok(agentSessionService.queryCurrentUserMessages(currentAccount(), sessionId, query));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 Agent 完整会话。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @return 完整会话
|
||||
*/
|
||||
@GetMapping("/{sessionId}/conversation")
|
||||
public Result<ChatWorkspaceConversationView> conversation(@PathVariable BigInteger sessionId) {
|
||||
return Result.ok(agentSessionService.getCurrentUserConversation(currentAccount(), sessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名 Agent 会话。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param title 新标题
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PostMapping("/{sessionId}/rename")
|
||||
public Result<Void> rename(@PathVariable BigInteger sessionId,
|
||||
@JsonBody(value = "title", required = true) String title) {
|
||||
agentSessionService.renameCurrentUserSession(currentAccount(), sessionId, title);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Agent 会话临时知识库。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param knowledgeIds 临时知识库 ID
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PostMapping("/{sessionId}/extraKnowledges")
|
||||
public Result<ChatWorkspaceSessionDetailView> saveExtraKnowledges(@PathVariable BigInteger sessionId,
|
||||
@JsonBody(value = "knowledgeIds") List<BigInteger> knowledgeIds) {
|
||||
return Result.ok(agentSessionService.saveCurrentUserExtraKnowledges(currentAccount(), sessionId, knowledgeIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Agent 会话。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PostMapping("/{sessionId}/delete")
|
||||
public Result<Void> delete(@PathVariable BigInteger sessionId) {
|
||||
agentSessionService.deleteCurrentUserSession(currentAccount(), sessionId);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
private LoginAccount currentAccount() {
|
||||
return SaTokenUtil.getLoginAccount();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import tech.easyflow.ai.entity.Model;
|
||||
import tech.easyflow.ai.service.DocumentChunkService;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
|
||||
import tech.easyflow.common.annotation.UsePermission;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
@@ -93,6 +94,7 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
|
||||
if (documentStore == null) {
|
||||
return Result.fail(2, "知识库没有配置向量库");
|
||||
}
|
||||
try {
|
||||
// 设置向量模型
|
||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
@@ -109,6 +111,9 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
|
||||
document.setMetadataMap(metadata);
|
||||
StoreResult result = documentStore.update(document, options); // 更新已有记录
|
||||
return Result.ok(result);
|
||||
} finally {
|
||||
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||
}
|
||||
}
|
||||
return Result.ok(false);
|
||||
}
|
||||
@@ -135,6 +140,7 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
|
||||
if (documentStore == null) {
|
||||
return Result.fail(3, "知识库没有配置向量库");
|
||||
}
|
||||
try {
|
||||
// 设置向量模型
|
||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
@@ -149,5 +155,8 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
|
||||
documentChunkService.removeChunk(knowledge, chunkId);
|
||||
|
||||
return super.remove(chunkId);
|
||||
} finally {
|
||||
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package tech.easyflow.admin.controller.ai;
|
||||
|
||||
import com.easyagents.mcp.client.McpEnvironmentCheckResult;
|
||||
import com.mybatisflex.core.paginate.Page;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -64,6 +65,11 @@ public class McpController extends BaseCurdController<McpService, Mcp> {
|
||||
return Result.ok(service.getMcpTools(id));
|
||||
}
|
||||
|
||||
@PostMapping("/check")
|
||||
public Result<McpEnvironmentCheckResult> check(@JsonBody("configJson") String configJson) {
|
||||
return Result.ok(service.checkMcp(configJson));
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("pageTools")
|
||||
public Result<Page<Mcp>> pageTools(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize) {
|
||||
|
||||
@@ -42,6 +42,7 @@ import tech.easyflow.ai.service.KnowledgeEmbeddingService;
|
||||
import tech.easyflow.ai.service.KnowledgeShareAuditService;
|
||||
import tech.easyflow.ai.service.KnowledgeShareService;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
|
||||
import tech.easyflow.ai.vo.FaqImportResultVo;
|
||||
import tech.easyflow.ai.vo.KnowledgeShareAuthContext;
|
||||
import tech.easyflow.ai.vo.KnowledgeShareViewDetail;
|
||||
@@ -520,6 +521,7 @@ public class ShareKnowledgeController {
|
||||
if (documentStore == null) {
|
||||
return Result.fail(2, "知识库没有配置向量库");
|
||||
}
|
||||
try {
|
||||
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
return Result.fail(3, "知识库没有配置向量模型");
|
||||
@@ -533,6 +535,9 @@ public class ShareKnowledgeController {
|
||||
audit(context, "更新分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
||||
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", documentChunk.getId()));
|
||||
return Result.ok(result);
|
||||
} finally {
|
||||
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||
}
|
||||
}
|
||||
return Result.ok(false);
|
||||
}
|
||||
@@ -559,6 +564,7 @@ public class ShareKnowledgeController {
|
||||
if (documentStore == null) {
|
||||
return Result.fail(2, "知识库没有配置向量库");
|
||||
}
|
||||
try {
|
||||
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
return Result.fail(3, "知识库没有配置向量模型");
|
||||
@@ -570,6 +576,9 @@ public class ShareKnowledgeController {
|
||||
audit(context, "删除分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
||||
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", chunkId));
|
||||
return Result.ok(true);
|
||||
} finally {
|
||||
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package tech.easyflow.admin.controller.ai.support;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.ai.entity.Bot;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.entity.Plugin;
|
||||
@@ -66,6 +67,15 @@ public class AiResourceCreatorNameSupport {
|
||||
fillCreatorNames(plugins, Plugin::getCreatedBy, Plugin::setCreatedByName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量填充 Agent 创建人名称。
|
||||
*
|
||||
* @param agents Agent 集合
|
||||
*/
|
||||
public void fillAgentCreatorNames(Collection<Agent> agents) {
|
||||
fillCreatorNames(agents, Agent::getCreatedBy, Agent::setCreatedByName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用的创建人名称填充逻辑。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
package tech.easyflow.admin.service.agent;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import tech.easyflow.admin.dto.chatworkspace.*;
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.agent.runtime.AgentRuntimeStateCleanupService;
|
||||
import tech.easyflow.agent.service.AgentService;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
|
||||
import tech.easyflow.chatlog.domain.dto.*;
|
||||
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
||||
import tech.easyflow.chatlog.service.ChatSessionCommandService;
|
||||
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
||||
import tech.easyflow.chatlog.support.ChatJsonSupport;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
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 java.math.BigInteger;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Agent 管理端会话服务。
|
||||
*/
|
||||
@Service
|
||||
public class AgentSessionService {
|
||||
|
||||
private static final String ASSISTANT_CODE = "AGENT";
|
||||
|
||||
private final ChatSessionQueryService chatSessionQueryService;
|
||||
private final ChatSessionCommandService chatSessionCommandService;
|
||||
private final AgentService agentService;
|
||||
private final DocumentCollectionService documentCollectionService;
|
||||
private final ResourceAccessService resourceAccessService;
|
||||
private final AgentRuntimeStateCleanupService agentRuntimeStateCleanupService;
|
||||
private final ChatJsonSupport chatJsonSupport;
|
||||
|
||||
/**
|
||||
* 创建 Agent 管理端会话服务。
|
||||
*
|
||||
* @param chatSessionQueryService 聊天会话查询服务
|
||||
* @param chatSessionCommandService 聊天会话命令服务
|
||||
* @param agentService Agent 服务
|
||||
* @param documentCollectionService 知识库服务
|
||||
* @param resourceAccessService 资源访问服务
|
||||
* @param agentRuntimeStateCleanupService Agent 运行态清理服务
|
||||
* @param chatJsonSupport 聊天 JSON 工具
|
||||
*/
|
||||
public AgentSessionService(ChatSessionQueryService chatSessionQueryService,
|
||||
ChatSessionCommandService chatSessionCommandService,
|
||||
AgentService agentService,
|
||||
DocumentCollectionService documentCollectionService,
|
||||
ResourceAccessService resourceAccessService,
|
||||
AgentRuntimeStateCleanupService agentRuntimeStateCleanupService,
|
||||
ChatJsonSupport chatJsonSupport) {
|
||||
this.chatSessionQueryService = chatSessionQueryService;
|
||||
this.chatSessionCommandService = chatSessionCommandService;
|
||||
this.agentService = agentService;
|
||||
this.documentCollectionService = documentCollectionService;
|
||||
this.resourceAccessService = resourceAccessService;
|
||||
this.agentRuntimeStateCleanupService = agentRuntimeStateCleanupService;
|
||||
this.chatJsonSupport = chatJsonSupport;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户的 Agent 会话分页。
|
||||
*
|
||||
* @param account 当前登录账号
|
||||
* @param agentId Agent ID
|
||||
* @param query 分页参数
|
||||
* @return Agent 会话分页
|
||||
*/
|
||||
public ChatWorkspaceSessionPage queryCurrentUserSessions(LoginAccount account, BigInteger agentId, ChatPageQuery query) {
|
||||
ChatSessionPage page = chatSessionQueryService.pageSessions(account.getId(), agentId, ASSISTANT_CODE, query);
|
||||
Map<BigInteger, AgentAvailability> availabilityMap = resolveAgentAvailability(page.getRecords());
|
||||
ChatWorkspaceSessionPage result = new ChatWorkspaceSessionPage();
|
||||
result.setTotal(page.getTotal());
|
||||
result.setPageNumber(page.getPageNumber());
|
||||
result.setPageSize(page.getPageSize());
|
||||
List<ChatWorkspaceSessionView> records = new ArrayList<>();
|
||||
for (ChatSessionSummary summary : page.getRecords()) {
|
||||
records.add(toSessionView(summary, availabilityMap.get(summary.getAssistantId())));
|
||||
}
|
||||
result.setRecords(records);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户的 Agent 会话详情。
|
||||
*
|
||||
* @param account 当前登录账号
|
||||
* @param sessionId 会话 ID
|
||||
* @return Agent 会话详情
|
||||
*/
|
||||
public ChatWorkspaceSessionDetailView getCurrentUserSession(LoginAccount account, BigInteger sessionId) {
|
||||
ChatSessionSummary summary = requireUserAgentSession(account, sessionId);
|
||||
AgentAvailability availability = resolveAgentAvailability(List.of(summary)).get(summary.getAssistantId());
|
||||
ChatWorkspaceSessionDetailView detail = new ChatWorkspaceSessionDetailView();
|
||||
fillSessionView(detail, summary, availability);
|
||||
Agent displayAgent = availability == null ? null : availability.displayAgent();
|
||||
detail.setAssistant(toAssistantView(displayAgent, summary));
|
||||
detail.setBoundKnowledges(resolveBoundKnowledges(displayAgent));
|
||||
ExtraKnowledgeResolution extraKnowledgeResolution = resolveExtraKnowledges(summary);
|
||||
detail.setExtraKnowledges(extraKnowledgeResolution.validKnowledges());
|
||||
detail.setRemovedExtraKnowledgeNames(extraKnowledgeResolution.removedNames());
|
||||
if (extraKnowledgeResolution.shouldSync()) {
|
||||
syncSessionExtraKnowledges(summary, extraKnowledgeResolution.validKnowledgeIds(), account.getId());
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户的 Agent 会话消息。
|
||||
*
|
||||
* @param account 当前登录账号
|
||||
* @param sessionId 会话 ID
|
||||
* @param query 分页参数
|
||||
* @return 消息分页
|
||||
*/
|
||||
public ChatHistoryPage queryCurrentUserMessages(LoginAccount account, BigInteger sessionId, ChatPageQuery query) {
|
||||
requireUserAgentSession(account, sessionId);
|
||||
return chatSessionQueryService.pageMainlineMessages(sessionId, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户的 Agent 完整会话。
|
||||
*
|
||||
* @param account 当前登录账号
|
||||
* @param sessionId 会话 ID
|
||||
* @return 完整会话
|
||||
*/
|
||||
public ChatWorkspaceConversationView getCurrentUserConversation(LoginAccount account, BigInteger sessionId) {
|
||||
requireUserAgentSession(account, sessionId);
|
||||
List<ChatMessageRecord> records = chatSessionQueryService.listMainlineMessages(sessionId);
|
||||
ChatWorkspaceConversationView view = new ChatWorkspaceConversationView();
|
||||
view.setRecords(records);
|
||||
view.setTotal(records.size());
|
||||
return view;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名当前用户的 Agent 会话。
|
||||
*
|
||||
* @param account 当前登录账号
|
||||
* @param sessionId 会话 ID
|
||||
* @param title 新标题
|
||||
*/
|
||||
public void renameCurrentUserSession(LoginAccount account, BigInteger sessionId, String title) {
|
||||
if (!StringUtils.hasText(title)) {
|
||||
throw new BusinessException("标题不能为空");
|
||||
}
|
||||
requireUserAgentSession(account, sessionId);
|
||||
chatSessionCommandService.renameSession(sessionId, account.getId(), title.trim(), account.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存当前用户 Agent 会话的临时知识库。
|
||||
*
|
||||
* @param account 当前登录账号
|
||||
* @param sessionId 会话 ID
|
||||
* @param knowledgeIds 临时知识库 ID
|
||||
* @return 更新后的会话详情
|
||||
*/
|
||||
public ChatWorkspaceSessionDetailView saveCurrentUserExtraKnowledges(LoginAccount account,
|
||||
BigInteger sessionId,
|
||||
List<BigInteger> knowledgeIds) {
|
||||
ChatSessionSummary summary = requireUserAgentSession(account, sessionId);
|
||||
ExtraKnowledgeResolution resolution = resolveVisibleKnowledgeViews(normalizeExtraKnowledgeIds(knowledgeIds));
|
||||
if (!resolution.removedNames().isEmpty()) {
|
||||
throw new BusinessException("所选知识库已失效或无权限使用");
|
||||
}
|
||||
syncSessionExtraKnowledges(summary, resolution.validKnowledgeIds(), account.getId());
|
||||
return getCurrentUserSession(account, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除当前用户的 Agent 会话。
|
||||
*
|
||||
* @param account 当前登录账号
|
||||
* @param sessionId 会话 ID
|
||||
*/
|
||||
public void deleteCurrentUserSession(LoginAccount account, BigInteger sessionId) {
|
||||
requireUserAgentSession(account, sessionId);
|
||||
agentRuntimeStateCleanupService.clearChatSession(sessionId, account.getId());
|
||||
chatSessionCommandService.deleteSession(sessionId, account.getId(), account.getId());
|
||||
}
|
||||
|
||||
private ChatSessionSummary requireUserAgentSession(LoginAccount account, BigInteger sessionId) {
|
||||
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
|
||||
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())
|
||||
|| !ASSISTANT_CODE.equals(summary.getAssistantCode())) {
|
||||
throw new BusinessException("Agent 会话不存在");
|
||||
}
|
||||
if (!Objects.equals(summary.getUserId(), account.getId())) {
|
||||
throw new BusinessException("无权访问该 Agent 会话");
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
private Map<BigInteger, AgentAvailability> resolveAgentAvailability(List<ChatSessionSummary> sessions) {
|
||||
Map<BigInteger, AgentAvailability> result = new LinkedHashMap<>();
|
||||
if (sessions == null || sessions.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
Set<BigInteger> agentIds = new LinkedHashSet<>();
|
||||
for (ChatSessionSummary session : sessions) {
|
||||
if (session != null && session.getAssistantId() != null) {
|
||||
agentIds.add(session.getAssistantId());
|
||||
}
|
||||
}
|
||||
if (agentIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<Agent> agents = agentService.list(QueryWrapper.create().in("id", agentIds));
|
||||
Map<BigInteger, Agent> agentMap = new LinkedHashMap<>();
|
||||
for (Agent agent : agents) {
|
||||
agentMap.put(agent.getId(), agent);
|
||||
}
|
||||
for (BigInteger agentId : agentIds) {
|
||||
Agent currentAgent = agentMap.get(agentId);
|
||||
if (currentAgent == null) {
|
||||
result.put(agentId, new AgentAvailability(false, ChatWorkspaceReadOnlyReason.ASSISTANT_DELETED, null));
|
||||
continue;
|
||||
}
|
||||
if (!resourceAccessService.canAccess(CategoryResourceType.AGENT, currentAgent, ResourceAction.USE)) {
|
||||
result.put(agentId, new AgentAvailability(false, ChatWorkspaceReadOnlyReason.NO_PERMISSION, null));
|
||||
continue;
|
||||
}
|
||||
boolean online = Integer.valueOf(1).equals(currentAgent.getStatus())
|
||||
&& PublishStatus.from(currentAgent.getPublishStatus()) == PublishStatus.PUBLISHED;
|
||||
result.put(agentId, new AgentAvailability(
|
||||
online,
|
||||
online ? null : ChatWorkspaceReadOnlyReason.ASSISTANT_OFFLINE,
|
||||
toDisplayAgent(currentAgent)
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Agent toDisplayAgent(Agent currentAgent) {
|
||||
if (currentAgent.getPublishedSnapshotJson() != null && !currentAgent.getPublishedSnapshotJson().isEmpty()) {
|
||||
return agentService.fromSnapshot(currentAgent.getPublishedSnapshotJson());
|
||||
}
|
||||
return currentAgent;
|
||||
}
|
||||
|
||||
private ChatWorkspaceSessionView toSessionView(ChatSessionSummary summary, AgentAvailability availability) {
|
||||
ChatWorkspaceSessionView view = new ChatWorkspaceSessionView();
|
||||
fillSessionView(view, summary, availability);
|
||||
return view;
|
||||
}
|
||||
|
||||
private void fillSessionView(ChatWorkspaceSessionView view, ChatSessionSummary summary, AgentAvailability availability) {
|
||||
view.setSessionId(summary.getId());
|
||||
view.setAssistantId(summary.getAssistantId());
|
||||
view.setAssistantCode(summary.getAssistantCode());
|
||||
view.setAssistantName(summary.getAssistantName());
|
||||
view.setTitle(summary.getTitle());
|
||||
view.setLastMessagePreview(summary.getLastMessagePreview());
|
||||
view.setMessageCount(summary.getMessageCount());
|
||||
view.setAccessAt(summary.getAccessAt());
|
||||
view.setLastMessageAt(summary.getLastMessageAt());
|
||||
view.setContinuable(availability != null && availability.continuable());
|
||||
view.setReadOnlyReason(availability == null ? ChatWorkspaceReadOnlyReason.ASSISTANT_DELETED : availability.reason());
|
||||
}
|
||||
|
||||
private ChatWorkspaceAssistantView toAssistantView(Agent agent, ChatSessionSummary summary) {
|
||||
ChatWorkspaceAssistantView view = new ChatWorkspaceAssistantView();
|
||||
if (agent != null) {
|
||||
view.setId(agent.getId());
|
||||
view.setAlias(agent.getId() == null ? null : agent.getId().toString());
|
||||
view.setTitle(agent.getName());
|
||||
view.setDescription(agent.getDescription());
|
||||
view.setIcon(agent.getAvatar());
|
||||
return view;
|
||||
}
|
||||
view.setId(summary == null ? null : summary.getAssistantId());
|
||||
view.setAlias(summary == null ? null : summary.getAssistantCode());
|
||||
view.setTitle(summary == null ? null : summary.getAssistantName());
|
||||
return view;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<ChatWorkspaceKnowledgeView> resolveBoundKnowledges(Agent displayAgent) {
|
||||
if (displayAgent == null || displayAgent.getKnowledgeBindings() == null || displayAgent.getKnowledgeBindings().isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<BigInteger> knowledgeIds = displayAgent.getKnowledgeBindings().stream()
|
||||
.map(binding -> binding.getKnowledgeId())
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
if (knowledgeIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<DocumentCollection> collections = documentCollectionService.listByIds(knowledgeIds);
|
||||
Map<BigInteger, DocumentCollection> collectionMap = new LinkedHashMap<>();
|
||||
for (DocumentCollection collection : collections) {
|
||||
collectionMap.put(collection.getId(), collection);
|
||||
}
|
||||
List<ChatWorkspaceKnowledgeView> views = new ArrayList<>();
|
||||
for (BigInteger knowledgeId : knowledgeIds) {
|
||||
DocumentCollection collection = collectionMap.get(knowledgeId);
|
||||
if (collection == null || PublishStatus.from(collection.getPublishStatus()) != PublishStatus.PUBLISHED) {
|
||||
continue;
|
||||
}
|
||||
views.add(toKnowledgeView(documentCollectionService.toPublishedView(collection)));
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
private ChatWorkspaceKnowledgeView toKnowledgeView(DocumentCollection collection) {
|
||||
ChatWorkspaceKnowledgeView view = new ChatWorkspaceKnowledgeView();
|
||||
view.setId(collection.getId());
|
||||
view.setAlias(collection.getAlias());
|
||||
view.setTitle(collection.getTitle());
|
||||
view.setDescription(collection.getDescription());
|
||||
view.setIcon(collection.getIcon());
|
||||
return view;
|
||||
}
|
||||
|
||||
private ExtraKnowledgeResolution resolveExtraKnowledges(ChatSessionSummary summary) {
|
||||
ChatSessionExtPayload payload = chatJsonSupport.fromJson(summary.getExtJson(), ChatSessionExtPayload.class);
|
||||
List<BigInteger> extraKnowledgeIds = payload == null ? List.of() : payload.getExtraKnowledgeIds();
|
||||
return resolveVisibleKnowledgeViews(extraKnowledgeIds);
|
||||
}
|
||||
|
||||
private ExtraKnowledgeResolution resolveVisibleKnowledgeViews(List<BigInteger> knowledgeIds) {
|
||||
if (knowledgeIds == null || knowledgeIds.isEmpty()) {
|
||||
return new ExtraKnowledgeResolution(List.of(), List.of(), List.of(), false);
|
||||
}
|
||||
List<BigInteger> normalizedIds = normalizeExtraKnowledgeIds(knowledgeIds);
|
||||
if (normalizedIds.isEmpty()) {
|
||||
return new ExtraKnowledgeResolution(List.of(), List.of(), List.of(), false);
|
||||
}
|
||||
List<DocumentCollection> collections = documentCollectionService.listByIds(normalizedIds);
|
||||
Map<BigInteger, DocumentCollection> collectionMap = new LinkedHashMap<>();
|
||||
for (DocumentCollection collection : collections) {
|
||||
collectionMap.put(collection.getId(), collection);
|
||||
}
|
||||
List<ChatWorkspaceKnowledgeView> validKnowledges = new ArrayList<>();
|
||||
List<BigInteger> validKnowledgeIds = new ArrayList<>();
|
||||
List<String> removedNames = new ArrayList<>();
|
||||
boolean changed = false;
|
||||
for (BigInteger knowledgeId : normalizedIds) {
|
||||
DocumentCollection current = collectionMap.get(knowledgeId);
|
||||
if (current == null) {
|
||||
removedNames.add("知识库#" + knowledgeId);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (PublishStatus.from(current.getPublishStatus()) != PublishStatus.PUBLISHED) {
|
||||
removedNames.add(current.getTitle());
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (!resourceAccessService.canAccess(CategoryResourceType.KNOWLEDGE, current, ResourceAction.USE)) {
|
||||
removedNames.add(current.getTitle());
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
validKnowledges.add(toKnowledgeView(documentCollectionService.toPublishedView(current)));
|
||||
validKnowledgeIds.add(current.getId());
|
||||
}
|
||||
if (!Objects.equals(normalizedIds, validKnowledgeIds)) {
|
||||
changed = true;
|
||||
}
|
||||
return new ExtraKnowledgeResolution(validKnowledges, validKnowledgeIds, removedNames, changed);
|
||||
}
|
||||
|
||||
private List<BigInteger> normalizeExtraKnowledgeIds(List<BigInteger> knowledgeIds) {
|
||||
if (knowledgeIds == null || knowledgeIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<BigInteger> normalizedIds = new ArrayList<>();
|
||||
for (BigInteger knowledgeId : knowledgeIds) {
|
||||
if (knowledgeId != null && !normalizedIds.contains(knowledgeId)) {
|
||||
normalizedIds.add(knowledgeId);
|
||||
}
|
||||
}
|
||||
if (normalizedIds.size() > 3) {
|
||||
throw new BusinessException("临时知识库最多选择 3 个");
|
||||
}
|
||||
return normalizedIds;
|
||||
}
|
||||
|
||||
private void syncSessionExtraKnowledges(ChatSessionSummary summary, List<BigInteger> validKnowledgeIds, BigInteger operatorId) {
|
||||
ChatSessionExtPayload payload = new ChatSessionExtPayload();
|
||||
payload.setExtraKnowledgeIds(validKnowledgeIds);
|
||||
ChatSessionUpsertCommand command = new ChatSessionUpsertCommand();
|
||||
command.setSessionId(summary.getId());
|
||||
command.setTenantId(summary.getTenantId());
|
||||
command.setDeptId(summary.getDeptId());
|
||||
command.setUserId(summary.getUserId());
|
||||
command.setUserAccount(summary.getUserAccount());
|
||||
command.setAssistantId(summary.getAssistantId());
|
||||
command.setAssistantCode(summary.getAssistantCode());
|
||||
command.setAssistantName(summary.getAssistantName());
|
||||
command.setTitle(summary.getTitle());
|
||||
command.setExtJson(chatJsonSupport.toJson(payload));
|
||||
command.setOperatorId(operatorId);
|
||||
chatSessionCommandService.createOrTouchSession(command);
|
||||
}
|
||||
|
||||
private record AgentAvailability(boolean continuable,
|
||||
ChatWorkspaceReadOnlyReason reason,
|
||||
Agent displayAgent) {
|
||||
}
|
||||
|
||||
private record ExtraKnowledgeResolution(List<ChatWorkspaceKnowledgeView> validKnowledges,
|
||||
List<BigInteger> validKnowledgeIds,
|
||||
List<String> removedNames,
|
||||
boolean shouldSync) {
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import tech.easyflow.ai.service.KnowledgeShareAuditService;
|
||||
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
import tech.easyflow.ai.service.impl.KnowledgeSharePermissionServiceImpl;
|
||||
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
|
||||
import tech.easyflow.ai.vo.FaqImportResultVo;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.filestorage.FileStorageService;
|
||||
@@ -342,6 +343,7 @@ public class PublicKnowledgeShareController {
|
||||
if (documentStore == null) {
|
||||
return Result.fail(2, "知识库没有配置向量库");
|
||||
}
|
||||
try {
|
||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
return Result.fail(3, "知识库没有配置向量模型");
|
||||
@@ -354,6 +356,9 @@ public class PublicKnowledgeShareController {
|
||||
StoreResult result = documentStore.update(doc, options);
|
||||
audit(apiKey, "API更新文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", documentChunk.getId()));
|
||||
return Result.ok(result);
|
||||
} finally {
|
||||
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||
}
|
||||
}
|
||||
return Result.ok(false);
|
||||
}
|
||||
@@ -376,6 +381,7 @@ public class PublicKnowledgeShareController {
|
||||
if (documentStore == null) {
|
||||
return Result.fail(2, "知识库没有配置向量库");
|
||||
}
|
||||
try {
|
||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
return Result.fail(3, "知识库没有配置向量模型");
|
||||
@@ -386,6 +392,9 @@ public class PublicKnowledgeShareController {
|
||||
documentChunkService.removeById(chunkId);
|
||||
audit(apiKey, "API删除文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", chunkId));
|
||||
return Result.ok(true);
|
||||
} finally {
|
||||
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-autoconfigure</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-actuator</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.clickhouse</groupId>
|
||||
<artifactId>clickhouse-jdbc</artifactId>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package tech.easyflow.common.analyticaldb.support;
|
||||
|
||||
import org.springframework.boot.actuate.health.Health;
|
||||
import org.springframework.boot.actuate.health.HealthIndicator;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 分析数据库健康检查。
|
||||
*/
|
||||
@Component("analyticalDbHealthIndicator")
|
||||
public class AnalyticalDBHealthIndicator implements HealthIndicator {
|
||||
|
||||
private final AnalyticalDBHealthSupport healthSupport;
|
||||
|
||||
/**
|
||||
* 创建分析数据库健康检查器。
|
||||
*
|
||||
* @param healthSupport 分析数据库健康检查支持
|
||||
*/
|
||||
public AnalyticalDBHealthIndicator(AnalyticalDBHealthSupport healthSupport) {
|
||||
this.healthSupport = healthSupport;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查分析数据库是否可用。
|
||||
*
|
||||
* @return 健康状态
|
||||
*/
|
||||
@Override
|
||||
public Health health() {
|
||||
if (!healthSupport.enabled()) {
|
||||
return Health.up().withDetail("enabled", false).build();
|
||||
}
|
||||
try {
|
||||
healthSupport.selfCheck();
|
||||
return Health.up().withDetail("enabled", true).build();
|
||||
} catch (Exception e) {
|
||||
return Health.down(e).withDetail("enabled", true).build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package tech.easyflow.common.audio.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* 音频模块线程池配置。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "easyflow.thread-pool.scheduler")
|
||||
public class AudioThreadPoolProperties {
|
||||
|
||||
private int poolSize = 4;
|
||||
|
||||
/**
|
||||
* 获取调度线程池大小。
|
||||
*
|
||||
* @return 调度线程池大小
|
||||
*/
|
||||
public int getPoolSize() {
|
||||
return poolSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置调度线程池大小。
|
||||
*
|
||||
* @param poolSize 调度线程池大小
|
||||
*/
|
||||
public void setPoolSize(int poolSize) {
|
||||
this.poolSize = poolSize;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,38 @@
|
||||
package tech.easyflow.common.audio.socket;
|
||||
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.TaskScheduler;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
|
||||
import tech.easyflow.common.audio.config.AudioThreadPoolProperties;
|
||||
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
@EnableConfigurationProperties(AudioThreadPoolProperties.class)
|
||||
public class SchedulingConfig {
|
||||
|
||||
private final AudioThreadPoolProperties properties;
|
||||
|
||||
/**
|
||||
* 创建音频调度配置。
|
||||
*
|
||||
* @param properties 音频调度线程池配置
|
||||
*/
|
||||
public SchedulingConfig(AudioThreadPoolProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建调度线程池。
|
||||
*
|
||||
* @return 调度线程池
|
||||
*/
|
||||
@Bean
|
||||
public TaskScheduler taskScheduler() {
|
||||
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
||||
scheduler.setPoolSize(10);
|
||||
scheduler.setPoolSize(properties.getPoolSize());
|
||||
scheduler.setThreadNamePrefix("scheduled-task-");
|
||||
scheduler.setDaemon(true);
|
||||
scheduler.initialize();
|
||||
|
||||
@@ -39,7 +39,23 @@
|
||||
<artifactId>fastjson</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<version>5.12.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package tech.easyflow.common.cache;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Spring 定时任务 Redis 分布式锁。
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface DistributedScheduledLock {
|
||||
|
||||
/**
|
||||
* 获取锁使用的 Redis key。
|
||||
*
|
||||
* @return Redis 锁 key
|
||||
*/
|
||||
String key();
|
||||
|
||||
/**
|
||||
* 等待锁的秒数。
|
||||
*
|
||||
* @return 等待锁的秒数
|
||||
*/
|
||||
long waitSeconds() default 0L;
|
||||
|
||||
/**
|
||||
* 锁租约秒数。
|
||||
*
|
||||
* @return 锁租约秒数
|
||||
*/
|
||||
long leaseSeconds() default 300L;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package tech.easyflow.common.cache;
|
||||
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 定时任务分布式锁切面。
|
||||
*/
|
||||
@Aspect
|
||||
@Component
|
||||
public class DistributedScheduledLockAspect {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(DistributedScheduledLockAspect.class);
|
||||
|
||||
private final RedisLockExecutor redisLockExecutor;
|
||||
private final ScheduledExecutorService renewExecutor;
|
||||
|
||||
/**
|
||||
* 创建定时任务分布式锁切面。
|
||||
*
|
||||
* @param redisLockExecutor Redis 分布式锁执行器
|
||||
*/
|
||||
public DistributedScheduledLockAspect(RedisLockExecutor redisLockExecutor) {
|
||||
this.redisLockExecutor = redisLockExecutor;
|
||||
this.renewExecutor = Executors.newScheduledThreadPool(
|
||||
1,
|
||||
new DistributedScheduledLockThreadFactory()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拦截带分布式调度锁的定时任务。
|
||||
*
|
||||
* @param joinPoint 切点
|
||||
* @param lock 锁注解
|
||||
* @return 原方法返回值;未抢到锁时返回 null
|
||||
* @throws Throwable 原方法执行异常或 Redis 访问异常
|
||||
*/
|
||||
@Around("@annotation(lock)")
|
||||
public Object around(ProceedingJoinPoint joinPoint, DistributedScheduledLock lock) throws Throwable {
|
||||
Duration waitTimeout = Duration.ofSeconds(Math.max(lock.waitSeconds(), 0L));
|
||||
Duration leaseTimeout = Duration.ofSeconds(Math.max(lock.leaseSeconds(), 1L));
|
||||
RedisLockExecutor.LockHandle handle = redisLockExecutor.tryAcquire(lock.key(), waitTimeout, leaseTimeout);
|
||||
if (handle == null) {
|
||||
LOG.info("定时任务分布式锁已被其他实例持有,跳过本轮执行: lockKey={}, method={}",
|
||||
lock.key(), joinPoint.getSignature().toShortString());
|
||||
return null;
|
||||
}
|
||||
ScheduledFuture<?> renewTask = scheduleRenew(lock.key(), handle, leaseTimeout);
|
||||
try {
|
||||
return joinPoint.proceed();
|
||||
} finally {
|
||||
renewTask.cancel(false);
|
||||
handle.release();
|
||||
}
|
||||
}
|
||||
|
||||
private ScheduledFuture<?> scheduleRenew(String lockKey,
|
||||
RedisLockExecutor.LockHandle handle,
|
||||
Duration leaseTimeout) {
|
||||
long renewIntervalMillis = Math.max(leaseTimeout.toMillis() / 3L, 1000L);
|
||||
return renewExecutor.scheduleWithFixedDelay(() -> {
|
||||
if (!handle.renew()) {
|
||||
LOG.warn("定时任务分布式锁续期失败: lockKey={}", lockKey);
|
||||
}
|
||||
}, renewIntervalMillis, renewIntervalMillis, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭调度锁续期线程池。
|
||||
*/
|
||||
@PreDestroy
|
||||
public void destroy() {
|
||||
renewExecutor.shutdownNow();
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度锁续期线程工厂。
|
||||
*/
|
||||
private static final class DistributedScheduledLockThreadFactory implements ThreadFactory {
|
||||
|
||||
private final AtomicInteger index = new AtomicInteger(1);
|
||||
|
||||
/**
|
||||
* 创建续期线程。
|
||||
*
|
||||
* @param runnable 线程任务
|
||||
* @return 续期线程
|
||||
*/
|
||||
@Override
|
||||
public Thread newThread(Runnable runnable) {
|
||||
Thread thread = new Thread(runnable);
|
||||
thread.setName("distributed-scheduled-lock-renew-" + index.getAndIncrement());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import java.util.Collections;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Redis 分布式锁执行器。
|
||||
*/
|
||||
@Component
|
||||
public class RedisLockExecutor {
|
||||
|
||||
@@ -20,6 +23,7 @@ public class RedisLockExecutor {
|
||||
private static final long RETRY_INTERVAL_MILLIS = 50L;
|
||||
|
||||
private static final DefaultRedisScript<Long> RELEASE_LOCK_SCRIPT;
|
||||
private static final DefaultRedisScript<Long> RENEW_LOCK_SCRIPT;
|
||||
|
||||
static {
|
||||
RELEASE_LOCK_SCRIPT = new DefaultRedisScript<>();
|
||||
@@ -29,11 +33,26 @@ public class RedisLockExecutor {
|
||||
"else return 0 end"
|
||||
);
|
||||
RELEASE_LOCK_SCRIPT.setResultType(Long.class);
|
||||
RENEW_LOCK_SCRIPT = new DefaultRedisScript<>();
|
||||
RENEW_LOCK_SCRIPT.setScriptText(
|
||||
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
|
||||
"return redis.call('pexpire', KEYS[1], ARGV[2]) " +
|
||||
"else return 0 end"
|
||||
);
|
||||
RENEW_LOCK_SCRIPT.setResultType(Long.class);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
/**
|
||||
* 在分布式锁保护下执行无返回任务。
|
||||
*
|
||||
* @param lockKey 锁 key
|
||||
* @param waitTimeout 等待锁的最大时间
|
||||
* @param leaseTimeout 锁租约时间
|
||||
* @param task 业务任务
|
||||
*/
|
||||
public void executeWithLock(String lockKey, Duration waitTimeout, Duration leaseTimeout, Runnable task) {
|
||||
executeWithLock(lockKey, waitTimeout, leaseTimeout, () -> {
|
||||
task.run();
|
||||
@@ -41,40 +60,156 @@ public class RedisLockExecutor {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在分布式锁保护下执行有返回任务。
|
||||
*
|
||||
* @param lockKey 锁 key
|
||||
* @param waitTimeout 等待锁的最大时间
|
||||
* @param leaseTimeout 锁租约时间
|
||||
* @param task 业务任务
|
||||
* @param <T> 返回类型
|
||||
* @return 任务返回值
|
||||
*/
|
||||
public <T> T executeWithLock(String lockKey, Duration waitTimeout, Duration leaseTimeout, Supplier<T> task) {
|
||||
LockHandle handle = acquire(lockKey, waitTimeout, leaseTimeout);
|
||||
try {
|
||||
return task.get();
|
||||
} finally {
|
||||
handle.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取显式释放的分布式锁句柄。
|
||||
*
|
||||
* <p>长连接、SSE 或异步任务不能使用 callback 型锁,否则 callback 返回后锁会被提前释放。
|
||||
* 该方法返回 owner token 绑定的句柄,由调用方在运行完成、失败或取消时显式释放。</p>
|
||||
*
|
||||
* @param lockKey 锁 key
|
||||
* @param waitTimeout 等待时间
|
||||
* @param leaseTimeout 租约时间
|
||||
* @return 锁句柄
|
||||
*/
|
||||
public LockHandle acquire(String lockKey, Duration waitTimeout, Duration leaseTimeout) {
|
||||
LockHandle handle = tryAcquire(lockKey, waitTimeout, leaseTimeout);
|
||||
if (handle == null) {
|
||||
throw new IllegalStateException("获取分布式锁失败,请稍后重试,lockKey=" + lockKey);
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试获取显式释放的分布式锁句柄。
|
||||
*
|
||||
* <p>返回 {@code null} 表示锁当前被其他节点持有。Redis 访问失败或等待过程被中断仍会抛出异常,
|
||||
* 调用方可据此区分“正常跳过”和“基础设施异常”。</p>
|
||||
*
|
||||
* @param lockKey 锁 key
|
||||
* @param waitTimeout 等待时间
|
||||
* @param leaseTimeout 租约时间
|
||||
* @return 获取成功时返回锁句柄,否则返回 null
|
||||
*/
|
||||
public LockHandle tryAcquire(String lockKey, Duration waitTimeout, Duration leaseTimeout) {
|
||||
String lockValue = UUID.randomUUID().toString();
|
||||
boolean acquired = false;
|
||||
long deadline = System.nanoTime() + waitTimeout.toNanos();
|
||||
try {
|
||||
while (System.nanoTime() <= deadline) {
|
||||
do {
|
||||
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, leaseTimeout);
|
||||
if (Boolean.TRUE.equals(success)) {
|
||||
acquired = true;
|
||||
break;
|
||||
}
|
||||
Thread.sleep(RETRY_INTERVAL_MILLIS);
|
||||
if (System.nanoTime() >= deadline) {
|
||||
break;
|
||||
}
|
||||
Thread.sleep(RETRY_INTERVAL_MILLIS);
|
||||
} while (System.nanoTime() <= deadline);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("等待分布式锁被中断,lockKey=" + lockKey, e);
|
||||
}
|
||||
|
||||
if (!acquired) {
|
||||
throw new IllegalStateException("获取分布式锁失败,请稍后重试,lockKey=" + lockKey);
|
||||
return null;
|
||||
}
|
||||
return new LockHandle(lockKey, lockValue, leaseTimeout);
|
||||
}
|
||||
|
||||
try {
|
||||
return task.get();
|
||||
} finally {
|
||||
releaseLock(lockKey, lockValue);
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseLock(String lockKey, String lockValue) {
|
||||
/**
|
||||
* 按 owner token 释放锁。
|
||||
*
|
||||
* @param lockKey 锁 key
|
||||
* @param lockValue owner token
|
||||
*/
|
||||
public void releaseLock(String lockKey, String lockValue) {
|
||||
try {
|
||||
stringRedisTemplate.execute(RELEASE_LOCK_SCRIPT, Collections.singletonList(lockKey), lockValue);
|
||||
} catch (Exception e) {
|
||||
log.warn("释放分布式锁失败,lockKey={}", lockKey, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 owner token 续期锁。
|
||||
*
|
||||
* @param lockKey 锁 key
|
||||
* @param lockValue owner token
|
||||
* @param leaseTimeout 新租约时间
|
||||
* @return 续期成功时为 true
|
||||
*/
|
||||
public boolean renewLock(String lockKey, String lockValue, Duration leaseTimeout) {
|
||||
try {
|
||||
Long result = stringRedisTemplate.execute(RENEW_LOCK_SCRIPT, Collections.singletonList(lockKey),
|
||||
lockValue, String.valueOf(leaseTimeout.toMillis()));
|
||||
return Long.valueOf(1L).equals(result);
|
||||
} catch (Exception e) {
|
||||
log.warn("续期分布式锁失败,lockKey={}", lockKey, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显式分布式锁句柄。
|
||||
*/
|
||||
public final class LockHandle implements AutoCloseable {
|
||||
|
||||
private final String lockKey;
|
||||
private final String lockValue;
|
||||
private final Duration leaseTimeout;
|
||||
private volatile boolean released;
|
||||
|
||||
private LockHandle(String lockKey, String lockValue, Duration leaseTimeout) {
|
||||
this.lockKey = lockKey;
|
||||
this.lockValue = lockValue;
|
||||
this.leaseTimeout = leaseTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 续期当前锁。
|
||||
*
|
||||
* @return 续期成功时为 true
|
||||
*/
|
||||
public boolean renew() {
|
||||
if (released) {
|
||||
return false;
|
||||
}
|
||||
return renewLock(lockKey, lockValue, leaseTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放当前锁。
|
||||
*/
|
||||
public void release() {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
releaseLock(lockKey, lockValue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package tech.easyflow.common.cache;
|
||||
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.Signature;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.data.redis.core.script.RedisScript;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* {@link DistributedScheduledLockAspect} 回归测试。
|
||||
*/
|
||||
public class DistributedScheduledLockAspectTest {
|
||||
|
||||
/**
|
||||
* 验证未抢到调度锁时跳过原方法。
|
||||
*
|
||||
* @throws Throwable 切面执行异常
|
||||
*/
|
||||
@Test
|
||||
public void aroundShouldSkipTaskWhenLockIsHeld() throws Throwable {
|
||||
RedisLockExecutor executor = createExecutor(false);
|
||||
DistributedScheduledLockAspect aspect = new DistributedScheduledLockAspect(executor);
|
||||
AtomicInteger proceedCount = new AtomicInteger();
|
||||
|
||||
Object result = aspect.around(
|
||||
mockJoinPoint(proceedCount),
|
||||
annotatedMethod("lockedTask").getAnnotation(DistributedScheduledLock.class)
|
||||
);
|
||||
|
||||
Assert.assertNull(result);
|
||||
Assert.assertEquals(0, proceedCount.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证抢到调度锁时执行原方法并释放锁。
|
||||
*
|
||||
* @throws Throwable 切面执行异常
|
||||
*/
|
||||
@Test
|
||||
public void aroundShouldProceedAndReleaseWhenLockAcquired() throws Throwable {
|
||||
RedisLockExecutor executor = createExecutor(true);
|
||||
DistributedScheduledLockAspect aspect = new DistributedScheduledLockAspect(executor);
|
||||
AtomicInteger proceedCount = new AtomicInteger();
|
||||
|
||||
Object result = aspect.around(
|
||||
mockJoinPoint(proceedCount),
|
||||
annotatedMethod("lockedTask").getAnnotation(DistributedScheduledLock.class)
|
||||
);
|
||||
|
||||
Assert.assertEquals("ok", result);
|
||||
Assert.assertEquals(1, proceedCount.get());
|
||||
}
|
||||
|
||||
@DistributedScheduledLock(key = "easyflow:test:scheduled", leaseSeconds = 30L)
|
||||
private void lockedTask() {
|
||||
}
|
||||
|
||||
private Method annotatedMethod(String methodName) throws NoSuchMethodException {
|
||||
Method method = DistributedScheduledLockAspectTest.class.getDeclaredMethod(methodName);
|
||||
method.setAccessible(true);
|
||||
return method;
|
||||
}
|
||||
|
||||
private ProceedingJoinPoint mockJoinPoint(AtomicInteger proceedCount) throws Throwable {
|
||||
ProceedingJoinPoint joinPoint = Mockito.mock(ProceedingJoinPoint.class);
|
||||
Signature signature = Mockito.mock(Signature.class);
|
||||
Mockito.when(signature.toShortString()).thenReturn("lockedTask()");
|
||||
Mockito.when(joinPoint.getSignature()).thenReturn(signature);
|
||||
Mockito.when(joinPoint.proceed()).thenAnswer(invocation -> {
|
||||
proceedCount.incrementAndGet();
|
||||
return "ok";
|
||||
});
|
||||
return joinPoint;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private RedisLockExecutor createExecutor(boolean acquired) throws Exception {
|
||||
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
|
||||
ValueOperations<String, String> valueOperations = Mockito.mock(ValueOperations.class);
|
||||
Mockito.when(valueOperations.setIfAbsent(
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.any(Duration.class)
|
||||
)).thenReturn(acquired);
|
||||
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
Mockito.when(redisTemplate.execute(
|
||||
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||
ArgumentMatchers.<List<String>>any(),
|
||||
ArgumentMatchers.<Object[]>any()
|
||||
)).thenReturn(1L);
|
||||
|
||||
RedisLockExecutor executor = new RedisLockExecutor();
|
||||
Field field = RedisLockExecutor.class.getDeclaredField("stringRedisTemplate");
|
||||
field.setAccessible(true);
|
||||
field.set(executor, redisTemplate);
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package tech.easyflow.common.cache;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.data.redis.core.script.RedisScript;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* {@link RedisLockExecutor} 回归测试。
|
||||
*/
|
||||
public class RedisLockExecutorTest {
|
||||
|
||||
/**
|
||||
* 验证锁被占用时返回 null,便于调度任务跳过本轮执行。
|
||||
*
|
||||
* @throws Exception 反射注入异常
|
||||
*/
|
||||
@Test
|
||||
public void tryAcquireShouldReturnNullWhenLockIsHeld() throws Exception {
|
||||
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
|
||||
ValueOperations<String, String> valueOperations = mockValueOperations(false);
|
||||
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
|
||||
RedisLockExecutor executor = new RedisLockExecutor();
|
||||
setRedisTemplate(executor, redisTemplate);
|
||||
|
||||
RedisLockExecutor.LockHandle handle = executor.tryAcquire(
|
||||
"easyflow:test:lock",
|
||||
Duration.ZERO,
|
||||
Duration.ofSeconds(30)
|
||||
);
|
||||
|
||||
Assert.assertNull(handle);
|
||||
Mockito.verify(valueOperations).setIfAbsent(
|
||||
ArgumentMatchers.eq("easyflow:test:lock"),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.eq(Duration.ofSeconds(30))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证锁获取成功后释放会执行 owner token 校验脚本。
|
||||
*
|
||||
* @throws Exception 反射注入异常
|
||||
*/
|
||||
@Test
|
||||
public void acquiredHandleShouldReleaseLockWithOwnerToken() throws Exception {
|
||||
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
|
||||
ValueOperations<String, String> valueOperations = mockValueOperations(true);
|
||||
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
Mockito.when(redisTemplate.execute(
|
||||
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||
ArgumentMatchers.<List<String>>any(),
|
||||
ArgumentMatchers.<Object[]>any()
|
||||
)).thenReturn(1L);
|
||||
|
||||
RedisLockExecutor executor = new RedisLockExecutor();
|
||||
setRedisTemplate(executor, redisTemplate);
|
||||
|
||||
RedisLockExecutor.LockHandle handle = executor.tryAcquire(
|
||||
"easyflow:test:lock",
|
||||
Duration.ZERO,
|
||||
Duration.ofSeconds(30)
|
||||
);
|
||||
|
||||
Assert.assertNotNull(handle);
|
||||
handle.release();
|
||||
Mockito.verify(redisTemplate).execute(
|
||||
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||
ArgumentMatchers.eq(List.of("easyflow:test:lock")),
|
||||
ArgumentMatchers.<Object[]>any()
|
||||
);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private ValueOperations<String, String> mockValueOperations(boolean acquired) {
|
||||
ValueOperations<String, String> valueOperations = Mockito.mock(ValueOperations.class);
|
||||
Mockito.when(valueOperations.setIfAbsent(
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.anyString(),
|
||||
ArgumentMatchers.any(Duration.class)
|
||||
)).thenReturn(acquired);
|
||||
return valueOperations;
|
||||
}
|
||||
|
||||
private void setRedisTemplate(RedisLockExecutor executor, StringRedisTemplate redisTemplate) throws Exception {
|
||||
Field field = RedisLockExecutor.class.getDeclaredField("stringRedisTemplate");
|
||||
field.setAccessible(true);
|
||||
field.set(executor, redisTemplate);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ public enum ChatType {
|
||||
TOOL_CALL,
|
||||
TOOL_RESULT,
|
||||
STATUS,
|
||||
CITATIONS,
|
||||
SESSION_CREATED,
|
||||
ERROR,
|
||||
FORM_REQUEST,
|
||||
FORM_CANCEL,
|
||||
|
||||
@@ -4,9 +4,10 @@ import com.alibaba.fastjson.JSON;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import tech.easyflow.common.util.StringUtil;
|
||||
import tech.easyflow.common.util.SpringContextUtil;
|
||||
import tech.easyflow.common.util.StringUtil;
|
||||
import tech.easyflow.core.chat.protocol.ChatEnvelope;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -90,15 +91,21 @@ public class ChatSseEmitter {
|
||||
.data(json)
|
||||
);
|
||||
return true;
|
||||
} catch (IllegalStateException e) {
|
||||
closed.compareAndSet(false, true);
|
||||
LOG.error("ChatSseEmitter send failed(event={}), message={}, exception={}", event, e.getMessage(), e.toString(), e);
|
||||
} catch (AsyncRequestNotUsableException e) {
|
||||
markDisconnected(event, e);
|
||||
return false;
|
||||
} catch (IOException e) {
|
||||
LOG.error("ChatSseEmitter send io failed(event={}), message={}, exception={}", event, e.getMessage(), e.toString(), e);
|
||||
safeCompleteWithError(e);
|
||||
markDisconnected(event, e);
|
||||
return false;
|
||||
} catch (IllegalStateException e) {
|
||||
closed.compareAndSet(false, true);
|
||||
LOG.warn("ChatSseEmitter send failed(event={}), message={}, exception={}", event, e.getMessage(), e.toString());
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
if (isClientDisconnected(e)) {
|
||||
markDisconnected(event, e);
|
||||
return false;
|
||||
}
|
||||
LOG.error("ChatSseEmitter send unexpected failed(event={}), message={}, exception={}", event, e.getMessage(), e.toString(), e);
|
||||
safeCompleteWithError(e);
|
||||
return false;
|
||||
@@ -165,4 +172,31 @@ public class ChatSseEmitter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void markDisconnected(String event, Throwable ex) {
|
||||
closed.compareAndSet(false, true);
|
||||
LOG.warn("ChatSseEmitter client disconnected(event={}), message={}, exception={}",
|
||||
event, ex == null ? null : ex.getMessage(), ex == null ? null : ex.toString());
|
||||
}
|
||||
|
||||
private boolean isClientDisconnected(Throwable ex) {
|
||||
Throwable current = ex;
|
||||
while (current != null) {
|
||||
if (current instanceof AsyncRequestNotUsableException || current instanceof IOException) {
|
||||
return true;
|
||||
}
|
||||
String message = current.getMessage();
|
||||
if (message != null) {
|
||||
String lowerMessage = message.toLowerCase();
|
||||
if (lowerMessage.contains("broken pipe")
|
||||
|| lowerMessage.contains("connection reset")
|
||||
|| lowerMessage.contains("disconnected client")
|
||||
|| lowerMessage.contains("response not usable")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,9 +51,22 @@ public class ChatAssistantAccumulator {
|
||||
* @param arguments tool 参数
|
||||
*/
|
||||
public void appendToolCall(String id, String name, Object arguments) {
|
||||
appendToolCall(id, name, null, arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 tool call,同时保留面向前端展示的工具名称。
|
||||
*
|
||||
* @param id tool call id
|
||||
* @param name tool 名称
|
||||
* @param displayName tool 展示名称
|
||||
* @param arguments tool 参数
|
||||
*/
|
||||
public void appendToolCall(String id, String name, String displayName, Object arguments) {
|
||||
Map<String, Object> chain = findToolChain(id, name);
|
||||
chain.put("status", "TOOL_CALL");
|
||||
chain.put("arguments", arguments);
|
||||
putIfNotBlank(chain, "toolDisplayName", displayName);
|
||||
|
||||
Map<String, Object> assistantMessage = ensureToolCallAssistantMessage();
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -63,6 +76,7 @@ public class ChatAssistantAccumulator {
|
||||
toolCall.put("id", id);
|
||||
toolCall.put("name", name);
|
||||
toolCall.put("arguments", arguments == null ? null : String.valueOf(arguments));
|
||||
putIfNotBlank(toolCall, "toolDisplayName", displayName);
|
||||
toolCalls.add(toolCall);
|
||||
}
|
||||
|
||||
@@ -74,9 +88,22 @@ public class ChatAssistantAccumulator {
|
||||
* @param result tool 结果
|
||||
*/
|
||||
public void appendToolResult(String id, String name, Object result) {
|
||||
appendToolResult(id, name, null, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 tool result,并保留面向前端展示的工具名称。
|
||||
*
|
||||
* @param id tool call id
|
||||
* @param name tool 名称
|
||||
* @param displayName tool 展示名称
|
||||
* @param result tool 结果
|
||||
*/
|
||||
public void appendToolResult(String id, String name, String displayName, Object result) {
|
||||
Map<String, Object> chain = findToolChain(id, name);
|
||||
chain.put("status", "TOOL_RESULT");
|
||||
chain.put("result", result);
|
||||
putIfNotBlank(chain, "toolDisplayName", displayName);
|
||||
Map<String, Object> toolMessage = ChatRuntimeHistoryPayloadHelper.toolMessage(
|
||||
id,
|
||||
result == null ? null : String.valueOf(result)
|
||||
@@ -191,4 +218,10 @@ public class ChatAssistantAccumulator {
|
||||
private String stringValue(Object value) {
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
|
||||
private void putIfNotBlank(Map<String, Object> target, String key, String value) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
target.put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,5 +22,22 @@
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-pool2</artifactId>
|
||||
<version>2.11.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<version>5.12.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
@@ -9,7 +9,9 @@ import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisPassword;
|
||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
import org.springframework.data.redis.connection.lettuce.LettucePoolingClientConfiguration;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import tech.easyflow.common.mq.core.MQConsumerContainer;
|
||||
import tech.easyflow.common.mq.core.MQConsumerHandler;
|
||||
@@ -24,6 +26,10 @@ import tech.easyflow.common.mq.redis.RedisMQProducer;
|
||||
import tech.easyflow.common.mq.redis.RedisStreamKeySupport;
|
||||
import tech.easyflow.common.mq.support.MQHealthSupport;
|
||||
|
||||
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
|
||||
|
||||
import io.lettuce.core.api.StatefulConnection;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@@ -43,11 +49,27 @@ public class MQConfiguration {
|
||||
if (redisProperties.getPassword() != null) {
|
||||
configuration.setPassword(RedisPassword.of(redisProperties.getPassword()));
|
||||
}
|
||||
LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory(configuration);
|
||||
LettuceClientConfiguration clientConfiguration = createClientConfiguration(redisProperties, mqProperties);
|
||||
LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory(configuration, clientConfiguration);
|
||||
connectionFactory.afterPropertiesSet();
|
||||
return new MQRedisResources(connectionFactory, new StringRedisTemplate(connectionFactory));
|
||||
}
|
||||
|
||||
private LettuceClientConfiguration createClientConfiguration(RedisProperties redisProperties,
|
||||
MQProperties mqProperties) {
|
||||
MQProperties.Redis.Pool pool = mqProperties.getRedis().getPool();
|
||||
GenericObjectPoolConfig<StatefulConnection<?, ?>> poolConfig = new GenericObjectPoolConfig<>();
|
||||
poolConfig.setMaxTotal(pool.getMaxActive());
|
||||
poolConfig.setMaxIdle(pool.getMaxIdle());
|
||||
poolConfig.setMinIdle(pool.getMinIdle());
|
||||
LettucePoolingClientConfiguration.LettucePoolingClientConfigurationBuilder builder =
|
||||
LettucePoolingClientConfiguration.builder().poolConfig(poolConfig);
|
||||
if (redisProperties.getTimeout() != null) {
|
||||
builder.commandTimeout(redisProperties.getTimeout());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@Bean(name = "mqRedisConnectionFactory", autowireCandidate = false, defaultCandidate = false)
|
||||
@ConditionalOnProperty(prefix = "easyflow.mq", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||
public LettuceConnectionFactory mqRedisConnectionFactory(MQRedisResources mqRedisResources) {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package tech.easyflow.common.mq.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* EasyFlow MQ 配置。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "easyflow.mq")
|
||||
public class MQProperties {
|
||||
|
||||
@@ -35,11 +39,14 @@ public class MQProperties {
|
||||
|
||||
private int database = 1;
|
||||
private String streamPrefix = "easyflow:mq";
|
||||
private String consumerInstanceId = defaultConsumerInstanceId();
|
||||
private int chatPersistShardCount = 4;
|
||||
private int consumerBatchSize = 200;
|
||||
private Duration consumerBlockTimeout = Duration.ofMillis(2000);
|
||||
private Duration pendingClaimIdle = Duration.ofMillis(60000);
|
||||
private int maxRetry = 16;
|
||||
private ConsumerExecutor consumerExecutor = new ConsumerExecutor();
|
||||
private Pool pool = new Pool();
|
||||
|
||||
public int getDatabase() {
|
||||
return database;
|
||||
@@ -57,6 +64,26 @@ public class MQProperties {
|
||||
this.streamPrefix = streamPrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Redis Stream 消费实例 ID。
|
||||
*
|
||||
* @return 消费实例 ID
|
||||
*/
|
||||
public String getConsumerInstanceId() {
|
||||
return consumerInstanceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Redis Stream 消费实例 ID。
|
||||
*
|
||||
* @param consumerInstanceId 消费实例 ID
|
||||
*/
|
||||
public void setConsumerInstanceId(String consumerInstanceId) {
|
||||
this.consumerInstanceId = StringUtils.hasText(consumerInstanceId)
|
||||
? consumerInstanceId.trim()
|
||||
: defaultConsumerInstanceId();
|
||||
}
|
||||
|
||||
public int getChatPersistShardCount() {
|
||||
return chatPersistShardCount;
|
||||
}
|
||||
@@ -96,5 +123,106 @@ public class MQProperties {
|
||||
public void setMaxRetry(int maxRetry) {
|
||||
this.maxRetry = maxRetry;
|
||||
}
|
||||
|
||||
public ConsumerExecutor getConsumerExecutor() {
|
||||
return consumerExecutor;
|
||||
}
|
||||
|
||||
public void setConsumerExecutor(ConsumerExecutor consumerExecutor) {
|
||||
this.consumerExecutor = consumerExecutor;
|
||||
}
|
||||
|
||||
public Pool getPool() {
|
||||
return pool;
|
||||
}
|
||||
|
||||
public void setPool(Pool pool) {
|
||||
this.pool = pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis MQ 消费线程池配置。
|
||||
*/
|
||||
public static class ConsumerExecutor {
|
||||
|
||||
private int coreSize = 4;
|
||||
private int maxSize = 12;
|
||||
private int queueCapacity = 64;
|
||||
private int keepAliveSeconds = 60;
|
||||
|
||||
public int getCoreSize() {
|
||||
return coreSize;
|
||||
}
|
||||
|
||||
public void setCoreSize(int coreSize) {
|
||||
this.coreSize = coreSize;
|
||||
}
|
||||
|
||||
public int getMaxSize() {
|
||||
return maxSize;
|
||||
}
|
||||
|
||||
public void setMaxSize(int maxSize) {
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
public int getQueueCapacity() {
|
||||
return queueCapacity;
|
||||
}
|
||||
|
||||
public void setQueueCapacity(int queueCapacity) {
|
||||
this.queueCapacity = queueCapacity;
|
||||
}
|
||||
|
||||
public int getKeepAliveSeconds() {
|
||||
return keepAliveSeconds;
|
||||
}
|
||||
|
||||
public void setKeepAliveSeconds(int keepAliveSeconds) {
|
||||
this.keepAliveSeconds = keepAliveSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis MQ 连接池配置。
|
||||
*/
|
||||
public static class Pool {
|
||||
|
||||
private int maxActive = 12;
|
||||
private int maxIdle = 8;
|
||||
private int minIdle = 1;
|
||||
|
||||
public int getMaxActive() {
|
||||
return maxActive;
|
||||
}
|
||||
|
||||
public void setMaxActive(int maxActive) {
|
||||
this.maxActive = maxActive;
|
||||
}
|
||||
|
||||
public int getMaxIdle() {
|
||||
return maxIdle;
|
||||
}
|
||||
|
||||
public void setMaxIdle(int maxIdle) {
|
||||
this.maxIdle = maxIdle;
|
||||
}
|
||||
|
||||
public int getMinIdle() {
|
||||
return minIdle;
|
||||
}
|
||||
|
||||
public void setMinIdle(int minIdle) {
|
||||
this.minIdle = minIdle;
|
||||
}
|
||||
}
|
||||
|
||||
private static String defaultConsumerInstanceId() {
|
||||
String hostName = System.getenv("HOSTNAME");
|
||||
if (StringUtils.hasText(hostName)) {
|
||||
return hostName.trim();
|
||||
}
|
||||
return java.util.UUID.randomUUID().toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ public class MQSubscription {
|
||||
private String topic;
|
||||
private String consumerGroup;
|
||||
private int shardCount;
|
||||
private boolean batchEnabled = true;
|
||||
|
||||
public String getTopic() {
|
||||
return topic;
|
||||
@@ -29,4 +30,22 @@ public class MQSubscription {
|
||||
public void setShardCount(int shardCount) {
|
||||
this.shardCount = shardCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否启用批量消费。
|
||||
*
|
||||
* @return true 表示启用批量消费
|
||||
*/
|
||||
public boolean isBatchEnabled() {
|
||||
return batchEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否启用批量消费。
|
||||
*
|
||||
* @param batchEnabled 是否启用批量消费
|
||||
*/
|
||||
public void setBatchEnabled(boolean batchEnabled) {
|
||||
this.batchEnabled = batchEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,13 +30,17 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifecycle {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(RedisMQConsumerContainer.class);
|
||||
private static final Pattern UNSAFE_CONSUMER_NAME_CHARS = Pattern.compile("[^A-Za-z0-9_.-]");
|
||||
|
||||
private final RedisConnectionFactory redisConnectionFactory;
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
@@ -45,7 +49,7 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
||||
private final MQDeadLetterService deadLetterService;
|
||||
private final RedisStreamKeySupport keySupport;
|
||||
private final List<MQConsumerHandler> handlers;
|
||||
private final ExecutorService executorService = Executors.newCachedThreadPool();
|
||||
private final ExecutorService executorService;
|
||||
|
||||
private volatile boolean running;
|
||||
|
||||
@@ -63,6 +67,7 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
||||
this.deadLetterService = deadLetterService;
|
||||
this.keySupport = keySupport;
|
||||
this.handlers = handlers;
|
||||
this.executorService = createExecutor(properties, handlers);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -77,7 +82,12 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
||||
int currentShard = shard;
|
||||
LOG.info("启动 MQ 消费线程: topic={}, group={}, shard={}, handler={}",
|
||||
subscription.getTopic(), subscription.getConsumerGroup(), currentShard, handler.getClass().getSimpleName());
|
||||
try {
|
||||
executorService.submit(() -> consumeLoop(handler, subscription, currentShard));
|
||||
} catch (RuntimeException e) {
|
||||
running = false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,15 +118,62 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
||||
stop();
|
||||
}
|
||||
|
||||
private ExecutorService createExecutor(MQProperties properties, List<MQConsumerHandler> handlers) {
|
||||
MQProperties.Redis.ConsumerExecutor config = properties.getRedis().getConsumerExecutor();
|
||||
int consumerTaskCount = handlers.stream()
|
||||
.map(MQConsumerHandler::subscription)
|
||||
.filter(Objects::nonNull)
|
||||
.mapToInt(subscription -> Math.max(subscription.getShardCount(), 1))
|
||||
.sum();
|
||||
if (config.getCoreSize() > config.getMaxSize()) {
|
||||
throw new IllegalStateException("Redis MQ 消费线程池配置错误:core-size 不能大于 max-size");
|
||||
}
|
||||
if (consumerTaskCount > config.getMaxSize()) {
|
||||
throw new IllegalStateException("Redis MQ 消费线程池配置错误:max-size="
|
||||
+ config.getMaxSize() + " 小于消费循环数 " + consumerTaskCount
|
||||
+ ",请调大 easyflow.mq.redis.consumer-executor.max-size");
|
||||
}
|
||||
int coreSize = Math.max(config.getCoreSize(), consumerTaskCount);
|
||||
int maxSize = config.getMaxSize();
|
||||
AtomicInteger threadIndex = new AtomicInteger(1);
|
||||
ThreadPoolExecutor executor = new ThreadPoolExecutor(
|
||||
coreSize,
|
||||
maxSize,
|
||||
config.getKeepAliveSeconds(),
|
||||
TimeUnit.SECONDS,
|
||||
new ArrayBlockingQueue<>(config.getQueueCapacity()),
|
||||
task -> {
|
||||
Thread thread = new Thread(task);
|
||||
thread.setName("redis-mq-consumer-" + threadIndex.getAndIncrement());
|
||||
thread.setDaemon(false);
|
||||
return thread;
|
||||
},
|
||||
new ThreadPoolExecutor.AbortPolicy()
|
||||
);
|
||||
executor.allowCoreThreadTimeOut(true);
|
||||
return executor;
|
||||
}
|
||||
|
||||
private void consumeLoop(MQConsumerHandler handler, MQSubscription subscription, int shard) {
|
||||
String streamKey = keySupport.streamKey(subscription.getTopic(), shard);
|
||||
String consumerName = subscription.getConsumerGroup() + "-" + shard;
|
||||
String consumerName = buildConsumerName(subscription.getConsumerGroup(), shard);
|
||||
ensureConsumerGroup(streamKey, subscription.getConsumerGroup());
|
||||
LOG.info("MQ 消费循环已启动: topic={}, group={}, shard={}, consumer={}, streamKey={}, handler={}",
|
||||
subscription.getTopic(), subscription.getConsumerGroup(), shard, consumerName, streamKey, handler.getClass().getSimpleName());
|
||||
while (running) {
|
||||
try {
|
||||
List<MapRecord<String, Object, Object>> pendingRecords =
|
||||
reclaimPending(streamKey, subscription.getConsumerGroup(), consumerName);
|
||||
if (!pendingRecords.isEmpty()) {
|
||||
List<MQMessage> pendingMessages = toMessages(streamKey, pendingRecords);
|
||||
if (!pendingMessages.isEmpty()) {
|
||||
LOG.info("MQ 收到重领 pending 消息批次: topic={}, group={}, shard={}, consumer={}, streamKey={}, count={}",
|
||||
subscription.getTopic(), subscription.getConsumerGroup(), shard, consumerName,
|
||||
streamKey, pendingMessages.size());
|
||||
handleMessages(handler, subscription, streamKey, subscription.getConsumerGroup(), pendingMessages);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
List<MapRecord<String, Object, Object>> records = stringRedisTemplate.opsForStream().read(
|
||||
Consumer.from(subscription.getConsumerGroup(), consumerName),
|
||||
StreamReadOptions.empty()
|
||||
@@ -133,7 +190,7 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
||||
}
|
||||
LOG.info("MQ 收到消息批次: topic={}, group={}, shard={}, consumer={}, streamKey={}, count={}",
|
||||
subscription.getTopic(), subscription.getConsumerGroup(), shard, consumerName, streamKey, messages.size());
|
||||
handleMessages(handler, streamKey, subscription.getConsumerGroup(), messages);
|
||||
handleMessages(handler, subscription, streamKey, subscription.getConsumerGroup(), messages);
|
||||
} catch (Exception exception) {
|
||||
LOG.error("MQ 消费循环异常: topic={}, group={}, shard={}, consumer={}, streamKey={}, handler={}",
|
||||
subscription.getTopic(),
|
||||
@@ -148,7 +205,20 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
||||
}
|
||||
}
|
||||
|
||||
private void reclaimPending(String streamKey, String group, String consumerName) {
|
||||
/**
|
||||
* 构建 Redis Stream consumer name。
|
||||
*
|
||||
* @param consumerGroup 消费组
|
||||
* @param shard 分片序号
|
||||
* @return consumer name
|
||||
*/
|
||||
String buildConsumerName(String consumerGroup, int shard) {
|
||||
String instanceId = properties.getRedis().getConsumerInstanceId();
|
||||
String safeInstanceId = UNSAFE_CONSUMER_NAME_CHARS.matcher(instanceId).replaceAll("-");
|
||||
return consumerGroup + "-" + shard + "-" + safeInstanceId;
|
||||
}
|
||||
|
||||
List<MapRecord<String, Object, Object>> reclaimPending(String streamKey, String group, String consumerName) {
|
||||
Duration idle = properties.getRedis().getPendingClaimIdle();
|
||||
try (RedisConnection connection = redisConnectionFactory.getConnection()) {
|
||||
RedisStreamCommands.XPendingOptions options = RedisStreamCommands.XPendingOptions
|
||||
@@ -156,7 +226,7 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
||||
var pendingMessages = connection.streamCommands()
|
||||
.xPending(streamKey.getBytes(StandardCharsets.UTF_8), group, options);
|
||||
if (pendingMessages == null || pendingMessages.isEmpty()) {
|
||||
return;
|
||||
return List.of();
|
||||
}
|
||||
List<RecordId> ids = new ArrayList<>();
|
||||
for (PendingMessage pendingMessage : pendingMessages) {
|
||||
@@ -165,15 +235,16 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
||||
}
|
||||
}
|
||||
if (ids.isEmpty()) {
|
||||
return;
|
||||
return List.of();
|
||||
}
|
||||
stringRedisTemplate.opsForStream().claim(
|
||||
List<MapRecord<String, Object, Object>> records = stringRedisTemplate.opsForStream().claim(
|
||||
streamKey,
|
||||
group,
|
||||
consumerName,
|
||||
idle,
|
||||
ids.toArray(new RecordId[0])
|
||||
);
|
||||
return records == null ? List.of() : records;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +260,7 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
||||
}
|
||||
}
|
||||
|
||||
private List<MQMessage> toMessages(String streamKey, List<MapRecord<String, Object, Object>> records) {
|
||||
List<MQMessage> toMessages(String streamKey, List<MapRecord<String, Object, Object>> records) {
|
||||
List<MQMessage> messages = new ArrayList<>(records.size());
|
||||
for (MapRecord<String, Object, Object> record : records) {
|
||||
Object payload = record.getValue().get("payload");
|
||||
@@ -225,7 +296,15 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
||||
}
|
||||
}
|
||||
|
||||
private void handleMessages(MQConsumerHandler handler, String streamKey, String group, List<MQMessage> messages) throws Exception {
|
||||
void handleMessages(MQConsumerHandler handler,
|
||||
MQSubscription subscription,
|
||||
String streamKey,
|
||||
String group,
|
||||
List<MQMessage> messages) throws Exception {
|
||||
if (!subscription.isBatchEnabled()) {
|
||||
handleMessagesIndividually(handler, streamKey, group, messages);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
LOG.info("MQ 开始批量处理消息: group={}, streamKey={}, count={}, handler={}",
|
||||
group, streamKey, messages.size(), handler.getClass().getSimpleName());
|
||||
@@ -244,6 +323,13 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
||||
}
|
||||
}
|
||||
|
||||
handleMessagesIndividually(handler, streamKey, group, messages);
|
||||
}
|
||||
|
||||
private void handleMessagesIndividually(MQConsumerHandler handler,
|
||||
String streamKey,
|
||||
String group,
|
||||
List<MQMessage> messages) {
|
||||
for (MQMessage message : messages) {
|
||||
try {
|
||||
LOG.info("MQ 开始单条处理消息: group={}, streamKey={}, messageId={}, handler={}",
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package tech.easyflow.common.mq.redis;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.data.redis.connection.RedisConnection;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.connection.RedisStreamCommands;
|
||||
import org.springframework.data.redis.connection.stream.Consumer;
|
||||
import org.springframework.data.redis.connection.stream.MapRecord;
|
||||
import org.springframework.data.redis.connection.stream.PendingMessage;
|
||||
import org.springframework.data.redis.connection.stream.PendingMessages;
|
||||
import org.springframework.data.redis.connection.stream.RecordId;
|
||||
import org.springframework.data.redis.core.StreamOperations;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import tech.easyflow.common.mq.config.MQProperties;
|
||||
import tech.easyflow.common.mq.core.MQConsumerHandler;
|
||||
import tech.easyflow.common.mq.core.MQDeadLetterService;
|
||||
import tech.easyflow.common.mq.core.MQMessage;
|
||||
import tech.easyflow.common.mq.core.MQMessageConverter;
|
||||
import tech.easyflow.common.mq.core.MQSubscription;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* {@link RedisMQConsumerContainer} 回归测试。
|
||||
*/
|
||||
public class RedisMQConsumerContainerTest {
|
||||
|
||||
/**
|
||||
* 验证 consumer name 包含稳定实例 ID,且消费组名称不被改变。
|
||||
*/
|
||||
@Test
|
||||
public void buildConsumerNameShouldAppendSanitizedInstanceId() {
|
||||
MQProperties properties = new MQProperties();
|
||||
properties.getRedis().setConsumerInstanceId("node/a:1");
|
||||
RedisMQConsumerContainer container = new RedisMQConsumerContainer(
|
||||
null,
|
||||
null,
|
||||
properties,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of()
|
||||
);
|
||||
|
||||
String consumerName = container.buildConsumerName("chat-persist", 2);
|
||||
|
||||
Assert.assertEquals("chat-persist-2-node-a-1", consumerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证关闭批量消费后,容器按单条处理并独立确认消息。
|
||||
*
|
||||
* @throws Exception 消息处理异常
|
||||
*/
|
||||
@Test
|
||||
public void handleMessagesShouldProcessIndividuallyWhenBatchDisabled() throws Exception {
|
||||
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
StreamOperations<String, Object, Object> streamOperations = Mockito.mock(StreamOperations.class);
|
||||
Mockito.when(redisTemplate.opsForStream()).thenReturn(streamOperations);
|
||||
RecordingHandler handler = new RecordingHandler();
|
||||
MQSubscription subscription = new MQSubscription();
|
||||
subscription.setBatchEnabled(false);
|
||||
RedisMQConsumerContainer container = container(redisTemplate, null);
|
||||
MQMessage first = message("message-1", "1-0");
|
||||
MQMessage second = message("message-2", "2-0");
|
||||
|
||||
container.handleMessages(handler, subscription, "stream-1", "group-1", List.of(first, second));
|
||||
|
||||
Assert.assertEquals(List.of(List.of("message-1"), List.of("message-2")), handler.calls);
|
||||
Mockito.verify(streamOperations).acknowledge("stream-1", "group-1", "1-0");
|
||||
Mockito.verify(streamOperations).acknowledge("stream-1", "group-1", "2-0");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 pending 消息被 claim 后可以转换为 MQ 消息继续消费。
|
||||
*/
|
||||
@Test
|
||||
public void reclaimPendingShouldReturnClaimedRecordsForConsumption() {
|
||||
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
StreamOperations<String, Object, Object> streamOperations = Mockito.mock(StreamOperations.class);
|
||||
Mockito.when(redisTemplate.opsForStream()).thenReturn(streamOperations);
|
||||
RedisConnectionFactory connectionFactory = Mockito.mock(RedisConnectionFactory.class);
|
||||
RedisConnection connection = Mockito.mock(RedisConnection.class);
|
||||
RedisStreamCommands streamCommands = Mockito.mock(RedisStreamCommands.class);
|
||||
Mockito.when(connectionFactory.getConnection()).thenReturn(connection);
|
||||
Mockito.when(connection.streamCommands()).thenReturn(streamCommands);
|
||||
PendingMessage pendingMessage = new PendingMessage(
|
||||
RecordId.of("1-0"), Consumer.from("group-1", "old-consumer"), Duration.ofMinutes(2), 1);
|
||||
Mockito.when(streamCommands.xPending(
|
||||
ArgumentMatchers.eq("stream-1".getBytes(java.nio.charset.StandardCharsets.UTF_8)),
|
||||
ArgumentMatchers.eq("group-1"),
|
||||
ArgumentMatchers.any(RedisStreamCommands.XPendingOptions.class)))
|
||||
.thenReturn(new PendingMessages("group-1", List.of(pendingMessage)));
|
||||
Map<Object, Object> payload = Map.of("payload", "message-1");
|
||||
MapRecord<String, Object, Object> record = MapRecord
|
||||
.create("stream-1", payload)
|
||||
.withId(RecordId.of("1-0"));
|
||||
Mockito.when(streamOperations.claim(
|
||||
ArgumentMatchers.eq("stream-1"),
|
||||
ArgumentMatchers.eq("group-1"),
|
||||
ArgumentMatchers.eq("consumer-1"),
|
||||
ArgumentMatchers.any(Duration.class),
|
||||
ArgumentMatchers.any(RecordId[].class)))
|
||||
.thenReturn(List.of(record));
|
||||
RedisMQConsumerContainer container = container(redisTemplate, connectionFactory);
|
||||
|
||||
List<MapRecord<String, Object, Object>> records =
|
||||
container.reclaimPending("stream-1", "group-1", "consumer-1");
|
||||
List<MQMessage> messages = container.toMessages("stream-1", records);
|
||||
|
||||
Assert.assertEquals(1, records.size());
|
||||
Assert.assertEquals(1, messages.size());
|
||||
Assert.assertEquals("message-1", messages.get(0).getMessageId());
|
||||
Assert.assertEquals("1-0", messages.get(0).getStreamMessageId());
|
||||
}
|
||||
|
||||
private RedisMQConsumerContainer container(StringRedisTemplate redisTemplate,
|
||||
RedisConnectionFactory connectionFactory) {
|
||||
MQProperties properties = new MQProperties();
|
||||
return new RedisMQConsumerContainer(
|
||||
connectionFactory,
|
||||
redisTemplate,
|
||||
properties,
|
||||
new PlainMessageConverter(),
|
||||
Mockito.mock(MQDeadLetterService.class),
|
||||
null,
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
private MQMessage message(String messageId, String streamMessageId) {
|
||||
MQMessage message = new MQMessage();
|
||||
message.setMessageId(messageId);
|
||||
message.setStreamMessageId(streamMessageId);
|
||||
return message;
|
||||
}
|
||||
|
||||
private static final class RecordingHandler implements MQConsumerHandler {
|
||||
|
||||
private final List<List<String>> calls = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public MQSubscription subscription() {
|
||||
return new MQSubscription();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(List<MQMessage> messages) {
|
||||
calls.add(messages.stream().map(MQMessage::getMessageId).toList());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PlainMessageConverter implements MQMessageConverter {
|
||||
|
||||
@Override
|
||||
public String serialize(MQMessage message) {
|
||||
return message.getMessageId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public MQMessage deserialize(String payload) {
|
||||
MQMessage message = new MQMessage();
|
||||
message.setMessageId(payload);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
}
|
||||
77
easyflow-modules/easyflow-module-agent/pom.xml
Normal file
77
easyflow-modules/easyflow-module-agent/pom.xml
Normal file
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-modules</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<name>easyflow-module-agent</name>
|
||||
<artifactId>easyflow-module-agent</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-module-ai</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-module-chatlog</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-module-approval</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-module-system</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-common-chat-protocol</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-common-cache</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-common-mq</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-common-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-common-satoken</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mybatis-flex</groupId>
|
||||
<artifactId>mybatis-flex-spring-boot3-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-agent-runtime</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<version>5.12.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,16 @@
|
||||
package tech.easyflow.agent.config;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
|
||||
/**
|
||||
* Agent 模块自动配置。
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@MapperScan("tech.easyflow.agent.mapper")
|
||||
@ComponentScan("tech.easyflow.agent")
|
||||
@EnableConfigurationProperties(AgentRuntimeProperties.class)
|
||||
public class AgentModuleConfig {
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package tech.easyflow.agent.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Agent 运行态生产化配置。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "easyflow.agent.runtime")
|
||||
public class AgentRuntimeProperties {
|
||||
|
||||
/**
|
||||
* Redis 热态 session 缓存 TTL。
|
||||
*/
|
||||
private Duration sessionCacheTtl = Duration.ofHours(24);
|
||||
|
||||
/**
|
||||
* 当前 Agent 运行实例 ID。
|
||||
*/
|
||||
private String instanceId = defaultInstanceId();
|
||||
|
||||
/**
|
||||
* Agent 运行路由 TTL。
|
||||
*/
|
||||
private Duration routeTtl = Duration.ofHours(24);
|
||||
|
||||
/**
|
||||
* Agent 运行命令 topic 前缀。
|
||||
*/
|
||||
private String commandTopicPrefix = "easyflow:agent-runtime-command";
|
||||
|
||||
/**
|
||||
* Agent 运行命令结果等待超时时间。
|
||||
*/
|
||||
private Duration commandResultTimeout = Duration.ofSeconds(5);
|
||||
|
||||
/**
|
||||
* Agent 运行命令结果缓存 TTL。
|
||||
*/
|
||||
private Duration commandResultTtl = Duration.ofMinutes(5);
|
||||
|
||||
/**
|
||||
* 当前进程启动代 ID。
|
||||
*/
|
||||
private final String bootId = UUID.randomUUID().toString();
|
||||
|
||||
/**
|
||||
* HITL pending 默认过期时间。
|
||||
*/
|
||||
private Duration hitlPendingTimeout = Duration.ofMinutes(30);
|
||||
|
||||
/**
|
||||
* 运行锁等待时间。
|
||||
*/
|
||||
private Duration lockWaitTimeout = Duration.ofSeconds(2);
|
||||
|
||||
/**
|
||||
* 运行锁租约时间。
|
||||
*/
|
||||
private Duration lockLeaseTimeout = Duration.ofMinutes(5);
|
||||
|
||||
/**
|
||||
* 运行锁续期间隔。
|
||||
*/
|
||||
private Duration lockRenewInterval = Duration.ofMinutes(1);
|
||||
|
||||
/**
|
||||
* Agent 异步工具任务 Redis 运行态 TTL。
|
||||
*/
|
||||
private Duration asyncToolTaskTtl = Duration.ofHours(24);
|
||||
|
||||
/**
|
||||
* 获取 Redis 热态 session 缓存 TTL。
|
||||
*
|
||||
* @return 缓存 TTL
|
||||
*/
|
||||
public Duration getSessionCacheTtl() {
|
||||
return sessionCacheTtl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Redis 热态 session 缓存 TTL。
|
||||
*
|
||||
* @param sessionCacheTtl 缓存 TTL
|
||||
*/
|
||||
public void setSessionCacheTtl(Duration sessionCacheTtl) {
|
||||
this.sessionCacheTtl = sessionCacheTtl == null ? Duration.ofHours(24) : sessionCacheTtl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 Agent 运行实例 ID。
|
||||
*
|
||||
* @return 实例 ID
|
||||
*/
|
||||
public String getInstanceId() {
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前 Agent 运行实例 ID。
|
||||
*
|
||||
* @param instanceId 实例 ID
|
||||
*/
|
||||
public void setInstanceId(String instanceId) {
|
||||
this.instanceId = StringUtils.hasText(instanceId) ? instanceId.trim() : defaultInstanceId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 运行路由 TTL。
|
||||
*
|
||||
* @return 路由 TTL
|
||||
*/
|
||||
public Duration getRouteTtl() {
|
||||
return routeTtl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Agent 运行路由 TTL。
|
||||
*
|
||||
* @param routeTtl 路由 TTL
|
||||
*/
|
||||
public void setRouteTtl(Duration routeTtl) {
|
||||
this.routeTtl = routeTtl == null ? Duration.ofHours(24) : routeTtl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 运行命令 topic 前缀。
|
||||
*
|
||||
* @return 命令 topic 前缀
|
||||
*/
|
||||
public String getCommandTopicPrefix() {
|
||||
return commandTopicPrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Agent 运行命令 topic 前缀。
|
||||
*
|
||||
* @param commandTopicPrefix 命令 topic 前缀
|
||||
*/
|
||||
public void setCommandTopicPrefix(String commandTopicPrefix) {
|
||||
this.commandTopicPrefix = StringUtils.hasText(commandTopicPrefix)
|
||||
? commandTopicPrefix.trim()
|
||||
: "easyflow:agent-runtime-command";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 运行命令结果等待超时时间。
|
||||
*
|
||||
* @return 等待超时时间
|
||||
*/
|
||||
public Duration getCommandResultTimeout() {
|
||||
return commandResultTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Agent 运行命令结果等待超时时间。
|
||||
*
|
||||
* @param commandResultTimeout 等待超时时间
|
||||
*/
|
||||
public void setCommandResultTimeout(Duration commandResultTimeout) {
|
||||
this.commandResultTimeout = commandResultTimeout == null ? Duration.ofSeconds(5) : commandResultTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 运行命令结果缓存 TTL。
|
||||
*
|
||||
* @return 结果缓存 TTL
|
||||
*/
|
||||
public Duration getCommandResultTtl() {
|
||||
return commandResultTtl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Agent 运行命令结果缓存 TTL。
|
||||
*
|
||||
* @param commandResultTtl 结果缓存 TTL
|
||||
*/
|
||||
public void setCommandResultTtl(Duration commandResultTtl) {
|
||||
this.commandResultTtl = commandResultTtl == null ? Duration.ofMinutes(5) : commandResultTtl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前进程启动代 ID。
|
||||
*
|
||||
* @return 启动代 ID
|
||||
*/
|
||||
public String getBootId() {
|
||||
return bootId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 HITL pending 默认过期时间。
|
||||
*
|
||||
* @return 过期时间
|
||||
*/
|
||||
public Duration getHitlPendingTimeout() {
|
||||
return hitlPendingTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 HITL pending 默认过期时间。
|
||||
*
|
||||
* @param hitlPendingTimeout 过期时间
|
||||
*/
|
||||
public void setHitlPendingTimeout(Duration hitlPendingTimeout) {
|
||||
this.hitlPendingTimeout = hitlPendingTimeout == null ? Duration.ofMinutes(30) : hitlPendingTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取运行锁等待时间。
|
||||
*
|
||||
* @return 等待时间
|
||||
*/
|
||||
public Duration getLockWaitTimeout() {
|
||||
return lockWaitTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置运行锁等待时间。
|
||||
*
|
||||
* @param lockWaitTimeout 等待时间
|
||||
*/
|
||||
public void setLockWaitTimeout(Duration lockWaitTimeout) {
|
||||
this.lockWaitTimeout = lockWaitTimeout == null ? Duration.ofSeconds(2) : lockWaitTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取运行锁租约时间。
|
||||
*
|
||||
* @return 租约时间
|
||||
*/
|
||||
public Duration getLockLeaseTimeout() {
|
||||
return lockLeaseTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置运行锁租约时间。
|
||||
*
|
||||
* @param lockLeaseTimeout 租约时间
|
||||
*/
|
||||
public void setLockLeaseTimeout(Duration lockLeaseTimeout) {
|
||||
this.lockLeaseTimeout = lockLeaseTimeout == null ? Duration.ofMinutes(5) : lockLeaseTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取运行锁续期间隔。
|
||||
*
|
||||
* @return 续期间隔
|
||||
*/
|
||||
public Duration getLockRenewInterval() {
|
||||
return lockRenewInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置运行锁续期间隔。
|
||||
*
|
||||
* @param lockRenewInterval 续期间隔
|
||||
*/
|
||||
public void setLockRenewInterval(Duration lockRenewInterval) {
|
||||
this.lockRenewInterval = lockRenewInterval == null ? Duration.ofMinutes(1) : lockRenewInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 异步工具任务 Redis 运行态 TTL。
|
||||
*
|
||||
* @return 任务 TTL
|
||||
*/
|
||||
public Duration getAsyncToolTaskTtl() {
|
||||
return asyncToolTaskTtl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Agent 异步工具任务 Redis 运行态 TTL。
|
||||
*
|
||||
* @param asyncToolTaskTtl 任务 TTL
|
||||
*/
|
||||
public void setAsyncToolTaskTtl(Duration asyncToolTaskTtl) {
|
||||
this.asyncToolTaskTtl = asyncToolTaskTtl == null ? Duration.ofHours(24) : asyncToolTaskTtl;
|
||||
}
|
||||
|
||||
private static String defaultInstanceId() {
|
||||
String envInstanceId = System.getenv("EASYFLOW_INSTANCE_ID");
|
||||
if (StringUtils.hasText(envInstanceId)) {
|
||||
return envInstanceId.trim();
|
||||
}
|
||||
String hostName = System.getenv("HOSTNAME");
|
||||
if (StringUtils.hasText(hostName)) {
|
||||
return hostName.trim();
|
||||
}
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package tech.easyflow.agent.distributed;
|
||||
|
||||
/**
|
||||
* Agent 运行态远程命令动作。
|
||||
*/
|
||||
public enum AgentRuntimeCommandAction {
|
||||
|
||||
/**
|
||||
* 批准工具执行。
|
||||
*/
|
||||
APPROVE,
|
||||
|
||||
/**
|
||||
* 拒绝工具执行。
|
||||
*/
|
||||
REJECT
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package tech.easyflow.agent.distributed;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.agent.config.AgentRuntimeProperties;
|
||||
import tech.easyflow.agent.runtime.AgentRunService;
|
||||
import tech.easyflow.common.mq.config.MQProperties;
|
||||
import tech.easyflow.common.mq.core.MQConsumerHandler;
|
||||
import tech.easyflow.common.mq.core.MQMessage;
|
||||
import tech.easyflow.common.mq.core.MQSubscription;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Agent 运行态远程命令消费者。
|
||||
*/
|
||||
@Component
|
||||
public class AgentRuntimeCommandConsumer implements MQConsumerHandler {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AgentRuntimeCommandConsumer.class);
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AgentRuntimeProperties properties;
|
||||
private final MQProperties mqProperties;
|
||||
private final AgentRunService agentRunService;
|
||||
private final AgentRuntimeCommandResultRegistry resultRegistry;
|
||||
|
||||
/**
|
||||
* 创建 Agent 运行态远程命令消费者。
|
||||
*
|
||||
* @param objectMapper JSON 序列化器
|
||||
* @param properties Agent 运行配置
|
||||
* @param mqProperties MQ 配置
|
||||
* @param agentRunService Agent 运行服务
|
||||
* @param resultRegistry 远程命令结果注册表
|
||||
*/
|
||||
public AgentRuntimeCommandConsumer(ObjectMapper objectMapper,
|
||||
AgentRuntimeProperties properties,
|
||||
MQProperties mqProperties,
|
||||
AgentRunService agentRunService,
|
||||
AgentRuntimeCommandResultRegistry resultRegistry) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.properties = properties;
|
||||
this.mqProperties = mqProperties;
|
||||
this.agentRunService = agentRunService;
|
||||
this.resultRegistry = resultRegistry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MQSubscription subscription() {
|
||||
MQSubscription subscription = new MQSubscription();
|
||||
subscription.setTopic(commandTopic());
|
||||
subscription.setConsumerGroup(commandTopic());
|
||||
subscription.setShardCount(Math.max(mqProperties.getRedis().getChatPersistShardCount(), 1));
|
||||
subscription.setBatchEnabled(false);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(List<MQMessage> messages) {
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (MQMessage message : messages) {
|
||||
try {
|
||||
handleCommand(message, objectMapper.readValue(message.getBody(), AgentRuntimeCommandMessage.class));
|
||||
} catch (Exception e) {
|
||||
LOG.warn("Agent 远程运行命令解析失败: messageId={}", message.getMessageId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCommand(MQMessage message, AgentRuntimeCommandMessage command) {
|
||||
if (command == null || command.getAction() == null) {
|
||||
LOG.warn("跳过非法 Agent 远程运行命令: messageId={}", message.getMessageId());
|
||||
return;
|
||||
}
|
||||
if (!properties.getInstanceId().equals(command.getTargetNodeId())) {
|
||||
LOG.warn("跳过非本节点 Agent 远程运行命令: messageId={}, targetNodeId={}, currentNodeId={}",
|
||||
message.getMessageId(), command.getTargetNodeId(), properties.getInstanceId());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (command.getAction() == AgentRuntimeCommandAction.APPROVE) {
|
||||
agentRunService.approveRuntimeLocal(
|
||||
command.getRequestId(), command.getResumeToken(), command.getOperatorId(), command.getUserId());
|
||||
} else if (command.getAction() == AgentRuntimeCommandAction.REJECT) {
|
||||
agentRunService.rejectRuntimeLocal(
|
||||
command.getRequestId(), command.getResumeToken(), command.getReason(),
|
||||
command.getOperatorId(), command.getUserId());
|
||||
} else {
|
||||
markFailureQuietly(command, new IllegalArgumentException("不支持的 Agent 远程运行命令"));
|
||||
LOG.warn("跳过不支持的 Agent 远程运行命令: messageId={}, commandId={}, action={}",
|
||||
message.getMessageId(), command.getCommandId(), command.getAction());
|
||||
return;
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
markFailureQuietly(command, e);
|
||||
LOG.warn("Agent 远程运行命令处理失败: messageId={}, commandId={}",
|
||||
message.getMessageId(), command.getCommandId(), e);
|
||||
return;
|
||||
}
|
||||
markSuccessQuietly(command);
|
||||
}
|
||||
|
||||
private String commandTopic() {
|
||||
return properties.getCommandTopicPrefix() + ":" + properties.getInstanceId();
|
||||
}
|
||||
|
||||
private void markSuccessQuietly(AgentRuntimeCommandMessage command) {
|
||||
try {
|
||||
resultRegistry.markSuccess(command.getCommandId());
|
||||
} catch (RuntimeException e) {
|
||||
LOG.error("Agent 远程运行命令成功结果写入失败: commandId={}", command.getCommandId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void markFailureQuietly(AgentRuntimeCommandMessage command, RuntimeException cause) {
|
||||
try {
|
||||
resultRegistry.markFailure(command.getCommandId(), cause.getMessage());
|
||||
} catch (RuntimeException e) {
|
||||
LOG.error("Agent 远程运行命令失败结果写入失败: commandId={}", command.getCommandId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package tech.easyflow.agent.distributed;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* Agent 运行态远程恢复命令消息。
|
||||
*/
|
||||
public class AgentRuntimeCommandMessage {
|
||||
|
||||
private String commandId;
|
||||
private String requestId;
|
||||
private String resumeToken;
|
||||
private AgentRuntimeCommandAction action;
|
||||
private String reason;
|
||||
private BigInteger operatorId;
|
||||
private String userId;
|
||||
private String targetNodeId;
|
||||
private Date occurredAt;
|
||||
|
||||
public String getCommandId() {
|
||||
return commandId;
|
||||
}
|
||||
|
||||
public void setCommandId(String commandId) {
|
||||
this.commandId = commandId;
|
||||
}
|
||||
|
||||
public String getRequestId() {
|
||||
return requestId;
|
||||
}
|
||||
|
||||
public void setRequestId(String requestId) {
|
||||
this.requestId = requestId;
|
||||
}
|
||||
|
||||
public String getResumeToken() {
|
||||
return resumeToken;
|
||||
}
|
||||
|
||||
public void setResumeToken(String resumeToken) {
|
||||
this.resumeToken = resumeToken;
|
||||
}
|
||||
|
||||
public AgentRuntimeCommandAction getAction() {
|
||||
return action;
|
||||
}
|
||||
|
||||
public void setAction(AgentRuntimeCommandAction action) {
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
public String getReason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
public void setReason(String reason) {
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public BigInteger getOperatorId() {
|
||||
return operatorId;
|
||||
}
|
||||
|
||||
public void setOperatorId(BigInteger operatorId) {
|
||||
this.operatorId = operatorId;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(String userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public String getTargetNodeId() {
|
||||
return targetNodeId;
|
||||
}
|
||||
|
||||
public void setTargetNodeId(String targetNodeId) {
|
||||
this.targetNodeId = targetNodeId;
|
||||
}
|
||||
|
||||
public Date getOccurredAt() {
|
||||
return occurredAt;
|
||||
}
|
||||
|
||||
public void setOccurredAt(Date occurredAt) {
|
||||
this.occurredAt = occurredAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package tech.easyflow.agent.distributed;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.agent.config.AgentRuntimeProperties;
|
||||
import tech.easyflow.common.mq.core.MQMessage;
|
||||
import tech.easyflow.common.mq.core.MQProducer;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Date;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Agent 运行态远程命令生产者。
|
||||
*/
|
||||
@Service
|
||||
public class AgentRuntimeCommandProducer {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AgentRuntimeCommandProducer.class);
|
||||
|
||||
private final MQProducer mqProducer;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AgentRuntimeProperties properties;
|
||||
private final AgentRuntimeCommandResultRegistry resultRegistry;
|
||||
|
||||
/**
|
||||
* 测试子类构造器。
|
||||
*/
|
||||
protected AgentRuntimeCommandProducer() {
|
||||
this.mqProducer = null;
|
||||
this.objectMapper = null;
|
||||
this.properties = null;
|
||||
this.resultRegistry = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Agent 运行态远程命令生产者。
|
||||
*
|
||||
* @param mqProducer MQ 生产者
|
||||
* @param objectMapper JSON 序列化器
|
||||
* @param properties Agent 运行配置
|
||||
* @param resultRegistry 远程命令结果注册表
|
||||
*/
|
||||
public AgentRuntimeCommandProducer(MQProducer mqProducer,
|
||||
ObjectMapper objectMapper,
|
||||
AgentRuntimeProperties properties,
|
||||
AgentRuntimeCommandResultRegistry resultRegistry) {
|
||||
this.mqProducer = mqProducer;
|
||||
this.objectMapper = objectMapper;
|
||||
this.properties = properties;
|
||||
this.resultRegistry = resultRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* 投递远程批准命令。
|
||||
*
|
||||
* @param targetNodeId 目标节点 ID
|
||||
* @param requestId 请求 ID
|
||||
* @param resumeToken 恢复令牌
|
||||
* @param operatorId 操作人 ID
|
||||
* @param userId 用户 ID
|
||||
*/
|
||||
public void sendApprove(String targetNodeId,
|
||||
String requestId,
|
||||
String resumeToken,
|
||||
BigInteger operatorId,
|
||||
String userId) {
|
||||
sendAndWait(targetNodeId, requestId, resumeToken, AgentRuntimeCommandAction.APPROVE, null, operatorId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 投递远程拒绝命令。
|
||||
*
|
||||
* @param targetNodeId 目标节点 ID
|
||||
* @param requestId 请求 ID
|
||||
* @param resumeToken 恢复令牌
|
||||
* @param reason 拒绝原因
|
||||
* @param operatorId 操作人 ID
|
||||
* @param userId 用户 ID
|
||||
*/
|
||||
public void sendReject(String targetNodeId,
|
||||
String requestId,
|
||||
String resumeToken,
|
||||
String reason,
|
||||
BigInteger operatorId,
|
||||
String userId) {
|
||||
sendAndWait(targetNodeId, requestId, resumeToken, AgentRuntimeCommandAction.REJECT, reason, operatorId, userId);
|
||||
}
|
||||
|
||||
private void sendAndWait(String targetNodeId,
|
||||
String requestId,
|
||||
String resumeToken,
|
||||
AgentRuntimeCommandAction action,
|
||||
String reason,
|
||||
BigInteger operatorId,
|
||||
String userId) {
|
||||
if (targetNodeId == null || targetNodeId.isBlank()) {
|
||||
throw new BusinessException("Agent 运行节点不可用,请重新发起对话");
|
||||
}
|
||||
AgentRuntimeCommandMessage command = new AgentRuntimeCommandMessage();
|
||||
command.setCommandId(UUID.randomUUID().toString());
|
||||
command.setRequestId(requestId);
|
||||
command.setResumeToken(resumeToken);
|
||||
command.setAction(action);
|
||||
command.setReason(reason);
|
||||
command.setOperatorId(operatorId);
|
||||
command.setUserId(userId);
|
||||
command.setTargetNodeId(targetNodeId);
|
||||
command.setOccurredAt(new Date());
|
||||
|
||||
MQMessage message = new MQMessage();
|
||||
message.setMessageId(command.getCommandId());
|
||||
message.setTopic(commandTopic(targetNodeId));
|
||||
message.setKey(command.getCommandId());
|
||||
message.setCreatedAt(command.getOccurredAt());
|
||||
try {
|
||||
message.setBody(objectMapper.writeValueAsString(command));
|
||||
String recordId = mqProducer.send(message);
|
||||
LOG.info("Agent 远程运行命令已投递: action={}, requestId={}, targetNodeId={}, recordId={}",
|
||||
action, requestId, targetNodeId, recordId);
|
||||
AgentRuntimeCommandResult result = resultRegistry.waitForResult(command.getCommandId());
|
||||
if (!result.isSuccess()) {
|
||||
throw new BusinessException(result.getMessage());
|
||||
}
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new BusinessException("Agent 运行命令序列化失败");
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (RuntimeException e) {
|
||||
LOG.error("Agent 远程运行命令投递失败: action={}, requestId={}, targetNodeId={}",
|
||||
action, requestId, targetNodeId, e);
|
||||
throw new BusinessException("Agent 运行节点不可用,请重新发起对话");
|
||||
} finally {
|
||||
deleteResultQuietly(command.getCommandId());
|
||||
}
|
||||
}
|
||||
|
||||
private String commandTopic(String nodeId) {
|
||||
return properties.getCommandTopicPrefix() + ":" + nodeId;
|
||||
}
|
||||
|
||||
private void deleteResultQuietly(String commandId) {
|
||||
try {
|
||||
resultRegistry.deleteResult(commandId);
|
||||
} catch (RuntimeException e) {
|
||||
LOG.warn("Agent 远程运行命令结果清理失败,等待 TTL 兜底: commandId={}", commandId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package tech.easyflow.agent.distributed;
|
||||
|
||||
/**
|
||||
* Agent 运行态远程命令结果。
|
||||
*/
|
||||
public class AgentRuntimeCommandResult {
|
||||
|
||||
private boolean success;
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 判断命令是否执行成功。
|
||||
*
|
||||
* @return true 表示执行成功
|
||||
*/
|
||||
public boolean isSuccess() {
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置命令是否执行成功。
|
||||
*
|
||||
* @param success 是否执行成功
|
||||
*/
|
||||
public void setSuccess(boolean success) {
|
||||
this.success = success;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取结果消息。
|
||||
*
|
||||
* @return 结果消息
|
||||
*/
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置结果消息。
|
||||
*
|
||||
* @param message 结果消息
|
||||
*/
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package tech.easyflow.agent.distributed;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.agent.config.AgentRuntimeProperties;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
/**
|
||||
* Agent 运行态远程命令结果注册表。
|
||||
*/
|
||||
@Component
|
||||
public class AgentRuntimeCommandResultRegistry {
|
||||
|
||||
private static final String RESULT_PREFIX = "easyflow:agent:runtime:command-result:";
|
||||
private static final long POLL_INTERVAL_MILLIS = 50L;
|
||||
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AgentRuntimeProperties properties;
|
||||
|
||||
/**
|
||||
* 创建 Agent 运行态远程命令结果注册表。
|
||||
*
|
||||
* @param stringRedisTemplate Redis 字符串模板
|
||||
* @param objectMapper JSON 序列化器
|
||||
* @param properties Agent 运行配置
|
||||
*/
|
||||
public AgentRuntimeCommandResultRegistry(StringRedisTemplate stringRedisTemplate,
|
||||
ObjectMapper objectMapper,
|
||||
AgentRuntimeProperties properties) {
|
||||
this.stringRedisTemplate = stringRedisTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入成功结果。
|
||||
*
|
||||
* @param commandId 命令 ID
|
||||
*/
|
||||
public void markSuccess(String commandId) {
|
||||
AgentRuntimeCommandResult result = new AgentRuntimeCommandResult();
|
||||
result.setSuccess(true);
|
||||
result.setMessage("OK");
|
||||
writeResult(commandId, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入失败结果。
|
||||
*
|
||||
* @param commandId 命令 ID
|
||||
* @param message 失败消息
|
||||
*/
|
||||
public void markFailure(String commandId, String message) {
|
||||
AgentRuntimeCommandResult result = new AgentRuntimeCommandResult();
|
||||
result.setSuccess(false);
|
||||
result.setMessage(message == null || message.isBlank() ? "Agent 运行节点不可用,请重新发起对话" : message);
|
||||
writeResult(commandId, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待远程命令结果。
|
||||
*
|
||||
* @param commandId 命令 ID
|
||||
* @return 命令结果
|
||||
*/
|
||||
public AgentRuntimeCommandResult waitForResult(String commandId) {
|
||||
long deadline = System.nanoTime() + properties.getCommandResultTimeout().toNanos();
|
||||
while (System.nanoTime() <= deadline) {
|
||||
AgentRuntimeCommandResult result = readResult(commandId);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
sleep();
|
||||
}
|
||||
throw new BusinessException("Agent 运行节点响应超时,请稍后重试");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除远程命令结果。
|
||||
*
|
||||
* @param commandId 命令 ID
|
||||
*/
|
||||
public void deleteResult(String commandId) {
|
||||
if (commandId == null || commandId.isBlank()) {
|
||||
return;
|
||||
}
|
||||
stringRedisTemplate.delete(resultKey(commandId));
|
||||
}
|
||||
|
||||
private AgentRuntimeCommandResult readResult(String commandId) {
|
||||
if (commandId == null || commandId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String value = stringRedisTemplate.opsForValue().get(resultKey(commandId));
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(value, AgentRuntimeCommandResult.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new BusinessException("Agent 运行命令结果解析失败");
|
||||
}
|
||||
}
|
||||
|
||||
private void writeResult(String commandId, AgentRuntimeCommandResult result) {
|
||||
if (commandId == null || commandId.isBlank()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
stringRedisTemplate.opsForValue().set(
|
||||
resultKey(commandId),
|
||||
objectMapper.writeValueAsString(result),
|
||||
properties.getCommandResultTtl());
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException("Agent 运行命令结果序列化失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String resultKey(String commandId) {
|
||||
return RESULT_PREFIX + commandId;
|
||||
}
|
||||
|
||||
private void sleep() {
|
||||
try {
|
||||
Thread.sleep(POLL_INTERVAL_MILLIS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new BusinessException("Agent 运行节点响应等待被中断");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package tech.easyflow.agent.distributed;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Agent 运行节点心跳维护器。
|
||||
*/
|
||||
@Component
|
||||
public class AgentRuntimeNodeHeartbeat {
|
||||
|
||||
private static final Duration HEARTBEAT_TTL = Duration.ofSeconds(90);
|
||||
|
||||
private final AgentRuntimeRouteRegistry routeRegistry;
|
||||
|
||||
/**
|
||||
* 创建 Agent 运行节点心跳维护器。
|
||||
*
|
||||
* @param routeRegistry Agent 运行态 Redis 路由注册表
|
||||
*/
|
||||
public AgentRuntimeNodeHeartbeat(AgentRuntimeRouteRegistry routeRegistry) {
|
||||
this.routeRegistry = routeRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动时立即写入一次当前节点心跳。
|
||||
*/
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 定期刷新当前节点心跳。
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "${easyflow.agent.runtime.node-heartbeat-delay:30000}", initialDelay = 30000L)
|
||||
public void refresh() {
|
||||
routeRegistry.heartbeat(HEARTBEAT_TTL);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package tech.easyflow.agent.distributed;
|
||||
|
||||
/**
|
||||
* Agent 运行态 owner 路由。
|
||||
*/
|
||||
public class AgentRuntimeRoute {
|
||||
|
||||
private String nodeId;
|
||||
private String bootId;
|
||||
|
||||
/**
|
||||
* 获取 owner 节点 ID。
|
||||
*
|
||||
* @return owner 节点 ID
|
||||
*/
|
||||
public String getNodeId() {
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 owner 节点 ID。
|
||||
*
|
||||
* @param nodeId owner 节点 ID
|
||||
*/
|
||||
public void setNodeId(String nodeId) {
|
||||
this.nodeId = nodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 owner 启动代 ID。
|
||||
*
|
||||
* @return 启动代 ID
|
||||
*/
|
||||
public String getBootId() {
|
||||
return bootId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 owner 启动代 ID。
|
||||
*
|
||||
* @param bootId 启动代 ID
|
||||
*/
|
||||
public void setBootId(String bootId) {
|
||||
this.bootId = bootId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package tech.easyflow.agent.distributed;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.agent.config.AgentRuntimeProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Agent 运行态 Redis 路由注册表。
|
||||
*/
|
||||
@Component
|
||||
public class AgentRuntimeRouteRegistry {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AgentRuntimeRouteRegistry.class);
|
||||
|
||||
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 NODE_HEARTBEAT_PREFIX = "easyflow:agent:runtime:node:";
|
||||
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final AgentRuntimeProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 创建 Agent 运行态 Redis 路由注册表。
|
||||
*
|
||||
* @param stringRedisTemplate Redis 字符串模板
|
||||
* @param properties Agent 运行配置
|
||||
* @param objectMapper JSON 序列化器
|
||||
*/
|
||||
public AgentRuntimeRouteRegistry(StringRedisTemplate stringRedisTemplate,
|
||||
AgentRuntimeProperties properties,
|
||||
ObjectMapper objectMapper) {
|
||||
this.stringRedisTemplate = stringRedisTemplate;
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册运行请求 owner 节点。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
*/
|
||||
public void registerRun(String requestId) {
|
||||
if (requestId == null || requestId.isBlank()) {
|
||||
return;
|
||||
}
|
||||
stringRedisTemplate.opsForValue().set(requestKey(requestId), serializeRoute(currentRoute()), properties.getRouteTtl());
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册恢复令牌与请求 ID 的关系。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
* @param resumeToken 恢复令牌
|
||||
*/
|
||||
public void registerResumeToken(String requestId, String resumeToken) {
|
||||
if (requestId == null || requestId.isBlank() || resumeToken == null || resumeToken.isBlank()) {
|
||||
return;
|
||||
}
|
||||
stringRedisTemplate.opsForValue().set(tokenKey(resumeToken), requestId, properties.getRouteTtl());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询请求 ID 所属节点。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
* @return owner 节点 ID
|
||||
*/
|
||||
public String findOwnerNode(String requestId) {
|
||||
AgentRuntimeRoute route = findOwnerRoute(requestId);
|
||||
return route == null ? null : route.getNodeId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询请求 ID 所属路由。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
* @return owner 路由
|
||||
*/
|
||||
public AgentRuntimeRoute findOwnerRoute(String requestId) {
|
||||
if (requestId == null || requestId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String value = stringRedisTemplate.opsForValue().get(requestKey(requestId));
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return deserializeRoute(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据恢复令牌查询请求 ID。
|
||||
*
|
||||
* @param resumeToken 恢复令牌
|
||||
* @return 请求 ID
|
||||
*/
|
||||
public String findRequestIdByResumeToken(String resumeToken) {
|
||||
if (resumeToken == null || resumeToken.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return stringRedisTemplate.opsForValue().get(tokenKey(resumeToken));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定运行请求的路由。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
*/
|
||||
public void removeRun(String requestId) {
|
||||
if (requestId == null || requestId.isBlank()) {
|
||||
return;
|
||||
}
|
||||
deleteQuietly(requestKey(requestId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定恢复令牌的路由。
|
||||
*
|
||||
* @param resumeToken 恢复令牌
|
||||
*/
|
||||
public void removeResumeToken(String resumeToken) {
|
||||
if (resumeToken == null || resumeToken.isBlank()) {
|
||||
return;
|
||||
}
|
||||
deleteQuietly(tokenKey(resumeToken));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前节点 ID。
|
||||
*
|
||||
* @return 当前节点 ID
|
||||
*/
|
||||
public String currentNodeId() {
|
||||
return properties.getInstanceId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新当前节点存活心跳。
|
||||
*
|
||||
* @param ttl 心跳 TTL
|
||||
*/
|
||||
public void heartbeat(Duration ttl) {
|
||||
stringRedisTemplate.opsForValue().set(nodeKey(properties.getInstanceId()), properties.getBootId(), ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定节点是否仍有存活心跳。
|
||||
*
|
||||
* @param nodeId 节点 ID
|
||||
* @return true 表示节点心跳仍有效
|
||||
*/
|
||||
public boolean isNodeAlive(String nodeId) {
|
||||
return currentNodeBootId(nodeId) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定节点当前启动代 ID。
|
||||
*
|
||||
* @param nodeId 节点 ID
|
||||
* @return 启动代 ID
|
||||
*/
|
||||
public String currentNodeBootId(String nodeId) {
|
||||
if (nodeId == null || nodeId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return stringRedisTemplate.opsForValue().get(nodeKey(nodeId));
|
||||
}
|
||||
|
||||
private String requestKey(String requestId) {
|
||||
return REQUEST_ROUTE_PREFIX + requestId;
|
||||
}
|
||||
|
||||
private String tokenKey(String resumeToken) {
|
||||
return TOKEN_ROUTE_PREFIX + resumeToken;
|
||||
}
|
||||
|
||||
private String nodeKey(String nodeId) {
|
||||
return NODE_HEARTBEAT_PREFIX + nodeId;
|
||||
}
|
||||
|
||||
private AgentRuntimeRoute currentRoute() {
|
||||
AgentRuntimeRoute route = new AgentRuntimeRoute();
|
||||
route.setNodeId(properties.getInstanceId());
|
||||
route.setBootId(properties.getBootId());
|
||||
return route;
|
||||
}
|
||||
|
||||
private String serializeRoute(AgentRuntimeRoute route) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(route);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException("Agent 运行路由序列化失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private AgentRuntimeRoute deserializeRoute(String value) {
|
||||
try {
|
||||
if (value.trim().startsWith("{")) {
|
||||
return objectMapper.readValue(value, AgentRuntimeRoute.class);
|
||||
}
|
||||
AgentRuntimeRoute legacyRoute = new AgentRuntimeRoute();
|
||||
legacyRoute.setNodeId(value);
|
||||
return legacyRoute;
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException("Agent 运行路由反序列化失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteQuietly(String key) {
|
||||
try {
|
||||
stringRedisTemplate.delete(key);
|
||||
} catch (RuntimeException e) {
|
||||
LOG.warn("清理 Agent 运行态 Redis 路由失败: key={}", key, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
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 tech.easyflow.system.permission.resource.VisibilityResource;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Agent 主实体。
|
||||
*/
|
||||
@Table("tb_agent")
|
||||
public class Agent extends DateEntity implements VisibilityResource, Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
|
||||
private BigInteger id;
|
||||
@Column(tenantId = true)
|
||||
private BigInteger tenantId;
|
||||
private BigInteger deptId;
|
||||
private String name;
|
||||
private String description;
|
||||
private String avatar;
|
||||
private BigInteger categoryId;
|
||||
private BigInteger modelId;
|
||||
@Column(typeHandler = FastjsonTypeHandler.class)
|
||||
private Map<String, Object> modelConfigJson = new LinkedHashMap<>();
|
||||
@Column(typeHandler = FastjsonTypeHandler.class)
|
||||
private Map<String, Object> generationConfigJson = new LinkedHashMap<>();
|
||||
@Column(typeHandler = FastjsonTypeHandler.class)
|
||||
private Map<String, Object> promptConfigJson = new LinkedHashMap<>();
|
||||
@Column(typeHandler = FastjsonTypeHandler.class)
|
||||
private Map<String, Object> memoryConfigJson = new LinkedHashMap<>();
|
||||
@Column(typeHandler = FastjsonTypeHandler.class)
|
||||
private Map<String, Object> executionConfigJson = new LinkedHashMap<>();
|
||||
private Integer status;
|
||||
private String visibilityScope;
|
||||
private String publishStatus;
|
||||
private BigInteger currentApprovalInstanceId;
|
||||
@Column(typeHandler = FastjsonTypeHandler.class)
|
||||
private Map<String, Object> publishedSnapshotJson = new LinkedHashMap<>();
|
||||
private Date publishedAt;
|
||||
private BigInteger publishedBy;
|
||||
private Date created;
|
||||
private BigInteger createdBy;
|
||||
private Date modified;
|
||||
private BigInteger modifiedBy;
|
||||
|
||||
@Column(ignore = true)
|
||||
private Boolean approvalPending;
|
||||
@Column(ignore = true)
|
||||
private String currentApprovalActionType;
|
||||
@Column(ignore = true)
|
||||
private String displayPublishStatus;
|
||||
@Column(ignore = true)
|
||||
private String createdByName;
|
||||
@Column(ignore = true)
|
||||
private List<AgentToolBinding> toolBindings;
|
||||
@Column(ignore = true)
|
||||
private List<AgentKnowledgeBinding> knowledgeBindings;
|
||||
|
||||
public BigInteger getId() { return id; }
|
||||
public void setId(BigInteger id) { this.id = id; }
|
||||
public BigInteger getTenantId() { return tenantId; }
|
||||
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
|
||||
public BigInteger getDeptId() { return deptId; }
|
||||
public void setDeptId(BigInteger deptId) { this.deptId = deptId; }
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
public String getAvatar() { return avatar; }
|
||||
public void setAvatar(String avatar) { this.avatar = avatar; }
|
||||
public BigInteger getCategoryId() { return categoryId; }
|
||||
public void setCategoryId(BigInteger categoryId) { this.categoryId = categoryId; }
|
||||
public BigInteger getModelId() { return modelId; }
|
||||
public void setModelId(BigInteger modelId) { this.modelId = modelId; }
|
||||
public Map<String, Object> getModelConfigJson() { return modelConfigJson; }
|
||||
public void setModelConfigJson(Map<String, Object> modelConfigJson) { this.modelConfigJson = modelConfigJson == null ? new LinkedHashMap<>() : modelConfigJson; }
|
||||
public Map<String, Object> getGenerationConfigJson() { return generationConfigJson; }
|
||||
public void setGenerationConfigJson(Map<String, Object> generationConfigJson) { this.generationConfigJson = generationConfigJson == null ? new LinkedHashMap<>() : generationConfigJson; }
|
||||
public Map<String, Object> getPromptConfigJson() { return promptConfigJson; }
|
||||
public void setPromptConfigJson(Map<String, Object> promptConfigJson) { this.promptConfigJson = promptConfigJson == null ? new LinkedHashMap<>() : promptConfigJson; }
|
||||
public Map<String, Object> getMemoryConfigJson() { return memoryConfigJson; }
|
||||
public void setMemoryConfigJson(Map<String, Object> memoryConfigJson) { this.memoryConfigJson = memoryConfigJson == null ? new LinkedHashMap<>() : memoryConfigJson; }
|
||||
public Map<String, Object> getExecutionConfigJson() { return executionConfigJson; }
|
||||
public void setExecutionConfigJson(Map<String, Object> executionConfigJson) { this.executionConfigJson = executionConfigJson == null ? new LinkedHashMap<>() : executionConfigJson; }
|
||||
public Integer getStatus() { return status; }
|
||||
public void setStatus(Integer status) { this.status = status; }
|
||||
public String getVisibilityScope() { return visibilityScope; }
|
||||
public void setVisibilityScope(String visibilityScope) { this.visibilityScope = visibilityScope; }
|
||||
public String getPublishStatus() { return publishStatus; }
|
||||
public void setPublishStatus(String publishStatus) { this.publishStatus = publishStatus; }
|
||||
public BigInteger getCurrentApprovalInstanceId() { return currentApprovalInstanceId; }
|
||||
public void setCurrentApprovalInstanceId(BigInteger currentApprovalInstanceId) { this.currentApprovalInstanceId = currentApprovalInstanceId; }
|
||||
public Map<String, Object> getPublishedSnapshotJson() { return publishedSnapshotJson; }
|
||||
public void setPublishedSnapshotJson(Map<String, Object> publishedSnapshotJson) { this.publishedSnapshotJson = publishedSnapshotJson == null ? new LinkedHashMap<>() : publishedSnapshotJson; }
|
||||
public Date getPublishedAt() { return publishedAt; }
|
||||
public void setPublishedAt(Date publishedAt) { this.publishedAt = publishedAt; }
|
||||
public BigInteger getPublishedBy() { return publishedBy; }
|
||||
public void setPublishedBy(BigInteger publishedBy) { this.publishedBy = publishedBy; }
|
||||
@Override public Date getCreated() { return created; }
|
||||
@Override public void setCreated(Date created) { this.created = created; }
|
||||
public BigInteger getCreatedBy() { return createdBy; }
|
||||
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
|
||||
@Override public Date getModified() { return modified; }
|
||||
@Override public void setModified(Date modified) { this.modified = modified; }
|
||||
public BigInteger getModifiedBy() { return modifiedBy; }
|
||||
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
|
||||
public Boolean getApprovalPending() { return approvalPending; }
|
||||
public void setApprovalPending(Boolean approvalPending) { this.approvalPending = approvalPending; }
|
||||
public String getCurrentApprovalActionType() { return currentApprovalActionType; }
|
||||
public void setCurrentApprovalActionType(String currentApprovalActionType) { this.currentApprovalActionType = currentApprovalActionType; }
|
||||
public String getDisplayPublishStatus() { return displayPublishStatus; }
|
||||
public void setDisplayPublishStatus(String displayPublishStatus) { this.displayPublishStatus = displayPublishStatus; }
|
||||
public String getCreatedByName() { return createdByName; }
|
||||
public void setCreatedByName(String createdByName) { this.createdByName = createdByName; }
|
||||
public List<AgentToolBinding> getToolBindings() { return toolBindings; }
|
||||
public void setToolBindings(List<AgentToolBinding> toolBindings) { this.toolBindings = toolBindings; }
|
||||
public List<AgentKnowledgeBinding> getKnowledgeBindings() { return knowledgeBindings; }
|
||||
public void setKnowledgeBindings(List<AgentKnowledgeBinding> knowledgeBindings) { this.knowledgeBindings = knowledgeBindings; }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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_category")
|
||||
public class AgentCategory extends DateEntity implements Serializable {
|
||||
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
|
||||
private BigInteger id;
|
||||
@Column(tenantId = true)
|
||||
private BigInteger tenantId;
|
||||
private String categoryName;
|
||||
private Integer sortNo;
|
||||
private Integer status;
|
||||
private Date created;
|
||||
private BigInteger createdBy;
|
||||
private Date modified;
|
||||
private BigInteger modifiedBy;
|
||||
|
||||
public BigInteger getId() { return id; }
|
||||
public void setId(BigInteger id) { this.id = id; }
|
||||
public BigInteger getTenantId() { return tenantId; }
|
||||
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
|
||||
public String getCategoryName() { return categoryName; }
|
||||
public void setCategoryName(String categoryName) { this.categoryName = categoryName; }
|
||||
public Integer getSortNo() { return sortNo; }
|
||||
public void setSortNo(Integer sortNo) { this.sortNo = sortNo; }
|
||||
public Integer getStatus() { return status; }
|
||||
public void setStatus(Integer status) { this.status = status; }
|
||||
@Override public Date getCreated() { return created; }
|
||||
@Override public void setCreated(Date created) { this.created = created; }
|
||||
public BigInteger getCreatedBy() { return createdBy; }
|
||||
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
|
||||
@Override public Date getModified() { return modified; }
|
||||
@Override public void setModified(Date modified) { this.modified = modified; }
|
||||
public BigInteger getModifiedBy() { return modifiedBy; }
|
||||
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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 工具审批挂起态实体。
|
||||
*/
|
||||
@Table("tb_agent_hitl_pending")
|
||||
public class AgentHitlPending 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 chatSessionId;
|
||||
private String runtimeSessionId;
|
||||
private String requestId;
|
||||
private String resumeToken;
|
||||
private String toolCallId;
|
||||
private String toolName;
|
||||
@Column(typeHandler = FastjsonTypeHandler.class)
|
||||
private Map<String, Object> toolInputJson = new LinkedHashMap<>();
|
||||
private String status;
|
||||
private String rejectReason;
|
||||
private Date expiresAt;
|
||||
private Date consumedAt;
|
||||
@Column(typeHandler = FastjsonTypeHandler.class)
|
||||
private Map<String, Object> metadataJson = new LinkedHashMap<>();
|
||||
private Date created;
|
||||
private BigInteger createdBy;
|
||||
private Date modified;
|
||||
private BigInteger modifiedBy;
|
||||
private Integer isDeleted;
|
||||
|
||||
public BigInteger getId() { return id; }
|
||||
public void setId(BigInteger id) { this.id = id; }
|
||||
public BigInteger getTenantId() { return tenantId; }
|
||||
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
|
||||
public BigInteger getAgentId() { return agentId; }
|
||||
public void setAgentId(BigInteger agentId) { this.agentId = agentId; }
|
||||
public BigInteger getChatSessionId() { return chatSessionId; }
|
||||
public void setChatSessionId(BigInteger chatSessionId) { this.chatSessionId = chatSessionId; }
|
||||
public String getRuntimeSessionId() { return runtimeSessionId; }
|
||||
public void setRuntimeSessionId(String runtimeSessionId) { this.runtimeSessionId = runtimeSessionId; }
|
||||
public String getRequestId() { return requestId; }
|
||||
public void setRequestId(String requestId) { this.requestId = requestId; }
|
||||
public String getResumeToken() { return resumeToken; }
|
||||
public void setResumeToken(String resumeToken) { this.resumeToken = resumeToken; }
|
||||
public String getToolCallId() { return toolCallId; }
|
||||
public void setToolCallId(String toolCallId) { this.toolCallId = toolCallId; }
|
||||
public String getToolName() { return toolName; }
|
||||
public void setToolName(String toolName) { this.toolName = toolName; }
|
||||
public Map<String, Object> getToolInputJson() { return toolInputJson; }
|
||||
public void setToolInputJson(Map<String, Object> toolInputJson) { this.toolInputJson = toolInputJson == null ? new LinkedHashMap<>() : toolInputJson; }
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
public String getRejectReason() { return rejectReason; }
|
||||
public void setRejectReason(String rejectReason) { this.rejectReason = rejectReason; }
|
||||
public Date getExpiresAt() { return expiresAt; }
|
||||
public void setExpiresAt(Date expiresAt) { this.expiresAt = expiresAt; }
|
||||
public Date getConsumedAt() { return consumedAt; }
|
||||
public void setConsumedAt(Date consumedAt) { this.consumedAt = consumedAt; }
|
||||
public Map<String, Object> getMetadataJson() { return metadataJson; }
|
||||
public void setMetadataJson(Map<String, Object> metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; }
|
||||
@Override public Date getCreated() { return created; }
|
||||
@Override public void setCreated(Date created) { this.created = created; }
|
||||
public BigInteger getCreatedBy() { return createdBy; }
|
||||
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
|
||||
@Override public Date getModified() { return modified; }
|
||||
@Override public void setModified(Date modified) { this.modified = modified; }
|
||||
public BigInteger getModifiedBy() { return modifiedBy; }
|
||||
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
|
||||
public Integer getIsDeleted() { return isDeleted; }
|
||||
public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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 知识库绑定实体。
|
||||
*/
|
||||
@Table("tb_agent_knowledge_binding")
|
||||
public class AgentKnowledgeBinding 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 knowledgeId;
|
||||
private String retrievalMode;
|
||||
private Boolean enabled;
|
||||
@Column(typeHandler = FastjsonTypeHandler.class)
|
||||
private Map<String, Object> optionsJson = new LinkedHashMap<>();
|
||||
@Column(ignore = true)
|
||||
private Map<String, Object> resourceSnapshot = new LinkedHashMap<>();
|
||||
@Column(ignore = true)
|
||||
private Map<String, Object> resourceSummary = new LinkedHashMap<>();
|
||||
private Integer sortNo;
|
||||
private Date created;
|
||||
private BigInteger createdBy;
|
||||
private Date modified;
|
||||
private BigInteger modifiedBy;
|
||||
|
||||
public BigInteger getId() { return id; }
|
||||
public void setId(BigInteger id) { this.id = id; }
|
||||
public BigInteger getTenantId() { return tenantId; }
|
||||
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
|
||||
public BigInteger getAgentId() { return agentId; }
|
||||
public void setAgentId(BigInteger agentId) { this.agentId = agentId; }
|
||||
public BigInteger getKnowledgeId() { return knowledgeId; }
|
||||
public void setKnowledgeId(BigInteger knowledgeId) { this.knowledgeId = knowledgeId; }
|
||||
public String getRetrievalMode() { return retrievalMode; }
|
||||
public void setRetrievalMode(String retrievalMode) { this.retrievalMode = retrievalMode; }
|
||||
public Boolean getEnabled() { return enabled; }
|
||||
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
|
||||
public Map<String, Object> getOptionsJson() { return optionsJson; }
|
||||
public void setOptionsJson(Map<String, Object> optionsJson) { this.optionsJson = optionsJson == null ? new LinkedHashMap<>() : optionsJson; }
|
||||
public Map<String, Object> getResourceSnapshot() { return resourceSnapshot; }
|
||||
public void setResourceSnapshot(Map<String, Object> resourceSnapshot) { this.resourceSnapshot = resourceSnapshot == null ? new LinkedHashMap<>() : resourceSnapshot; }
|
||||
public Map<String, Object> getResourceSummary() { return resourceSummary; }
|
||||
public void setResourceSummary(Map<String, Object> resourceSummary) { this.resourceSummary = resourceSummary == null ? new LinkedHashMap<>() : resourceSummary; }
|
||||
public Integer getSortNo() { return sortNo; }
|
||||
public void setSortNo(Integer sortNo) { this.sortNo = sortNo; }
|
||||
@Override public Date getCreated() { return created; }
|
||||
@Override public void setCreated(Date created) { this.created = created; }
|
||||
public BigInteger getCreatedBy() { return createdBy; }
|
||||
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
|
||||
@Override public Date getModified() { return modified; }
|
||||
@Override public void setModified(Date modified) { this.modified = modified; }
|
||||
public BigInteger getModifiedBy() { return modifiedBy; }
|
||||
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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 java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Agent 运行事件摘要实体。
|
||||
*/
|
||||
@Table("tb_agent_run_event")
|
||||
public class AgentRunEventRecord 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 chatSessionId;
|
||||
private BigInteger roundId;
|
||||
private Integer roundNo;
|
||||
private Integer variantIndex;
|
||||
private String requestId;
|
||||
private String eventId;
|
||||
private String eventType;
|
||||
private String eventPhase;
|
||||
private String toolCallId;
|
||||
@Column(typeHandler = FastjsonTypeHandler.class)
|
||||
private Map<String, Object> payloadJson = new LinkedHashMap<>();
|
||||
@Column(typeHandler = FastjsonTypeHandler.class)
|
||||
private Map<String, Object> metadataJson = new LinkedHashMap<>();
|
||||
private Date created;
|
||||
private BigInteger createdBy;
|
||||
|
||||
public BigInteger getId() { return id; }
|
||||
public void setId(BigInteger id) { this.id = id; }
|
||||
public BigInteger getTenantId() { return tenantId; }
|
||||
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
|
||||
public BigInteger getAgentId() { return agentId; }
|
||||
public void setAgentId(BigInteger agentId) { this.agentId = agentId; }
|
||||
public BigInteger getChatSessionId() { return chatSessionId; }
|
||||
public void setChatSessionId(BigInteger chatSessionId) { this.chatSessionId = chatSessionId; }
|
||||
public BigInteger getRoundId() { return roundId; }
|
||||
public void setRoundId(BigInteger roundId) { this.roundId = roundId; }
|
||||
public Integer getRoundNo() { return roundNo; }
|
||||
public void setRoundNo(Integer roundNo) { this.roundNo = roundNo; }
|
||||
public Integer getVariantIndex() { return variantIndex; }
|
||||
public void setVariantIndex(Integer variantIndex) { this.variantIndex = variantIndex; }
|
||||
public String getRequestId() { return requestId; }
|
||||
public void setRequestId(String requestId) { this.requestId = requestId; }
|
||||
public String getEventId() { return eventId; }
|
||||
public void setEventId(String eventId) { this.eventId = eventId; }
|
||||
public String getEventType() { return eventType; }
|
||||
public void setEventType(String eventType) { this.eventType = eventType; }
|
||||
public String getEventPhase() { return eventPhase; }
|
||||
public void setEventPhase(String eventPhase) { this.eventPhase = eventPhase; }
|
||||
public String getToolCallId() { return toolCallId; }
|
||||
public void setToolCallId(String toolCallId) { this.toolCallId = toolCallId; }
|
||||
public Map<String, Object> getPayloadJson() { return payloadJson; }
|
||||
public void setPayloadJson(Map<String, Object> payloadJson) { this.payloadJson = payloadJson == null ? new LinkedHashMap<>() : payloadJson; }
|
||||
public Map<String, Object> getMetadataJson() { return metadataJson; }
|
||||
public void setMetadataJson(Map<String, Object> metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; }
|
||||
public Date getCreated() { return created; }
|
||||
public void setCreated(Date created) { this.created = created; }
|
||||
public BigInteger getCreatedBy() { return createdBy; }
|
||||
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* AgentScope 会话状态实体。
|
||||
*/
|
||||
@Table("tb_agent_session")
|
||||
public class AgentSession 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 chatSessionId;
|
||||
private String runtimeSessionId;
|
||||
private String sessionKey;
|
||||
@Column(typeHandler = FastjsonTypeHandler.class)
|
||||
private Map<String, Object> stateJson = new LinkedHashMap<>();
|
||||
private Long version;
|
||||
private Long cacheVersion;
|
||||
private Date lastAccessAt;
|
||||
private Date expiresAt;
|
||||
private Date created;
|
||||
private BigInteger createdBy;
|
||||
private Date modified;
|
||||
private BigInteger modifiedBy;
|
||||
private Integer isDeleted;
|
||||
|
||||
public BigInteger getId() { return id; }
|
||||
public void setId(BigInteger id) { this.id = id; }
|
||||
public BigInteger getTenantId() { return tenantId; }
|
||||
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
|
||||
public BigInteger getAgentId() { return agentId; }
|
||||
public void setAgentId(BigInteger agentId) { this.agentId = agentId; }
|
||||
public BigInteger getChatSessionId() { return chatSessionId; }
|
||||
public void setChatSessionId(BigInteger chatSessionId) { this.chatSessionId = chatSessionId; }
|
||||
public String getRuntimeSessionId() { return runtimeSessionId; }
|
||||
public void setRuntimeSessionId(String runtimeSessionId) { this.runtimeSessionId = runtimeSessionId; }
|
||||
public String getSessionKey() { return sessionKey; }
|
||||
public void setSessionKey(String sessionKey) { this.sessionKey = sessionKey; }
|
||||
public Map<String, Object> getStateJson() { return stateJson; }
|
||||
public void setStateJson(Map<String, Object> stateJson) { this.stateJson = stateJson == null ? new LinkedHashMap<>() : stateJson; }
|
||||
public Long getVersion() { return version; }
|
||||
public void setVersion(Long version) { this.version = version; }
|
||||
public Long getCacheVersion() { return cacheVersion; }
|
||||
public void setCacheVersion(Long cacheVersion) { this.cacheVersion = cacheVersion; }
|
||||
public Date getLastAccessAt() { return lastAccessAt; }
|
||||
public void setLastAccessAt(Date lastAccessAt) { this.lastAccessAt = lastAccessAt; }
|
||||
public Date getExpiresAt() { return expiresAt; }
|
||||
public void setExpiresAt(Date expiresAt) { this.expiresAt = expiresAt; }
|
||||
@Override public Date getCreated() { return created; }
|
||||
@Override public void setCreated(Date created) { this.created = created; }
|
||||
public BigInteger getCreatedBy() { return createdBy; }
|
||||
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
|
||||
@Override public Date getModified() { return modified; }
|
||||
@Override public void setModified(Date modified) { this.modified = modified; }
|
||||
public BigInteger getModifiedBy() { return modifiedBy; }
|
||||
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
|
||||
public Integer getIsDeleted() { return isDeleted; }
|
||||
public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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 工具绑定实体。
|
||||
*/
|
||||
@Table("tb_agent_tool_binding")
|
||||
public class AgentToolBinding 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 String toolType;
|
||||
private BigInteger targetId;
|
||||
private String toolName;
|
||||
private Boolean enabled;
|
||||
private Boolean hitlEnabled;
|
||||
@Column(typeHandler = FastjsonTypeHandler.class)
|
||||
private Map<String, Object> hitlConfigJson = new LinkedHashMap<>();
|
||||
@Column(typeHandler = FastjsonTypeHandler.class)
|
||||
private Map<String, Object> optionsJson = new LinkedHashMap<>();
|
||||
@Column(ignore = true)
|
||||
private Map<String, Object> resourceSnapshot = new LinkedHashMap<>();
|
||||
@Column(ignore = true)
|
||||
private Map<String, Object> resourceSummary = new LinkedHashMap<>();
|
||||
private Integer sortNo;
|
||||
private Date created;
|
||||
private BigInteger createdBy;
|
||||
private Date modified;
|
||||
private BigInteger modifiedBy;
|
||||
|
||||
public BigInteger getId() { return id; }
|
||||
public void setId(BigInteger id) { this.id = id; }
|
||||
public BigInteger getTenantId() { return tenantId; }
|
||||
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
|
||||
public BigInteger getAgentId() { return agentId; }
|
||||
public void setAgentId(BigInteger agentId) { this.agentId = agentId; }
|
||||
public String getToolType() { return toolType; }
|
||||
public void setToolType(String toolType) { this.toolType = toolType; }
|
||||
public BigInteger getTargetId() { return targetId; }
|
||||
public void setTargetId(BigInteger targetId) { this.targetId = targetId; }
|
||||
public String getToolName() { return toolName; }
|
||||
public void setToolName(String toolName) { this.toolName = toolName; }
|
||||
public Boolean getEnabled() { return enabled; }
|
||||
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
|
||||
public Boolean getHitlEnabled() { return hitlEnabled; }
|
||||
public void setHitlEnabled(Boolean hitlEnabled) { this.hitlEnabled = hitlEnabled; }
|
||||
public Map<String, Object> getHitlConfigJson() { return hitlConfigJson; }
|
||||
public void setHitlConfigJson(Map<String, Object> hitlConfigJson) { this.hitlConfigJson = hitlConfigJson == null ? new LinkedHashMap<>() : hitlConfigJson; }
|
||||
public Map<String, Object> getOptionsJson() { return optionsJson; }
|
||||
public void setOptionsJson(Map<String, Object> optionsJson) { this.optionsJson = optionsJson == null ? new LinkedHashMap<>() : optionsJson; }
|
||||
public Map<String, Object> getResourceSnapshot() { return resourceSnapshot; }
|
||||
public void setResourceSnapshot(Map<String, Object> resourceSnapshot) { this.resourceSnapshot = resourceSnapshot == null ? new LinkedHashMap<>() : resourceSnapshot; }
|
||||
public Map<String, Object> getResourceSummary() { return resourceSummary; }
|
||||
public void setResourceSummary(Map<String, Object> resourceSummary) { this.resourceSummary = resourceSummary == null ? new LinkedHashMap<>() : resourceSummary; }
|
||||
public Integer getSortNo() { return sortNo; }
|
||||
public void setSortNo(Integer sortNo) { this.sortNo = sortNo; }
|
||||
@Override public Date getCreated() { return created; }
|
||||
@Override public void setCreated(Date created) { this.created = created; }
|
||||
public BigInteger getCreatedBy() { return createdBy; }
|
||||
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
|
||||
@Override public Date getModified() { return modified; }
|
||||
@Override public void setModified(Date modified) { this.modified = modified; }
|
||||
public BigInteger getModifiedBy() { return modifiedBy; }
|
||||
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package tech.easyflow.agent.enums;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Agent 工具绑定类型。
|
||||
*/
|
||||
public enum AgentToolType {
|
||||
|
||||
WORKFLOW,
|
||||
PLUGIN,
|
||||
MCP;
|
||||
|
||||
/**
|
||||
* 解析工具类型。
|
||||
*
|
||||
* @param value 类型值
|
||||
* @return 工具类型
|
||||
*/
|
||||
public static AgentToolType from(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("toolType 不能为空");
|
||||
}
|
||||
return AgentToolType.valueOf(value.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package tech.easyflow.agent.mapper;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import tech.easyflow.agent.entity.AgentCategory;
|
||||
|
||||
/**
|
||||
* Agent 分类 Mapper。
|
||||
*/
|
||||
public interface AgentCategoryMapper extends BaseMapper<AgentCategory> {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package tech.easyflow.agent.mapper;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import tech.easyflow.agent.entity.AgentHitlPending;
|
||||
|
||||
/**
|
||||
* Agent 工具审批挂起态 Mapper。
|
||||
*/
|
||||
public interface AgentHitlPendingMapper extends BaseMapper<AgentHitlPending> {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package tech.easyflow.agent.mapper;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||
|
||||
/**
|
||||
* Agent 知识库绑定 Mapper。
|
||||
*/
|
||||
public interface AgentKnowledgeBindingMapper extends BaseMapper<AgentKnowledgeBinding> {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package tech.easyflow.agent.mapper;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
|
||||
/**
|
||||
* Agent Mapper。
|
||||
*/
|
||||
public interface AgentMapper extends BaseMapper<Agent> {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package tech.easyflow.agent.mapper;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import tech.easyflow.agent.entity.AgentRunEventRecord;
|
||||
|
||||
/**
|
||||
* Agent 运行事件摘要 Mapper。
|
||||
*/
|
||||
public interface AgentRunEventRecordMapper extends BaseMapper<AgentRunEventRecord> {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package tech.easyflow.agent.mapper;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import tech.easyflow.agent.entity.AgentSession;
|
||||
|
||||
/**
|
||||
* AgentScope 会话状态 Mapper。
|
||||
*/
|
||||
public interface AgentSessionMapper extends BaseMapper<AgentSession> {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package tech.easyflow.agent.mapper;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||
|
||||
/**
|
||||
* Agent 工具绑定 Mapper。
|
||||
*/
|
||||
public interface AgentToolBindingMapper extends BaseMapper<AgentToolBinding> {
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package tech.easyflow.agent.publish;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
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.service.AgentKnowledgeBindingService;
|
||||
import tech.easyflow.agent.service.AgentService;
|
||||
import tech.easyflow.agent.service.AgentToolBindingService;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.publish.AbstractAiResourceLifecycleHandler;
|
||||
import tech.easyflow.approval.enums.ApprovalResourceType;
|
||||
import tech.easyflow.approval.service.ApprovalInstanceService;
|
||||
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 java.math.BigInteger;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Agent 审批资源处理器。
|
||||
*/
|
||||
@Component
|
||||
public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHandler<Agent> {
|
||||
|
||||
private final AgentService agentService;
|
||||
private final AgentToolBindingService agentToolBindingService;
|
||||
private final AgentKnowledgeBindingService agentKnowledgeBindingService;
|
||||
private final ResourceAccessService resourceAccessService;
|
||||
|
||||
/**
|
||||
* 创建 Agent 审批资源处理器。
|
||||
*
|
||||
* @param approvalInstanceService 审批实例服务
|
||||
* @param objectMapper JSON 映射器
|
||||
* @param agentService Agent 服务
|
||||
* @param agentToolBindingService Agent 工具绑定服务
|
||||
* @param agentKnowledgeBindingService Agent 知识库绑定服务
|
||||
* @param resourceAccessService 资源访问服务
|
||||
*/
|
||||
public AgentApprovalSubjectHandler(ApprovalInstanceService approvalInstanceService,
|
||||
ObjectMapper objectMapper,
|
||||
AgentService agentService,
|
||||
AgentToolBindingService agentToolBindingService,
|
||||
AgentKnowledgeBindingService agentKnowledgeBindingService,
|
||||
ResourceAccessService resourceAccessService) {
|
||||
super(approvalInstanceService, objectMapper);
|
||||
this.agentService = agentService;
|
||||
this.agentToolBindingService = agentToolBindingService;
|
||||
this.agentKnowledgeBindingService = agentKnowledgeBindingService;
|
||||
this.resourceAccessService = resourceAccessService;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public String resourceType() {
|
||||
return ApprovalResourceType.AGENT.getCode();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public void assertPublishedAccess(Object identifier, String denyMessage) {
|
||||
Agent agent = agentService.getById(String.valueOf(identifier));
|
||||
if (agent == null || !PublishStatus.from(agent.getPublishStatus()).isExternallyVisible()
|
||||
|| agent.getPublishedSnapshotJson() == null || agent.getPublishedSnapshotJson().isEmpty()) {
|
||||
throw new BusinessException(denyMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Agent requireResource(BigInteger resourceId) {
|
||||
Agent agent = agentService.getById(resourceId);
|
||||
if (agent == null) {
|
||||
throw new BusinessException("Agent 不存在");
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void assertManagePermission(Agent resource) {
|
||||
resourceAccessService.assertAccess(CategoryResourceType.AGENT, resource, ResourceAction.MANAGE, "无权限管理该 Agent");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected BigInteger getCategoryId(Agent resource) {
|
||||
return resource.getCategoryId();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected BigInteger getDeptId(Agent resource) {
|
||||
return resource.getDeptId();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getTitle(Agent resource) {
|
||||
return resource.getName();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected PublishStatus getCurrentStatus(Agent resource) {
|
||||
return PublishStatus.from(resource.getPublishStatus());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Map<String, Object> getPublishedSnapshot(Agent resource) {
|
||||
return resource.getPublishedSnapshotJson();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Map<String, Object> buildResourceSnapshot(Agent resource) {
|
||||
return agentService.buildPublishSnapshot(resource);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void persistResourceState(BigInteger resourceId, PublishStatus publishStatus, BigInteger currentApprovalInstanceId) {
|
||||
Agent agent = new Agent();
|
||||
agent.setId(resourceId);
|
||||
agent.setPublishStatus(publishStatus.getCode());
|
||||
agent.setCurrentApprovalInstanceId(currentApprovalInstanceId);
|
||||
agentService.updateById(agent);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void publishResource(BigInteger resourceId, Map<String, Object> resourceSnapshot, BigInteger operatorId) {
|
||||
Agent agent = new Agent();
|
||||
agent.setId(resourceId);
|
||||
agent.setPublishStatus(PublishStatus.PUBLISHED.getCode());
|
||||
agent.setPublishedSnapshotJson(resourceSnapshot);
|
||||
agent.setPublishedAt(new Date());
|
||||
agent.setPublishedBy(operatorId);
|
||||
agent.setCurrentApprovalInstanceId(null);
|
||||
agentService.updateById(agent);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void markResourceOffline(BigInteger resourceId) {
|
||||
Agent agent = new Agent();
|
||||
agent.setId(resourceId);
|
||||
agent.setPublishStatus(PublishStatus.OFFLINE.getCode());
|
||||
agent.setCurrentApprovalInstanceId(null);
|
||||
agentService.updateById(agent);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void removeResource(BigInteger resourceId) {
|
||||
agentService.removeById(resourceId);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void beforeRemove(BigInteger resourceId) {
|
||||
agentToolBindingService.remove(QueryWrapper.create().eq(AgentToolBinding::getAgentId, resourceId));
|
||||
agentKnowledgeBindingService.remove(QueryWrapper.create().eq(AgentKnowledgeBinding::getAgentId, resourceId));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String resourceLabel() {
|
||||
return "Agent";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package tech.easyflow.agent.publish;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.ai.publish.AiResourceLifecycleService;
|
||||
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
|
||||
import tech.easyflow.approval.enums.ApprovalActionType;
|
||||
import tech.easyflow.approval.enums.ApprovalResourceType;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* Agent 发布生命周期应用服务。
|
||||
*/
|
||||
@Service
|
||||
public class AgentPublishAppService {
|
||||
|
||||
private final AiResourceLifecycleService aiResourceLifecycleService;
|
||||
|
||||
/**
|
||||
* 创建 Agent 发布应用服务。
|
||||
*
|
||||
* @param aiResourceLifecycleService AI 资源生命周期服务
|
||||
*/
|
||||
public AgentPublishAppService(AiResourceLifecycleService aiResourceLifecycleService) {
|
||||
this.aiResourceLifecycleService = aiResourceLifecycleService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交 Agent 发布审批。
|
||||
*
|
||||
* @param id Agent ID
|
||||
* @return 审批动作结果
|
||||
*/
|
||||
public ApprovalActionResult submitPublishApproval(BigInteger id) {
|
||||
return submit(id, ApprovalActionType.PUBLISH);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交 Agent 下线审批。
|
||||
*
|
||||
* @param id Agent ID
|
||||
* @return 审批动作结果
|
||||
*/
|
||||
public ApprovalActionResult submitOfflineApproval(BigInteger id) {
|
||||
return submit(id, ApprovalActionType.OFFLINE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交 Agent 删除审批。
|
||||
*
|
||||
* @param id Agent ID
|
||||
* @return 审批动作结果
|
||||
*/
|
||||
public ApprovalActionResult submitDeleteApproval(BigInteger id) {
|
||||
return submit(id, ApprovalActionType.DELETE);
|
||||
}
|
||||
|
||||
private ApprovalActionResult submit(BigInteger id, ApprovalActionType actionType) {
|
||||
if (id == null) {
|
||||
throw new BusinessException("Agent 审批时资源ID不能为空");
|
||||
}
|
||||
return aiResourceLifecycleService.submitAction(
|
||||
ApprovalResourceType.AGENT.getCode(),
|
||||
id,
|
||||
actionType.getCode(),
|
||||
SaTokenUtil.getLoginAccount().getId()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package tech.easyflow.agent.runtime;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Agent 聊天临时能力请求项。
|
||||
*/
|
||||
public class AgentChatCapability implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String type;
|
||||
private List<BigInteger> resourceIds = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 获取能力类型。
|
||||
*
|
||||
* @return 能力类型
|
||||
*/
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置能力类型。
|
||||
*
|
||||
* @param type 能力类型
|
||||
*/
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源 ID 列表。
|
||||
*
|
||||
* @return 资源 ID 列表
|
||||
*/
|
||||
public List<BigInteger> getResourceIds() {
|
||||
return resourceIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置资源 ID 列表。
|
||||
*
|
||||
* @param resourceIds 资源 ID 列表
|
||||
*/
|
||||
public void setResourceIds(List<BigInteger> resourceIds) {
|
||||
this.resourceIds = resourceIds == null ? new ArrayList<>() : new ArrayList<>(resourceIds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package tech.easyflow.agent.runtime;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
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 java.math.BigInteger;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Agent 聊天临时能力编排服务。
|
||||
*/
|
||||
@Service
|
||||
public class AgentChatCapabilityService {
|
||||
|
||||
private static final String KNOWLEDGE_CAPABILITY_TYPE = "KNOWLEDGE";
|
||||
private static final String DEFAULT_RETRIEVAL_MODE = "HYBRID";
|
||||
private static final int MAX_EXTRA_KNOWLEDGE_COUNT = 3;
|
||||
|
||||
private final DocumentCollectionService documentCollectionService;
|
||||
private final ResourceAccessService resourceAccessService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 创建 Agent 聊天临时能力编排服务。
|
||||
*
|
||||
* @param documentCollectionService 知识库服务
|
||||
* @param resourceAccessService 资源访问服务
|
||||
* @param objectMapper 对象复制工具
|
||||
*/
|
||||
public AgentChatCapabilityService(DocumentCollectionService documentCollectionService,
|
||||
ResourceAccessService resourceAccessService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.documentCollectionService = documentCollectionService;
|
||||
this.resourceAccessService = resourceAccessService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将临时聊天能力合并到运行时 Agent 定义中。
|
||||
*
|
||||
* @param agent 已发布 Agent 运行视图
|
||||
* @param capabilities 临时能力请求
|
||||
* @param account 当前登录账号
|
||||
* @return 能力解析结果
|
||||
*/
|
||||
public AgentChatCapabilityResolution apply(Agent agent,
|
||||
List<AgentChatCapability> capabilities,
|
||||
LoginAccount account) {
|
||||
List<BigInteger> extraKnowledgeIds = resolveKnowledgeIds(capabilities);
|
||||
boolean knowledgeCapabilityProvided = hasKnowledgeCapability(capabilities);
|
||||
if (agent == null || extraKnowledgeIds.isEmpty()) {
|
||||
return new AgentChatCapabilityResolution(agent, extraKnowledgeIds, knowledgeCapabilityProvided);
|
||||
}
|
||||
Agent runtimeAgent = objectMapper.convertValue(agent, Agent.class);
|
||||
List<AgentKnowledgeBinding> mergedBindings = new ArrayList<>();
|
||||
Set<BigInteger> existingKnowledgeIds = new LinkedHashSet<>();
|
||||
if (runtimeAgent.getKnowledgeBindings() != null) {
|
||||
for (AgentKnowledgeBinding binding : runtimeAgent.getKnowledgeBindings()) {
|
||||
if (binding == null) {
|
||||
continue;
|
||||
}
|
||||
mergedBindings.add(binding);
|
||||
if (Boolean.TRUE.equals(binding.getEnabled()) && binding.getKnowledgeId() != null) {
|
||||
existingKnowledgeIds.add(binding.getKnowledgeId());
|
||||
}
|
||||
}
|
||||
}
|
||||
int sortNo = mergedBindings.size();
|
||||
for (BigInteger knowledgeId : extraKnowledgeIds) {
|
||||
if (existingKnowledgeIds.contains(knowledgeId)) {
|
||||
continue;
|
||||
}
|
||||
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
|
||||
validateKnowledge(knowledge);
|
||||
resourceAccessService.assertAccess(
|
||||
CategoryResourceType.KNOWLEDGE,
|
||||
knowledge,
|
||||
ResourceAction.USE,
|
||||
"无权限使用所选知识库"
|
||||
);
|
||||
AgentKnowledgeBinding binding = new AgentKnowledgeBinding();
|
||||
binding.setTenantId(account == null ? runtimeAgent.getTenantId() : account.getTenantId());
|
||||
binding.setAgentId(runtimeAgent.getId());
|
||||
binding.setKnowledgeId(knowledgeId);
|
||||
binding.setRetrievalMode(DEFAULT_RETRIEVAL_MODE);
|
||||
binding.setEnabled(true);
|
||||
binding.setSortNo(sortNo++);
|
||||
mergedBindings.add(binding);
|
||||
existingKnowledgeIds.add(knowledgeId);
|
||||
}
|
||||
runtimeAgent.setKnowledgeBindings(mergedBindings);
|
||||
return new AgentChatCapabilityResolution(runtimeAgent, extraKnowledgeIds, knowledgeCapabilityProvided);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从能力列表提取知识库 ID。
|
||||
*
|
||||
* @param capabilities 临时能力列表
|
||||
* @return 已去重知识库 ID
|
||||
*/
|
||||
public List<BigInteger> resolveKnowledgeIds(List<AgentChatCapability> capabilities) {
|
||||
if (capabilities == null || capabilities.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
LinkedHashSet<BigInteger> ids = new LinkedHashSet<>();
|
||||
for (AgentChatCapability capability : capabilities) {
|
||||
if (capability == null || !isKnowledgeCapability(capability.getType())) {
|
||||
continue;
|
||||
}
|
||||
if (capability.getResourceIds() == null) {
|
||||
continue;
|
||||
}
|
||||
for (BigInteger resourceId : capability.getResourceIds()) {
|
||||
if (resourceId != null) {
|
||||
ids.add(resourceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ids.size() > MAX_EXTRA_KNOWLEDGE_COUNT) {
|
||||
throw new BusinessException("临时知识库最多选择 3 个");
|
||||
}
|
||||
return new ArrayList<>(ids);
|
||||
}
|
||||
|
||||
private boolean isKnowledgeCapability(String type) {
|
||||
return Objects.equals(KNOWLEDGE_CAPABILITY_TYPE, type == null ? null : type.trim().toUpperCase());
|
||||
}
|
||||
|
||||
private boolean hasKnowledgeCapability(List<AgentChatCapability> capabilities) {
|
||||
if (capabilities == null || capabilities.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (AgentChatCapability capability : capabilities) {
|
||||
if (capability != null && isKnowledgeCapability(capability.getType())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void validateKnowledge(DocumentCollection knowledge) {
|
||||
if (knowledge == null) {
|
||||
throw new BusinessException("所选知识库不存在");
|
||||
}
|
||||
if (PublishStatus.from(knowledge.getPublishStatus()) != PublishStatus.PUBLISHED) {
|
||||
throw new BusinessException("所选知识库未发布,无法用于聊天");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 聊天临时能力解析结果。
|
||||
*
|
||||
* @param agent 合并临时能力后的运行时 Agent
|
||||
* @param extraKnowledgeIds 本次选择的临时知识库 ID
|
||||
* @param knowledgeCapabilityProvided 请求是否显式传入知识库能力
|
||||
*/
|
||||
public record AgentChatCapabilityResolution(Agent agent,
|
||||
List<BigInteger> extraKnowledgeIds,
|
||||
boolean knowledgeCapabilityProvided) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package tech.easyflow.agent.runtime;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Agent 管理端运行请求。
|
||||
*/
|
||||
public class AgentChatRequest {
|
||||
|
||||
private BigInteger agentId;
|
||||
private BigInteger sessionId;
|
||||
private String prompt;
|
||||
private List<AgentChatCapability> capabilities = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 获取 Agent ID。
|
||||
*
|
||||
* @return Agent ID
|
||||
*/
|
||||
public BigInteger getAgentId() { return agentId; }
|
||||
|
||||
/**
|
||||
* 设置 Agent ID。
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
*/
|
||||
public void setAgentId(BigInteger agentId) { this.agentId = agentId; }
|
||||
|
||||
/**
|
||||
* 获取会话 ID。
|
||||
*
|
||||
* @return 会话 ID
|
||||
*/
|
||||
public BigInteger getSessionId() { return sessionId; }
|
||||
|
||||
/**
|
||||
* 设置会话 ID。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
*/
|
||||
public void setSessionId(BigInteger sessionId) { this.sessionId = sessionId; }
|
||||
|
||||
/**
|
||||
* 获取用户纯文本输入。
|
||||
*
|
||||
* @return 用户输入
|
||||
*/
|
||||
public String getPrompt() { return prompt; }
|
||||
|
||||
/**
|
||||
* 设置用户纯文本输入。
|
||||
*
|
||||
* @param prompt 用户输入
|
||||
*/
|
||||
public void setPrompt(String prompt) { this.prompt = prompt; }
|
||||
|
||||
/**
|
||||
* 获取本次聊天启用的临时能力。
|
||||
*
|
||||
* @return 临时能力列表
|
||||
*/
|
||||
public List<AgentChatCapability> getCapabilities() {
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置本次聊天启用的临时能力。
|
||||
*
|
||||
* @param capabilities 临时能力列表
|
||||
*/
|
||||
public void setCapabilities(List<AgentChatCapability> capabilities) {
|
||||
this.capabilities = capabilities == null ? new ArrayList<>() : new ArrayList<>(capabilities);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package tech.easyflow.agent.runtime;
|
||||
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Agent 草稿态纯文本试用请求。
|
||||
*/
|
||||
public class AgentDraftChatRequest {
|
||||
|
||||
private Agent agent;
|
||||
private List<AgentToolBinding> toolBindings;
|
||||
private List<AgentKnowledgeBinding> knowledgeBindings;
|
||||
private String sessionId;
|
||||
private String prompt;
|
||||
|
||||
/**
|
||||
* 获取 Agent 草稿快照。
|
||||
*
|
||||
* @return Agent 草稿快照
|
||||
*/
|
||||
public Agent getAgent() {
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Agent 草稿快照。
|
||||
*
|
||||
* @param agent Agent 草稿快照
|
||||
*/
|
||||
public void setAgent(Agent agent) {
|
||||
this.agent = agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具绑定快照。
|
||||
*
|
||||
* @return 工具绑定快照
|
||||
*/
|
||||
public List<AgentToolBinding> getToolBindings() {
|
||||
return toolBindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置工具绑定快照。
|
||||
*
|
||||
* @param toolBindings 工具绑定快照
|
||||
*/
|
||||
public void setToolBindings(List<AgentToolBinding> toolBindings) {
|
||||
this.toolBindings = toolBindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取知识库绑定快照。
|
||||
*
|
||||
* @return 知识库绑定快照
|
||||
*/
|
||||
public List<AgentKnowledgeBinding> getKnowledgeBindings() {
|
||||
return knowledgeBindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置知识库绑定快照。
|
||||
*
|
||||
* @param knowledgeBindings 知识库绑定快照
|
||||
*/
|
||||
public void setKnowledgeBindings(List<AgentKnowledgeBinding> knowledgeBindings) {
|
||||
this.knowledgeBindings = knowledgeBindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取草稿试运行会话 ID。
|
||||
*
|
||||
* @return 会话 ID
|
||||
*/
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置草稿试运行会话 ID。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
*/
|
||||
public void setSessionId(String sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户纯文本输入。
|
||||
*
|
||||
* @return 用户输入
|
||||
*/
|
||||
public String getPrompt() {
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户纯文本输入。
|
||||
*
|
||||
* @param prompt 用户输入
|
||||
*/
|
||||
public void setPrompt(String prompt) {
|
||||
this.prompt = prompt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
package tech.easyflow.agent.runtime;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentResumeRequest;
|
||||
import com.easyagents.agent.runtime.AgentRuntime;
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||
import com.easyagents.agent.runtime.hitl.AgentResumeToken;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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.runtime.lock.AgentRunLock;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
|
||||
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Agent 运行态注册表。
|
||||
*/
|
||||
@Component
|
||||
public class AgentRunRegistry {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AgentRunRegistry.class);
|
||||
|
||||
private final Map<String, AgentRunContext> runs = new ConcurrentHashMap<>();
|
||||
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, RunOwner> owners = new ConcurrentHashMap<>();
|
||||
private AgentRuntimeRouteRegistry routeRegistry;
|
||||
|
||||
/**
|
||||
* 设置 Agent 运行态 Redis 路由注册表。
|
||||
*
|
||||
* @param routeRegistry Redis 路由注册表
|
||||
*/
|
||||
@Autowired(required = false)
|
||||
public void setRouteRegistry(AgentRuntimeRouteRegistry routeRegistry) {
|
||||
this.routeRegistry = routeRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册运行态。
|
||||
*
|
||||
* @param context 运行态上下文
|
||||
*/
|
||||
public void register(AgentRunContext context) {
|
||||
if (context == null || context.requestId() == null || context.requestId().isBlank()) {
|
||||
throw new BusinessException("Agent 运行请求 ID 不能为空");
|
||||
}
|
||||
if (context.sessionId() == null || context.sessionId().isBlank()) {
|
||||
throw new BusinessException("Agent 会话 ID 不能为空");
|
||||
}
|
||||
String existingRequestId = sessionRuns.putIfAbsent(context.sessionId(), context.requestId());
|
||||
if (existingRequestId != null && !existingRequestId.equals(context.requestId())) {
|
||||
throw new BusinessException("当前 Agent 会话已有运行中的请求,请稍后再试");
|
||||
}
|
||||
AgentRunContext existing = runs.putIfAbsent(context.requestId(), context);
|
||||
if (existing != null) {
|
||||
sessionRuns.remove(context.sessionId(), context.requestId());
|
||||
throw new BusinessException("当前 Agent 运行请求已存在");
|
||||
}
|
||||
owners.put(context.requestId(), context.owner());
|
||||
if (routeRegistry != null) {
|
||||
routeRegistry.registerRun(context.requestId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定运行订阅。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
* @param subscription Reactor 订阅
|
||||
*/
|
||||
public void bindSubscription(String requestId, Disposable subscription) {
|
||||
AgentRunContext context = runs.get(requestId);
|
||||
if (context != null) {
|
||||
context.setSubscription(subscription);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取运行态。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
* @return 运行态
|
||||
*/
|
||||
public AgentRunContext get(String requestId) {
|
||||
return requestId == null ? null : runs.get(requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消并移除指定会话当前活跃运行。
|
||||
*
|
||||
* <p>草稿试运行清理会删除 AgentScope session。若同一会话仍有 SSE 运行中,
|
||||
* 必须先取消 runtime 订阅并关闭 SSE,避免旧运行继续向已清空的会话写入状态。</p>
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
*/
|
||||
public void cancelSession(String sessionId) {
|
||||
cancelSession(sessionId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消并移除指定会话当前活跃运行。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param userId 当前用户 ID,非空时会校验运行归属
|
||||
*/
|
||||
public void cancelSession(String sessionId, String userId) {
|
||||
if (sessionId == null || sessionId.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String requestId = sessionRuns.get(sessionId);
|
||||
if (requestId == null) {
|
||||
return;
|
||||
}
|
||||
assertOwner(requestId, userId);
|
||||
AgentRunContext context = runs.get(requestId);
|
||||
if (context != null) {
|
||||
context.cancelAndComplete();
|
||||
}
|
||||
remove(requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录等待审批的恢复令牌。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
* @param resumeToken 恢复令牌
|
||||
*/
|
||||
public void registerResumeToken(String requestId, String resumeToken) {
|
||||
if (requestId != null && resumeToken != null && !resumeToken.isBlank()) {
|
||||
resumeTokenIndex.put(resumeToken, requestId);
|
||||
requestTokens.computeIfAbsent(requestId, ignored -> ConcurrentHashMap.newKeySet()).add(resumeToken);
|
||||
if (routeRegistry != null) {
|
||||
routeRegistry.registerResumeToken(requestId, resumeToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行结束后移除运行态。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
*/
|
||||
public synchronized void remove(String requestId) {
|
||||
if (requestId == null) {
|
||||
return;
|
||||
}
|
||||
AgentRunContext context = runs.remove(requestId);
|
||||
if (context != null) {
|
||||
sessionRuns.remove(context.sessionId(), requestId);
|
||||
context.releaseLock();
|
||||
context.closeRuntime();
|
||||
}
|
||||
owners.remove(requestId);
|
||||
Set<String> tokens = requestTokens.remove(requestId);
|
||||
if (tokens != null) {
|
||||
tokens.forEach(token -> {
|
||||
resumeTokenIndex.remove(token);
|
||||
if (routeRegistry != null) {
|
||||
routeRegistry.removeResumeToken(token);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (routeRegistry != null) {
|
||||
routeRegistry.removeRun(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批准工具执行。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
* @param resumeToken 恢复令牌
|
||||
* @param userId 当前用户 ID
|
||||
*/
|
||||
public void approve(String requestId, String resumeToken, String userId) {
|
||||
submit(requestId, resumeToken, userId, true, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批准工具执行,并在恢复 runtime 前执行持久化消费动作。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
* @param resumeToken 恢复令牌
|
||||
* @param userId 当前用户 ID
|
||||
* @param beforeResume runtime resume 前的持久化消费动作
|
||||
*/
|
||||
public void approve(String requestId, String resumeToken, String userId, Runnable beforeResume) {
|
||||
submit(requestId, resumeToken, userId, true, null, beforeResume);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝工具执行。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
* @param resumeToken 恢复令牌
|
||||
* @param userId 当前用户 ID
|
||||
* @param reason 拒绝原因
|
||||
*/
|
||||
public void reject(String requestId, String resumeToken, String userId, String reason) {
|
||||
submit(requestId, resumeToken, userId, false, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝工具执行,并在恢复 runtime 前执行持久化消费动作。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
* @param resumeToken 恢复令牌
|
||||
* @param userId 当前用户 ID
|
||||
* @param reason 拒绝原因
|
||||
* @param beforeResume runtime resume 前的持久化消费动作
|
||||
*/
|
||||
public void reject(String requestId, String resumeToken, String userId, String reason, Runnable beforeResume) {
|
||||
submit(requestId, resumeToken, userId, false, reason, beforeResume);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前进程是否存在指定 token 对应的活跃运行态。
|
||||
*
|
||||
* @param requestId 请求 ID,可为空
|
||||
* @param resumeToken 恢复令牌
|
||||
* @return true 表示当前节点可直接恢复
|
||||
*/
|
||||
public boolean containsResumeTarget(String requestId, String resumeToken) {
|
||||
try {
|
||||
String resolvedRequestId = resolveRequestId(requestId, resumeToken);
|
||||
return runs.containsKey(resolvedRequestId);
|
||||
} catch (BusinessException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前恢复目标是否为草稿试运行。
|
||||
*
|
||||
* @param requestId 请求 ID,可为空
|
||||
* @param resumeToken 恢复令牌
|
||||
* @return true 表示目标为草稿试运行
|
||||
*/
|
||||
public boolean isDraftResumeTarget(String requestId, String resumeToken) {
|
||||
try {
|
||||
String resolvedRequestId = resolveRequestId(requestId, resumeToken);
|
||||
AgentRunContext context = runs.get(resolvedRequestId);
|
||||
return context != null && !context.persistChatlog();
|
||||
} catch (BusinessException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void submit(String requestId, String resumeToken, String userId, boolean approved, String reason) {
|
||||
submit(requestId, resumeToken, userId, approved, reason, null);
|
||||
}
|
||||
|
||||
private synchronized void submit(String requestId,
|
||||
String resumeToken,
|
||||
String userId,
|
||||
boolean approved,
|
||||
String reason,
|
||||
Runnable beforeResume) {
|
||||
String resolvedRequestId = resolveRequestId(requestId, resumeToken);
|
||||
AgentRunContext context = runs.get(resolvedRequestId);
|
||||
if (context == null) {
|
||||
throw new BusinessException("当前 Agent 运行已结束或不在本进程内");
|
||||
}
|
||||
assertOwner(resolvedRequestId, userId);
|
||||
assertResumeTokenBelongsToRequest(resolvedRequestId, resumeToken);
|
||||
if (beforeResume != null) {
|
||||
beforeResume.run();
|
||||
}
|
||||
Set<String> tokens = requestTokens.get(resolvedRequestId);
|
||||
if (tokens != null) {
|
||||
tokens.remove(resumeToken);
|
||||
}
|
||||
resumeTokenIndex.remove(resumeToken);
|
||||
if (routeRegistry != null) {
|
||||
routeRegistry.removeResumeToken(resumeToken);
|
||||
}
|
||||
AgentResumeToken token = new AgentResumeToken();
|
||||
token.setValue(resumeToken);
|
||||
AgentResumeRequest request = new AgentResumeRequest();
|
||||
request.setResumeToken(token);
|
||||
request.setApproved(approved);
|
||||
request.setRejectReason(reason);
|
||||
request.getMetadata().put("requestId", resolvedRequestId);
|
||||
request.getMetadata().put("operatorId", userId);
|
||||
context.resume(request);
|
||||
}
|
||||
|
||||
private String resolveRequestId(String requestId, String resumeToken) {
|
||||
if (requestId != null && !requestId.isBlank()) {
|
||||
return requestId;
|
||||
}
|
||||
String resolved = resumeTokenIndex.get(resumeToken);
|
||||
if (resolved == null || resolved.isBlank()) {
|
||||
throw new BusinessException("当前 Agent 运行已结束或不在本进程内");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private void assertResumeTokenBelongsToRequest(String requestId, String resumeToken) {
|
||||
if (resumeToken == null || resumeToken.isBlank()) {
|
||||
throw new BusinessException("Agent 恢复令牌不能为空");
|
||||
}
|
||||
Set<String> tokens = requestTokens.get(requestId);
|
||||
if (tokens == null || !tokens.contains(resumeToken)) {
|
||||
throw new BusinessException("Agent 审批请求已失效");
|
||||
}
|
||||
}
|
||||
|
||||
private void assertOwner(String requestId, String userId) {
|
||||
if (userId == null || userId.isBlank()) {
|
||||
return;
|
||||
}
|
||||
RunOwner owner = owners.get(requestId);
|
||||
if (owner == null) {
|
||||
throw new BusinessException("当前 Agent 运行已结束或不在本进程内");
|
||||
}
|
||||
if (owner.userId() != null && !owner.userId().equals(userId)) {
|
||||
throw new BusinessException("无权处理该 Agent 运行审批");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前运行归属信息。
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
* @param sessionId 会话 ID
|
||||
* @param userId 用户 ID
|
||||
*/
|
||||
public record RunOwner(String agentId, String sessionId, String userId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 单机内存运行态。
|
||||
*
|
||||
*/
|
||||
public static final class AgentRunContext {
|
||||
|
||||
private final String requestId;
|
||||
private final String sessionId;
|
||||
private final AgentRuntime runtime;
|
||||
private final ChatSseEmitter chatSseEmitter;
|
||||
private final ChatRuntimeContext chatContext;
|
||||
private final StringBuilder answer;
|
||||
private final ChatAssistantAccumulator assistantAccumulator;
|
||||
private final AtomicBoolean finished;
|
||||
private final boolean persistChatlog;
|
||||
private final RunOwner owner;
|
||||
private final AgentRunLock.Handle lockHandle;
|
||||
private final Consumer<AgentRuntimeEvent> eventConsumer;
|
||||
private final Consumer<Throwable> errorConsumer;
|
||||
private final Runnable completionHandler;
|
||||
private final AtomicBoolean suspended = new AtomicBoolean(false);
|
||||
private final AtomicReference<Disposable> subscription = new AtomicReference<>();
|
||||
|
||||
/**
|
||||
* 创建运行态。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
* @param sessionId 会话 ID
|
||||
* @param runtime 有状态运行时
|
||||
* @param chatSseEmitter SSE 连接
|
||||
* @param chatContext 聊天上下文
|
||||
* @param answer 助手正文累计缓冲
|
||||
* @param assistantAccumulator 助手结构化累计器
|
||||
* @param finished 运行收口标记
|
||||
* @param persistChatlog 是否持久化聊天日志
|
||||
* @param owner 运行归属
|
||||
* @param eventConsumer 运行事件处理器
|
||||
* @param errorConsumer 错误处理器
|
||||
* @param completionHandler 完成处理器
|
||||
*/
|
||||
public AgentRunContext(String requestId,
|
||||
String sessionId,
|
||||
AgentRuntime runtime,
|
||||
ChatSseEmitter chatSseEmitter,
|
||||
ChatRuntimeContext chatContext,
|
||||
StringBuilder answer,
|
||||
ChatAssistantAccumulator assistantAccumulator,
|
||||
AtomicBoolean finished,
|
||||
boolean persistChatlog,
|
||||
RunOwner owner,
|
||||
AgentRunLock.Handle lockHandle,
|
||||
Consumer<AgentRuntimeEvent> eventConsumer,
|
||||
Consumer<Throwable> errorConsumer,
|
||||
Runnable completionHandler) {
|
||||
this.requestId = requestId;
|
||||
this.sessionId = sessionId;
|
||||
this.runtime = runtime;
|
||||
this.chatSseEmitter = chatSseEmitter;
|
||||
this.chatContext = chatContext;
|
||||
this.answer = answer;
|
||||
this.assistantAccumulator = assistantAccumulator;
|
||||
this.finished = finished;
|
||||
this.persistChatlog = persistChatlog;
|
||||
this.owner = owner;
|
||||
this.lockHandle = lockHandle;
|
||||
this.eventConsumer = eventConsumer;
|
||||
this.errorConsumer = errorConsumer;
|
||||
this.completionHandler = completionHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取请求 ID。
|
||||
*
|
||||
* @return 请求 ID
|
||||
*/
|
||||
public String requestId() {
|
||||
return requestId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话 ID。
|
||||
*
|
||||
* @return 会话 ID
|
||||
*/
|
||||
public String sessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取运行归属。
|
||||
*
|
||||
* @return 运行归属
|
||||
*/
|
||||
public RunOwner owner() {
|
||||
return owner;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取运行事件处理器。
|
||||
*
|
||||
* @return 运行事件处理器
|
||||
*/
|
||||
public Consumer<AgentRuntimeEvent> eventConsumer() {
|
||||
return eventConsumer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误处理器。
|
||||
*
|
||||
* @return 错误处理器
|
||||
*/
|
||||
public Consumer<Throwable> errorConsumer() {
|
||||
return errorConsumer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完成处理器。
|
||||
*
|
||||
* @return 完成处理器
|
||||
*/
|
||||
public Runnable completionHandler() {
|
||||
return completionHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记当前运行已进入 HITL 挂起态。
|
||||
*/
|
||||
public void markSuspended() {
|
||||
suspended.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前运行是否处于 HITL 挂起态。
|
||||
*
|
||||
* @return true 表示等待审批恢复
|
||||
*/
|
||||
public boolean isSuspended() {
|
||||
return suspended.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前运行是否持久化聊天日志与运行态。
|
||||
*
|
||||
* @return true 表示正式聊天持久化运行
|
||||
*/
|
||||
public boolean persistChatlog() {
|
||||
return persistChatlog;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定运行订阅。
|
||||
*
|
||||
* @param subscription Reactor 订阅
|
||||
*/
|
||||
public void setSubscription(Disposable subscription) {
|
||||
if (subscription == null) {
|
||||
return;
|
||||
}
|
||||
Disposable previous = this.subscription.getAndSet(subscription);
|
||||
if (previous != null && !previous.isDisposed()) {
|
||||
previous.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消当前运行订阅。
|
||||
*/
|
||||
public void cancel() {
|
||||
Disposable subscription = this.subscription.getAndSet(null);
|
||||
if (subscription != null && !subscription.isDisposed()) {
|
||||
subscription.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消当前运行并关闭 SSE。
|
||||
*
|
||||
* <p>该方法用于调用方主动清理会话的场景。它不通过 runtime 事件链发送取消事件,
|
||||
* 因为调用方此时已经明确要求丢弃当前草稿会话。</p>
|
||||
*/
|
||||
public void cancelAndComplete() {
|
||||
cancel();
|
||||
if (finished.compareAndSet(false, true) && chatSseEmitter != null) {
|
||||
chatSseEmitter.complete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放当前运行持有的分布式锁。
|
||||
*/
|
||||
public void releaseLock() {
|
||||
if (lockHandle != null) {
|
||||
lockHandle.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭底层运行时并释放资源。
|
||||
*/
|
||||
public void closeRuntime() {
|
||||
try {
|
||||
runtime.close();
|
||||
} catch (Exception e) {
|
||||
LOG.warn("Close Agent runtime failed, requestId={}, sessionId={}, message={}",
|
||||
requestId, sessionId, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过同一个 runtime 恢复挂起运行,事件继续写入原 SSE。
|
||||
*
|
||||
* @param request 恢复请求
|
||||
*/
|
||||
public void resume(AgentResumeRequest request) {
|
||||
suspended.set(false);
|
||||
Disposable subscription = runtime.resume(request).subscribe(eventConsumer, errorConsumer, completionHandler);
|
||||
setSubscription(subscription);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
package tech.easyflow.agent.runtime;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentDefinition;
|
||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Agent 运行时编译结果。
|
||||
*/
|
||||
public class AgentRuntimeBundle {
|
||||
|
||||
private AgentDefinition definition;
|
||||
private Map<String, AgentToolInvoker> toolInvokers = new LinkedHashMap<>();
|
||||
private Map<String, AgentKnowledgeRetriever> knowledgeRetrievers = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 获取 Agent 定义。
|
||||
*
|
||||
* @return Agent 定义
|
||||
*/
|
||||
public AgentDefinition getDefinition() {
|
||||
return definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Agent 定义。
|
||||
*
|
||||
* @param definition Agent 定义
|
||||
*/
|
||||
public void setDefinition(AgentDefinition definition) {
|
||||
this.definition = definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具调用器。
|
||||
*
|
||||
* @return 工具调用器
|
||||
*/
|
||||
public Map<String, AgentToolInvoker> getToolInvokers() {
|
||||
return toolInvokers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置工具调用器。
|
||||
*
|
||||
* @param toolInvokers 工具调用器
|
||||
*/
|
||||
public void setToolInvokers(Map<String, AgentToolInvoker> toolInvokers) {
|
||||
this.toolInvokers = toolInvokers == null ? new LinkedHashMap<>() : toolInvokers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取知识库检索器。
|
||||
*
|
||||
* @return 知识库检索器
|
||||
*/
|
||||
public Map<String, AgentKnowledgeRetriever> getKnowledgeRetrievers() {
|
||||
return knowledgeRetrievers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置知识库检索器。
|
||||
*
|
||||
* @param knowledgeRetrievers 知识库检索器
|
||||
*/
|
||||
public void setKnowledgeRetrievers(Map<String, AgentKnowledgeRetriever> knowledgeRetrievers) {
|
||||
this.knowledgeRetrievers = knowledgeRetrievers == null ? new LinkedHashMap<>() : knowledgeRetrievers;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,860 @@
|
||||
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.knowledge.AgentKnowledgeDocument;
|
||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgePolicy;
|
||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
|
||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
|
||||
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.model.AgentGenerationOptions;
|
||||
import com.easyagents.agent.runtime.model.AgentModelProviderType;
|
||||
import com.easyagents.agent.runtime.model.AgentModelSpec;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolCategory;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolResult;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolSpec;
|
||||
import com.easyagents.core.document.Document;
|
||||
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.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.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.ai.entity.*;
|
||||
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
|
||||
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
|
||||
import tech.easyflow.ai.service.*;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 将 Agent 发布快照编译为可执行定义。
|
||||
*/
|
||||
@Component
|
||||
public class AgentRuntimeCompiler {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AgentRuntimeCompiler.class);
|
||||
private static final int LOG_TEXT_MAX_LENGTH = 500;
|
||||
private static final Pattern MCP_INPUT_PATTERN = Pattern.compile("\\$\\{input:([A-Za-z0-9_.-]+)}");
|
||||
|
||||
@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;
|
||||
|
||||
/**
|
||||
* 编译 Agent 运行时定义和调用器。
|
||||
*
|
||||
* @param agent 已发布 Agent 视图
|
||||
* @return 运行时编译结果
|
||||
*/
|
||||
public AgentRuntimeBundle compile(Agent agent) {
|
||||
if (agent == null || agent.getId() == null) {
|
||||
throw new BusinessException("Agent 运行定义不能为空");
|
||||
}
|
||||
AgentRuntimeBundle bundle = new AgentRuntimeBundle();
|
||||
AgentDefinition definition = new AgentDefinition();
|
||||
definition.setAgentId(agent.getId().toString());
|
||||
definition.setAgentName(agent.getName());
|
||||
definition.setDescription(agent.getDescription());
|
||||
definition.setSystemPrompt(stringValue(agent.getPromptConfigJson(), "systemPrompt", stringValue(agent.getPromptConfigJson(), "prompt", "")));
|
||||
definition.setModelSpec(buildModelSpec(agent));
|
||||
definition.setGenerationOptions(buildGenerationOptions(agent.getGenerationConfigJson()));
|
||||
definition.setExecutionOptions(buildExecutionOptions(agent.getExecutionConfigJson()));
|
||||
definition.setMemoryPolicy(buildMemoryPolicy(agent.getMemoryConfigJson()));
|
||||
bundle.setDefinition(definition);
|
||||
|
||||
compileTools(agent, definition, bundle);
|
||||
compileKnowledge(agent, definition, bundle);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
private AgentModelSpec buildModelSpec(Agent agent) {
|
||||
Model model = modelService.getModelInstance(agent.getModelId());
|
||||
if (model == null) {
|
||||
throw new BusinessException("Agent 模型不存在");
|
||||
}
|
||||
Map<String, Object> config = agent.getModelConfigJson();
|
||||
AgentModelSpec spec = new AgentModelSpec();
|
||||
String providerType = stringValue(config, "providerType", model.getModelProvider() == null ? null : model.getModelProvider().getProviderType());
|
||||
spec.setProviderType(parseProviderType(providerType));
|
||||
spec.setModelName(stringValue(config, "modelName", model.getModelName()));
|
||||
spec.setBaseUrl(stringValue(config, "baseUrl", model.getEndpoint()));
|
||||
spec.setEndpointPath(stringValue(config, "endpointPath", model.getRequestPath()));
|
||||
spec.setApiKey(stringValue(config, "apiKey", model.getApiKey()));
|
||||
spec.getMetadata().put("modelId", model.getId());
|
||||
return spec;
|
||||
}
|
||||
|
||||
private AgentGenerationOptions buildGenerationOptions(Map<String, Object> config) {
|
||||
AgentGenerationOptions options = new AgentGenerationOptions();
|
||||
options.setTemperature(doubleValue(config, "temperature"));
|
||||
options.setTopP(doubleValue(config, "topP"));
|
||||
options.setTopK(intValue(config, "topK"));
|
||||
options.setMaxTokens(intValue(config, "maxTokens"));
|
||||
options.setMaxCompletionTokens(intValue(config, "maxCompletionTokens"));
|
||||
options.setThinkingBudget(intValue(config, "thinkingBudget"));
|
||||
options.setReasoningEffort(stringValue(config, "reasoningEffort", stringValue(config, "thinkingLevel", null)));
|
||||
options.setThinkingEnabled(booleanValue(config, "thinkingEnabled"));
|
||||
Boolean stream = booleanValue(config, "stream");
|
||||
if (stream != null) {
|
||||
options.setStream(stream);
|
||||
}
|
||||
options.setAdditionalBodyParams(mapValue(config, "additionalBodyParams"));
|
||||
options.setAdditionalHeaders(stringMapValue(config, "additionalHeaders"));
|
||||
options.setAdditionalQueryParams(stringMapValue(config, "additionalQueryParams"));
|
||||
return options;
|
||||
}
|
||||
|
||||
private AgentExecutionOptions buildExecutionOptions(Map<String, Object> config) {
|
||||
AgentExecutionOptions options = new AgentExecutionOptions();
|
||||
Integer maxIters = intValue(config, "maxIters");
|
||||
if (maxIters != null) {
|
||||
options.setMaxIters(maxIters);
|
||||
}
|
||||
Integer timeoutSeconds = intValue(config, "timeoutSeconds");
|
||||
if (timeoutSeconds != null) {
|
||||
options.setTimeout(Duration.ofSeconds(timeoutSeconds));
|
||||
}
|
||||
Boolean reasoningEnabled = booleanValue(config, "reasoningEnabled");
|
||||
if (reasoningEnabled != null) {
|
||||
options.setReasoningEnabled(reasoningEnabled);
|
||||
}
|
||||
Boolean toolCallingEnabled = booleanValue(config, "toolCallingEnabled");
|
||||
if (toolCallingEnabled != null) {
|
||||
options.setToolCallingEnabled(toolCallingEnabled);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private AgentMemoryPolicy buildMemoryPolicy(Map<String, Object> config) {
|
||||
AgentMemoryPolicy policy = new AgentMemoryPolicy();
|
||||
policy.setType(memoryTypeValue(config, "type"));
|
||||
Map<String, Object> compressionConfig = mapValue(config, "compressionParameter");
|
||||
if (compressionConfig.isEmpty()) {
|
||||
compressionConfig = mapValue(config, "autoContext");
|
||||
}
|
||||
AgentMemoryCompressionParameter parameter = new AgentMemoryCompressionParameter();
|
||||
Boolean enabled = booleanValue(compressionConfig, "enabled");
|
||||
if (enabled != null) {
|
||||
parameter.setEnabled(enabled);
|
||||
}
|
||||
Integer msgThreshold = intValue(compressionConfig, "msgThreshold");
|
||||
if (msgThreshold == null) {
|
||||
msgThreshold = intValue(config, "maxAttachedMessageCount");
|
||||
}
|
||||
if (msgThreshold == null) {
|
||||
msgThreshold = intValue(config, "historyLimit");
|
||||
}
|
||||
if (msgThreshold != null) {
|
||||
parameter.setMsgThreshold(msgThreshold);
|
||||
policy.setMaxAttachedMessageCount(msgThreshold);
|
||||
}
|
||||
Integer lastKeep = intValue(compressionConfig, "lastKeep");
|
||||
if (lastKeep != null) {
|
||||
parameter.setLastKeep(lastKeep);
|
||||
}
|
||||
Double tokenRatio = doubleValue(compressionConfig, "tokenRatio");
|
||||
if (tokenRatio != null) {
|
||||
parameter.setTokenRatio(tokenRatio);
|
||||
}
|
||||
Long maxToken = longValue(compressionConfig, "maxToken");
|
||||
if (maxToken != null) {
|
||||
parameter.setMaxToken(maxToken);
|
||||
}
|
||||
Long largePayloadThreshold = longValue(compressionConfig, "largePayloadThreshold");
|
||||
if (largePayloadThreshold != null) {
|
||||
parameter.setLargePayloadThreshold(largePayloadThreshold);
|
||||
}
|
||||
Integer minCompressionTokenThreshold = intValue(compressionConfig, "minCompressionTokenThreshold");
|
||||
if (minCompressionTokenThreshold != null) {
|
||||
parameter.setMinCompressionTokenThreshold(minCompressionTokenThreshold);
|
||||
}
|
||||
Double currentRoundCompressionRatio = doubleValue(compressionConfig, "currentRoundCompressionRatio");
|
||||
if (currentRoundCompressionRatio != null) {
|
||||
parameter.setCurrentRoundCompressionRatio(currentRoundCompressionRatio);
|
||||
}
|
||||
Integer minConsecutiveToolMessages = intValue(compressionConfig, "minConsecutiveToolMessages");
|
||||
if (minConsecutiveToolMessages != null) {
|
||||
parameter.setMinConsecutiveToolMessages(minConsecutiveToolMessages);
|
||||
}
|
||||
policy.setCompressionParameter(parameter);
|
||||
return policy;
|
||||
}
|
||||
|
||||
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("绑定工作流不存在");
|
||||
}
|
||||
return new WorkflowTool(
|
||||
workflow,
|
||||
true,
|
||||
PublishedWorkflowDefinitionIds.published(String.valueOf(workflow.getId()))
|
||||
);
|
||||
}
|
||||
if (type == AgentToolType.PLUGIN) {
|
||||
PluginItem pluginItem = snapshotOrCurrentPlugin(binding);
|
||||
if (pluginItem == null) {
|
||||
throw new BusinessException("绑定插件不存在");
|
||||
}
|
||||
return pluginItem.toFunction();
|
||||
}
|
||||
throw new BusinessException("不支持的 Agent 工具类型:" + type.name());
|
||||
}
|
||||
|
||||
private McpSpec buildMcpSpec(AgentToolBinding binding) {
|
||||
Mcp mcp = snapshotOrCurrentMcp(binding);
|
||||
if (mcp == null) {
|
||||
throw new BusinessException("绑定 MCP 不存在");
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
private void applyMcpToolBinding(McpSpec spec, AgentToolBinding binding) {
|
||||
if (Boolean.TRUE.equals(binding.getHitlEnabled())) {
|
||||
spec.setApprovalRequired(true);
|
||||
spec.setApprovalRequest(buildBindingApprovalRequest(binding));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveRuntimeToolName(Tool tool, AgentToolBinding binding) {
|
||||
String bindingName = binding == null ? null : binding.getToolName();
|
||||
if (ChatToolNameHelper.isSafeToolName(bindingName)) {
|
||||
return bindingName;
|
||||
}
|
||||
String toolName = tool == null ? null : tool.getName();
|
||||
if (ChatToolNameHelper.isSafeToolName(toolName)) {
|
||||
return toolName;
|
||||
}
|
||||
BigInteger targetId = binding == null ? null : binding.getTargetId();
|
||||
return ChatToolNameHelper.buildFallbackName("tool", targetId);
|
||||
}
|
||||
|
||||
private void compileKnowledge(Agent agent, AgentDefinition definition, AgentRuntimeBundle bundle) {
|
||||
if (agent.getKnowledgeBindings() == null) {
|
||||
return;
|
||||
}
|
||||
List<AgentKnowledgeSpec> specs = new ArrayList<>();
|
||||
Map<String, com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever> retrievers = new LinkedHashMap<>();
|
||||
for (AgentKnowledgeBinding binding : agent.getKnowledgeBindings()) {
|
||||
if (!Boolean.TRUE.equals(binding.getEnabled())) {
|
||||
continue;
|
||||
}
|
||||
DocumentCollection knowledge = snapshotOrPublishedKnowledge(binding);
|
||||
if (knowledge == null) {
|
||||
throw new BusinessException("绑定知识库不存在");
|
||||
}
|
||||
AgentKnowledgeSpec spec = new AgentKnowledgeSpec();
|
||||
spec.setKnowledgeId(binding.getKnowledgeId().toString());
|
||||
spec.setName(knowledge.getTitle());
|
||||
spec.setDescription(knowledge.getDescription());
|
||||
spec.setRetrievalMode(AgentKnowledgePolicy.AGENTIC);
|
||||
spec.getMetadata().put("knowledgeType", knowledge.getCollectionType());
|
||||
spec.getMetadata().put("faqCollection", knowledge.isFaqCollection());
|
||||
Integer limit = intValue(binding.getOptionsJson(), "limit");
|
||||
spec.setLimit(limit == null ? 5 : limit);
|
||||
Double threshold = doubleValue(binding.getOptionsJson(), "scoreThreshold");
|
||||
if (threshold != null) {
|
||||
spec.setScoreThreshold(threshold);
|
||||
}
|
||||
specs.add(spec);
|
||||
retrievers.put(spec.getKnowledgeId(), request -> retrieveKnowledge(binding, request.getQuery(), request.getLimit(), request.getScoreThreshold()));
|
||||
}
|
||||
definition.setKnowledgeSpecs(specs);
|
||||
bundle.setKnowledgeRetrievers(retrievers);
|
||||
}
|
||||
|
||||
private AgentKnowledgeRetrievalResult retrieveKnowledge(AgentKnowledgeBinding binding, String query, int limit, double scoreThreshold) {
|
||||
KnowledgeRetrievalRequest request = new KnowledgeRetrievalRequest();
|
||||
request.setKnowledgeId(binding.getKnowledgeId());
|
||||
request.setQuery(query);
|
||||
request.setLimit(limit <= 0 ? null : limit);
|
||||
request.setMinSimilarity(scoreThreshold <= 0D ? null : scoreThreshold);
|
||||
request.setRetrievalMode(KnowledgeRetrievalModes.parse(binding.getRetrievalMode()));
|
||||
request.setCallerType("AGENT_KNOWLEDGE");
|
||||
request.setCallerId(binding.getAgentId() == null ? null : binding.getAgentId().toString());
|
||||
LOG.info(
|
||||
"Agent knowledge retrieval started, agentId={}, knowledgeId={}, query={}, limit={}, scoreThreshold={}, retrievalMode={}",
|
||||
request.getCallerId(),
|
||||
request.getKnowledgeId(),
|
||||
request.getQuery(),
|
||||
request.getLimit(),
|
||||
request.getMinSimilarity(),
|
||||
request.getRetrievalMode()
|
||||
);
|
||||
List<Document> documents = documentCollectionService.search(request);
|
||||
LOG.info(
|
||||
"Agent knowledge retrieval completed, agentId={}, knowledgeId={}, query={}, documentCount={}, documents={}",
|
||||
request.getCallerId(),
|
||||
request.getKnowledgeId(),
|
||||
request.getQuery(),
|
||||
documents == null ? 0 : documents.size(),
|
||||
summarizeDocuments(documents)
|
||||
);
|
||||
List<AgentKnowledgeDocument> mapped = new ArrayList<>();
|
||||
if (documents != null) {
|
||||
for (Document document : documents) {
|
||||
AgentKnowledgeDocument item = new AgentKnowledgeDocument();
|
||||
item.setDocumentId(firstNonBlank(
|
||||
metadataString(document.getMetadataMap(), "documentId"),
|
||||
document.getId() == null ? null : String.valueOf(document.getId())
|
||||
));
|
||||
item.setDocumentName(firstNonBlank(
|
||||
metadataString(document.getMetadataMap(), "sourceFileName"),
|
||||
document.getTitle()
|
||||
));
|
||||
item.setChunkId(firstNonBlank(
|
||||
metadataString(document.getMetadataMap(), "chunkId"),
|
||||
document.getId() == null ? null : String.valueOf(document.getId())
|
||||
));
|
||||
item.setContent(document.getContent());
|
||||
item.setScore(document.getScore());
|
||||
item.setMetadata(document.getMetadataMap());
|
||||
item.setSourceUri(metadataString(document.getMetadataMap(), "sourceUri"));
|
||||
mapped.add(item);
|
||||
}
|
||||
}
|
||||
return AgentKnowledgeRetrievalResult.of(mapped);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用于日志排查的知识库命中摘要,避免完整内容撑爆日志。
|
||||
*
|
||||
* @param documents 知识库检索命中文档
|
||||
* @return 文档摘要列表
|
||||
*/
|
||||
private List<Map<String, Object>> summarizeDocuments(List<Document> documents) {
|
||||
List<Map<String, Object>> summaries = new ArrayList<>();
|
||||
if (documents == null) {
|
||||
return summaries;
|
||||
}
|
||||
for (Document document : documents) {
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("id", document.getId());
|
||||
summary.put("title", document.getTitle());
|
||||
summary.put("score", document.getScore());
|
||||
summary.put("content", truncate(document.getContent()));
|
||||
summary.put("metadata", document.getMetadataMap());
|
||||
summaries.add(summary);
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断日志文本,保留排查所需的前缀内容。
|
||||
*
|
||||
* @param text 原始文本
|
||||
* @return 截断后的文本
|
||||
*/
|
||||
private String truncate(String text) {
|
||||
if (text == null || text.length() <= LOG_TEXT_MAX_LENGTH) {
|
||||
return text;
|
||||
}
|
||||
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);
|
||||
knowledge.setId(firstNonNull(knowledge.getId(), binding.getKnowledgeId()));
|
||||
return knowledge;
|
||||
}
|
||||
return documentCollectionService.getPublishedById(binding.getKnowledgeId());
|
||||
}
|
||||
|
||||
private BigInteger firstNonNull(BigInteger first, BigInteger second) {
|
||||
return first == null ? second : first;
|
||||
}
|
||||
|
||||
private String firstNonBlank(String first, String second) {
|
||||
return first == null || first.isBlank() ? second : first;
|
||||
}
|
||||
|
||||
private String metadataString(Map<String, Object> metadata, String key) {
|
||||
Object value = metadata == null ? null : metadata.get(key);
|
||||
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 AgentModelProviderType parseProviderType(String providerType) {
|
||||
if (providerType == null || providerType.isBlank()) {
|
||||
return AgentModelProviderType.OPENAI_COMPATIBLE;
|
||||
}
|
||||
try {
|
||||
return AgentModelProviderType.valueOf(providerType.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return AgentModelProviderType.OPENAI_COMPATIBLE;
|
||||
}
|
||||
}
|
||||
|
||||
private AgentMemoryType memoryTypeValue(Map<String, Object> map, String key) {
|
||||
String value = stringValue(map, key, AgentMemoryType.AUTO_CONTEXT.name());
|
||||
try {
|
||||
return AgentMemoryType.valueOf(value.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new BusinessException("不支持的 Agent 记忆策略:" + value);
|
||||
}
|
||||
}
|
||||
|
||||
private String stringValue(Map<String, Object> map, String key, String defaultValue) {
|
||||
Object value = map == null ? null : map.get(key);
|
||||
return value == null ? defaultValue : String.valueOf(value);
|
||||
}
|
||||
|
||||
private Integer intValue(Map<String, Object> map, String key) {
|
||||
Object value = map == null ? null : map.get(key);
|
||||
if (value instanceof Number number) {
|
||||
return number.intValue();
|
||||
}
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(String.valueOf(value));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new BusinessException("Agent 配置字段必须是整数:" + key);
|
||||
}
|
||||
}
|
||||
|
||||
private Long longValue(Map<String, Object> map, String key) {
|
||||
Object value = map == null ? null : map.get(key);
|
||||
if (value instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(String.valueOf(value));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new BusinessException("Agent 配置字段必须是长整数:" + key);
|
||||
}
|
||||
}
|
||||
|
||||
private Double doubleValue(Map<String, Object> map, String key) {
|
||||
Object value = map == null ? null : map.get(key);
|
||||
if (value instanceof Number number) {
|
||||
return number.doubleValue();
|
||||
}
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Double.parseDouble(String.valueOf(value));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new BusinessException("Agent 配置字段必须是数字:" + key);
|
||||
}
|
||||
}
|
||||
|
||||
private Boolean booleanValue(Map<String, Object> map, String key) {
|
||||
Object value = map == null ? null : map.get(key);
|
||||
if (value instanceof Boolean bool) {
|
||||
return bool;
|
||||
}
|
||||
return value == null ? null : Boolean.parseBoolean(String.valueOf(value));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> mapValue(Map<String, Object> map, String key) {
|
||||
Object value = map == null ? null : map.get(key);
|
||||
if (value == null) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
if (value instanceof Map<?, ?> rawMap) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
rawMap.forEach((rawKey, rawValue) -> result.put(String.valueOf(rawKey), rawValue));
|
||||
return result;
|
||||
}
|
||||
throw new BusinessException("Agent 配置字段必须是对象:" + key);
|
||||
}
|
||||
|
||||
private Map<String, String> stringMapValue(Map<String, Object> map, String key) {
|
||||
Map<String, Object> rawMap = mapValue(map, key);
|
||||
Map<String, String> result = new LinkedHashMap<>();
|
||||
rawMap.forEach((rawKey, rawValue) -> {
|
||||
if (rawValue != null) {
|
||||
result.put(rawKey, String.valueOf(rawValue));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package tech.easyflow.agent.runtime;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRuntime;
|
||||
|
||||
/**
|
||||
* Agent 运行时工厂
|
||||
*/
|
||||
public interface AgentRuntimeFactory {
|
||||
|
||||
/**
|
||||
* 创建新的有状态 Agent 运行时实例。
|
||||
*
|
||||
* @return Agent 运行时实例
|
||||
*/
|
||||
AgentRuntime create();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package tech.easyflow.agent.runtime;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
|
||||
import tech.easyflow.agent.runtime.session.EasyFlowAgentSessionStore;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* Agent 运行态持久化状态清理服务。
|
||||
*/
|
||||
@Service
|
||||
public class AgentRuntimeStateCleanupService {
|
||||
|
||||
private final AgentRunRegistry agentRunRegistry;
|
||||
private final EasyFlowAgentSessionStore sessionStore;
|
||||
private final AgentHitlPendingService pendingService;
|
||||
|
||||
/**
|
||||
* 创建清理服务。
|
||||
*
|
||||
* @param agentRunRegistry 当前节点运行态注册表
|
||||
* @param sessionStore AgentScope session store
|
||||
* @param pendingService HITL pending 服务
|
||||
*/
|
||||
public AgentRuntimeStateCleanupService(AgentRunRegistry agentRunRegistry,
|
||||
EasyFlowAgentSessionStore sessionStore,
|
||||
AgentHitlPendingService pendingService) {
|
||||
this.agentRunRegistry = agentRunRegistry;
|
||||
this.sessionStore = sessionStore;
|
||||
this.pendingService = pendingService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理指定正式聊天会话关联的 Agent 运行态。
|
||||
*
|
||||
* @param chatSessionId chatlog 会话 ID
|
||||
* @param userId 当前用户 ID
|
||||
*/
|
||||
public void clearChatSession(BigInteger chatSessionId, BigInteger userId) {
|
||||
if (chatSessionId == null) {
|
||||
return;
|
||||
}
|
||||
String runtimeSessionId = chatSessionId.toString();
|
||||
agentRunRegistry.cancelSession(runtimeSessionId, userId == null ? null : userId.toString());
|
||||
sessionStore.deleteByChatSessionId(chatSessionId);
|
||||
pendingService.deleteByChatSessionId(chatSessionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package tech.easyflow.agent.runtime;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRuntime;
|
||||
import com.easyagents.agent.runtime.agentscope.AgentScopeReActRuntime;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* ReActAgent 运行时工厂。
|
||||
*/
|
||||
@Component
|
||||
public class AgentScopeRuntimeFactory implements AgentRuntimeFactory {
|
||||
|
||||
/**
|
||||
* 创建新的 ReAct 运行时实例。
|
||||
*
|
||||
* @return 新的运行时实例
|
||||
*/
|
||||
@Override
|
||||
public AgentRuntime create() {
|
||||
return new AgentScopeReActRuntime();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package tech.easyflow.agent.runtime;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Agent 工具调用人工确认事件载荷。
|
||||
*/
|
||||
public class AgentToolHitlPayload {
|
||||
|
||||
private String requestId;
|
||||
private String resumeToken;
|
||||
private String sessionId;
|
||||
private String agentId;
|
||||
private String toolCallId;
|
||||
private String toolName;
|
||||
private String toolDisplayName;
|
||||
private String toolType;
|
||||
private Map<String, Object> input = new LinkedHashMap<>();
|
||||
private String expiresAt;
|
||||
private Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 获取运行请求 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话 ID。
|
||||
*
|
||||
* @return 会话 ID
|
||||
*/
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置会话 ID。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
*/
|
||||
public void setSessionId(String sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent ID。
|
||||
*
|
||||
* @return Agent ID
|
||||
*/
|
||||
public String getAgentId() {
|
||||
return agentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Agent ID。
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
*/
|
||||
public void setAgentId(String agentId) {
|
||||
this.agentId = agentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具调用 ID。
|
||||
*
|
||||
* @return 工具调用 ID
|
||||
*/
|
||||
public String getToolCallId() {
|
||||
return toolCallId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置工具调用 ID。
|
||||
*
|
||||
* @param toolCallId 工具调用 ID
|
||||
*/
|
||||
public void setToolCallId(String toolCallId) {
|
||||
this.toolCallId = toolCallId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具名称。
|
||||
*
|
||||
* @return 工具名称
|
||||
*/
|
||||
public String getToolName() {
|
||||
return toolName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置工具名称。
|
||||
*
|
||||
* @param toolName 工具名称
|
||||
*/
|
||||
public void setToolName(String toolName) {
|
||||
this.toolName = toolName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具展示名称。
|
||||
*
|
||||
* @return 工具展示名称
|
||||
*/
|
||||
public String getToolDisplayName() {
|
||||
return toolDisplayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置工具展示名称。
|
||||
*
|
||||
* @param toolDisplayName 工具展示名称
|
||||
*/
|
||||
public void setToolDisplayName(String toolDisplayName) {
|
||||
this.toolDisplayName = toolDisplayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具类型。
|
||||
*
|
||||
* @return 工具类型
|
||||
*/
|
||||
public String getToolType() {
|
||||
return toolType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置工具类型。
|
||||
*
|
||||
* @param toolType 工具类型
|
||||
*/
|
||||
public void setToolType(String toolType) {
|
||||
this.toolType = toolType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具入参。
|
||||
*
|
||||
* @return 工具入参
|
||||
*/
|
||||
public Map<String, Object> getInput() {
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置工具入参。
|
||||
*
|
||||
* @param input 工具入参
|
||||
*/
|
||||
public void setInput(Map<String, Object> input) {
|
||||
this.input = input == null ? new LinkedHashMap<>() : input;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取过期时间。
|
||||
*
|
||||
* @return 过期时间
|
||||
*/
|
||||
public String getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置过期时间。
|
||||
*
|
||||
* @param expiresAt 过期时间
|
||||
*/
|
||||
public void setExpiresAt(String expiresAt) {
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取扩展元数据。
|
||||
*
|
||||
* @return 扩展元数据
|
||||
*/
|
||||
public Map<String, Object> getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置扩展元数据。
|
||||
*
|
||||
* @param metadata 扩展元数据
|
||||
*/
|
||||
public void setMetadata(Map<String, Object> metadata) {
|
||||
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package tech.easyflow.agent.runtime.asynctool;
|
||||
|
||||
import com.easyagents.agent.runtime.tool.AgentToolContext;
|
||||
import com.easyagents.agent.runtime.tool.asynctool.*;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.util.StringUtils;
|
||||
import tech.easyflow.agent.runtime.tool.AgentToolExecutionResult;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* EasyFlow Agent 异步业务工具基类。
|
||||
*/
|
||||
public abstract class AbstractAgentAsyncSubTools implements AsyncSubTools {
|
||||
|
||||
private static final String ERROR_TYPE_NOT_FOUND = "TASK_NOT_FOUND";
|
||||
private static final String ERROR_TYPE_EXCEPTION = "EXCEPTION";
|
||||
|
||||
private final AgentAsyncToolTaskStore taskStore;
|
||||
private final ThreadPoolTaskExecutor taskExecutor;
|
||||
|
||||
/**
|
||||
* 创建异步业务工具基类。
|
||||
*
|
||||
* @param taskStore 任务存储
|
||||
* @param taskExecutor 后台执行器
|
||||
*/
|
||||
protected AbstractAgentAsyncSubTools(AgentAsyncToolTaskStore taskStore,
|
||||
ThreadPoolTaskExecutor taskExecutor) {
|
||||
this.taskStore = taskStore;
|
||||
this.taskExecutor = taskExecutor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具类型。
|
||||
*
|
||||
* @return 工具类型
|
||||
*/
|
||||
protected abstract String toolType();
|
||||
|
||||
/**
|
||||
* 获取运行时工具名。
|
||||
*
|
||||
* @return 运行时工具名
|
||||
*/
|
||||
protected abstract String toolName();
|
||||
|
||||
/**
|
||||
* 获取用户可见工具名称。
|
||||
*
|
||||
* @return 用户可见工具名称
|
||||
*/
|
||||
protected abstract String displayName();
|
||||
|
||||
/**
|
||||
* 获取业务资源 ID。
|
||||
*
|
||||
* @return 业务资源 ID
|
||||
*/
|
||||
protected abstract String businessId();
|
||||
|
||||
/**
|
||||
* 执行业务工具。
|
||||
*
|
||||
* @param arguments 调用参数
|
||||
* @return 执行结果
|
||||
*/
|
||||
protected abstract AgentToolExecutionResult executeBusiness(Map<String, Object> arguments);
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public AsyncToolSubmitResult submit(Map<String, Object> arguments, AgentToolContext context) {
|
||||
String sessionId = requireSessionId(context);
|
||||
String taskId = newTaskId();
|
||||
AgentAsyncToolTaskRecord record = new AgentAsyncToolTaskRecord();
|
||||
record.setTaskId(taskId);
|
||||
record.setToolType(toolType());
|
||||
record.setToolName(toolName());
|
||||
record.setBusinessId(businessId());
|
||||
record.setStatus(AsyncToolTaskStatus.PENDING);
|
||||
record.setArguments(arguments == null ? Map.of() : new LinkedHashMap<>(arguments));
|
||||
record.setSummary(displayName() + "任务已提交");
|
||||
record.setRequestId(context == null ? null : context.getRequestId());
|
||||
record.setTraceId(context == null ? null : context.getTraceId());
|
||||
record.setSessionId(sessionId);
|
||||
record.setAgentId(context == null ? null : context.getAgentId());
|
||||
record.setToolCallId(context == null ? null : context.getToolCallId());
|
||||
record.getMetadata().put("toolDisplayName", displayName());
|
||||
appendEvent(record, "SUBMITTED", displayName() + "任务已提交");
|
||||
taskStore.create(record);
|
||||
dispatch(sessionId, record.getTaskId(), record.getArguments());
|
||||
|
||||
AsyncToolSubmitResult result = new AsyncToolSubmitResult();
|
||||
result.setTaskId(taskId);
|
||||
result.setStatus(AsyncToolTaskStatus.PENDING);
|
||||
result.setCursor(0L);
|
||||
result.setSummary(record.getSummary());
|
||||
result.setNextAction(toolName() + "_observe 查看任务进度。");
|
||||
result.getMetadata().put("toolDisplayName", displayName());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public AsyncToolTaskView observe(AsyncToolObserveRequest request, AgentToolContext context) {
|
||||
return taskView(request == null ? null : request.getTaskId(),
|
||||
request == null ? null : request.getCursor(),
|
||||
request == null ? null : request.getLimit(),
|
||||
context);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public AsyncToolTaskView result(AsyncToolResultRequest request, AgentToolContext context) {
|
||||
return taskView(request == null ? null : request.getTaskId(),
|
||||
request == null ? null : request.getCursor(),
|
||||
request == null ? null : request.getLimit(),
|
||||
context);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public AsyncToolCancelResult cancel(AsyncToolCancelRequest request, AgentToolContext context) {
|
||||
AsyncToolCancelResult result = new AsyncToolCancelResult();
|
||||
result.setTaskId(request == null ? null : request.getTaskId());
|
||||
result.setStatus(AsyncToolTaskStatus.FAILED);
|
||||
result.setErrorMessage("当前异步工具不支持取消正在执行的任务");
|
||||
result.setMessage("不支持取消");
|
||||
result.getMetadata().put("toolDisplayName", displayName());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public AsyncToolTaskListResult list(AsyncToolListRequest request, AgentToolContext context) {
|
||||
String sessionId = requireSessionId(context);
|
||||
AsyncToolTaskStatus status = request == null ? null : request.getStatus();
|
||||
List<AsyncToolTaskSummary> tasks = new ArrayList<>();
|
||||
for (AgentAsyncToolTaskRecord record : taskStore.list(sessionId, status)) {
|
||||
tasks.add(summary(record));
|
||||
}
|
||||
AsyncToolTaskListResult result = new AsyncToolTaskListResult();
|
||||
result.setTasks(tasks);
|
||||
result.getMetadata().put("toolDisplayName", displayName());
|
||||
return result;
|
||||
}
|
||||
|
||||
private void dispatch(String sessionId, String taskId, Map<String, Object> arguments) {
|
||||
try {
|
||||
taskExecutor.execute(() -> executeTask(sessionId, taskId, arguments));
|
||||
} catch (Exception e) {
|
||||
taskStore.update(sessionId, taskId, record -> fail(record, e));
|
||||
throw new BusinessException("提交异步工具任务失败:" + safeMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
private void executeTask(String sessionId, String taskId, Map<String, Object> arguments) {
|
||||
try {
|
||||
taskStore.update(sessionId, taskId, record -> {
|
||||
record.setStatus(AsyncToolTaskStatus.RUNNING);
|
||||
record.setSummary(displayName() + "任务执行中");
|
||||
appendEvent(record, "RUNNING", displayName() + "任务执行中");
|
||||
return record;
|
||||
});
|
||||
AgentToolExecutionResult executionResult = executeBusiness(arguments);
|
||||
taskStore.update(sessionId, taskId, record -> {
|
||||
record.setStatus(AsyncToolTaskStatus.SUCCEEDED);
|
||||
record.setSummary(displayName() + "任务已完成");
|
||||
record.setResult(executionResult == null ? null : executionResult.getResult());
|
||||
record.setBusinessExecutionId(executionResult == null ? null : executionResult.getBusinessExecutionId());
|
||||
appendEvent(record, "SUCCEEDED", displayName() + "任务已完成");
|
||||
return record;
|
||||
});
|
||||
} catch (Exception e) {
|
||||
taskStore.update(sessionId, taskId, record -> fail(record, e));
|
||||
}
|
||||
}
|
||||
|
||||
private AgentAsyncToolTaskRecord fail(AgentAsyncToolTaskRecord record, Exception error) {
|
||||
record.setStatus(AsyncToolTaskStatus.FAILED);
|
||||
record.setSummary(displayName() + "任务执行失败");
|
||||
record.setErrorType(ERROR_TYPE_EXCEPTION);
|
||||
record.setErrorMessage(safeMessage(error));
|
||||
appendEvent(record, "FAILED", record.getErrorMessage());
|
||||
return record;
|
||||
}
|
||||
|
||||
private AsyncToolTaskView taskView(String taskId, Long cursor, Integer limit, AgentToolContext context) {
|
||||
String sessionId = requireSessionId(context);
|
||||
if (!StringUtils.hasText(taskId)) {
|
||||
return notFoundView(taskId, cursor, "任务 ID 不能为空");
|
||||
}
|
||||
return taskStore.get(sessionId, taskId)
|
||||
.map(record -> toView(record, cursor, limit))
|
||||
.orElseGet(() -> notFoundView(taskId, cursor, "异步工具任务不存在或已过期"));
|
||||
}
|
||||
|
||||
private AsyncToolTaskView toView(AgentAsyncToolTaskRecord record, Long cursor, Integer limit) {
|
||||
long safeCursor = cursor == null ? 0L : Math.max(0L, cursor);
|
||||
int safeLimit = limit == null || limit <= 0 ? 20 : Math.min(limit, 100);
|
||||
List<AsyncToolTaskEvent> events = new ArrayList<>();
|
||||
for (AsyncToolTaskEvent event : record.getEvents()) {
|
||||
if (event.getSequence() != null && event.getSequence() > safeCursor) {
|
||||
events.add(event);
|
||||
}
|
||||
if (events.size() >= safeLimit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Long nextCursor = events.isEmpty()
|
||||
? safeCursor
|
||||
: events.get(events.size() - 1).getSequence();
|
||||
AsyncToolTaskView view = new AsyncToolTaskView();
|
||||
view.setTaskId(record.getTaskId());
|
||||
view.setStatus(record.getStatus());
|
||||
view.setCursor(safeCursor);
|
||||
view.setNextCursor(nextCursor);
|
||||
view.setSummary(record.getSummary());
|
||||
view.setNextAction(nextAction(record.getStatus()));
|
||||
view.setEvents(events);
|
||||
view.setResult(record.getResult());
|
||||
view.setErrorMessage(record.getErrorMessage());
|
||||
view.setErrorType(record.getErrorType());
|
||||
view.setTerminal(record.getStatus() != null && record.getStatus().isTerminal());
|
||||
view.setResultAvailable(record.getStatus() == AsyncToolTaskStatus.SUCCEEDED && record.getResult() != null);
|
||||
view.getMetadata().put("toolDisplayName", displayName());
|
||||
putIfNotNull(view.getPayload(), "businessId", record.getBusinessId());
|
||||
putIfNotNull(view.getPayload(), "businessExecutionId", record.getBusinessExecutionId());
|
||||
return view;
|
||||
}
|
||||
|
||||
private AsyncToolTaskView notFoundView(String taskId, Long cursor, String message) {
|
||||
AsyncToolTaskView view = new AsyncToolTaskView();
|
||||
view.setTaskId(taskId);
|
||||
view.setStatus(AsyncToolTaskStatus.FAILED);
|
||||
view.setCursor(cursor == null ? 0L : cursor);
|
||||
view.setNextCursor(cursor == null ? 0L : cursor);
|
||||
view.setSummary(message);
|
||||
view.setErrorType(ERROR_TYPE_NOT_FOUND);
|
||||
view.setErrorMessage(message);
|
||||
view.setTerminal(true);
|
||||
view.setResultAvailable(false);
|
||||
view.getMetadata().put("toolDisplayName", displayName());
|
||||
return view;
|
||||
}
|
||||
|
||||
private AsyncToolTaskSummary summary(AgentAsyncToolTaskRecord record) {
|
||||
AsyncToolTaskSummary summary = new AsyncToolTaskSummary();
|
||||
summary.setTaskId(record.getTaskId());
|
||||
summary.setStatus(record.getStatus());
|
||||
summary.setSummary(record.getSummary());
|
||||
summary.setCreatedAt(record.getCreatedAt());
|
||||
summary.setUpdatedAt(record.getUpdatedAt());
|
||||
summary.getPayload().put("toolName", record.getToolName());
|
||||
summary.getPayload().put("toolDisplayName", displayName());
|
||||
return summary;
|
||||
}
|
||||
|
||||
private void appendEvent(AgentAsyncToolTaskRecord record, String type, String text) {
|
||||
AsyncToolTaskEvent event = new AsyncToolTaskEvent();
|
||||
event.setSequence((long) record.getEvents().size() + 1L);
|
||||
event.setType(type);
|
||||
event.setText(text);
|
||||
event.setCreatedAt(Instant.now());
|
||||
record.getEvents().add(event);
|
||||
}
|
||||
|
||||
private String nextAction(AsyncToolTaskStatus status) {
|
||||
if (status != null && status.isTerminal()) {
|
||||
return "任务已结束。";
|
||||
}
|
||||
return toolName() + "_observe 继续查看任务进度。";
|
||||
}
|
||||
|
||||
private String requireSessionId(AgentToolContext context) {
|
||||
if (context == null || !StringUtils.hasText(context.getSessionId())) {
|
||||
throw new BusinessException("异步工具任务缺少 Agent session 上下文");
|
||||
}
|
||||
return context.getSessionId();
|
||||
}
|
||||
|
||||
private String newTaskId() {
|
||||
String idPart = UUID.randomUUID().toString().replace("-", "");
|
||||
return "async_" + idPart;
|
||||
}
|
||||
|
||||
private void putIfNotNull(Map<String, Object> target, String key, Object value) {
|
||||
if (value != null) {
|
||||
target.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private String safeMessage(Exception e) {
|
||||
return e == null || e.getMessage() == null || e.getMessage().isBlank()
|
||||
? "异步工具任务执行失败"
|
||||
: e.getMessage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package tech.easyflow.agent.runtime.asynctool;
|
||||
|
||||
import com.easyagents.agent.runtime.tool.asynctool.AsyncToolTaskEvent;
|
||||
import com.easyagents.agent.runtime.tool.asynctool.AsyncToolTaskStatus;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Agent 异步工具任务 Redis 运行态记录。
|
||||
*/
|
||||
public class AgentAsyncToolTaskRecord {
|
||||
|
||||
private String taskId;
|
||||
private String toolType;
|
||||
private String toolName;
|
||||
private String businessId;
|
||||
private String businessExecutionId;
|
||||
private String sessionScopedKey;
|
||||
private Long ttlSeconds;
|
||||
private AsyncToolTaskStatus status = AsyncToolTaskStatus.PENDING;
|
||||
private Map<String, Object> arguments = new LinkedHashMap<>();
|
||||
private String summary;
|
||||
private Object result;
|
||||
private String errorMessage;
|
||||
private String errorType;
|
||||
private List<AsyncToolTaskEvent> events = new ArrayList<>();
|
||||
private String requestId;
|
||||
private String traceId;
|
||||
private String sessionId;
|
||||
private String agentId;
|
||||
private String toolCallId;
|
||||
private Instant createdAt = Instant.now();
|
||||
private Instant updatedAt = Instant.now();
|
||||
private Map<String, Object> payload = new LinkedHashMap<>();
|
||||
private Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 获取任务 ID。
|
||||
*
|
||||
* @return 任务 ID
|
||||
*/
|
||||
public String getTaskId() { return taskId; }
|
||||
|
||||
/**
|
||||
* 设置任务 ID。
|
||||
*
|
||||
* @param taskId 任务 ID
|
||||
*/
|
||||
public void setTaskId(String taskId) { this.taskId = taskId; }
|
||||
|
||||
/**
|
||||
* 获取工具类型。
|
||||
*
|
||||
* @return 工具类型
|
||||
*/
|
||||
public String getToolType() { return toolType; }
|
||||
|
||||
/**
|
||||
* 设置工具类型。
|
||||
*
|
||||
* @param toolType 工具类型
|
||||
*/
|
||||
public void setToolType(String toolType) { this.toolType = toolType; }
|
||||
|
||||
/**
|
||||
* 获取工具名称。
|
||||
*
|
||||
* @return 工具名称
|
||||
*/
|
||||
public String getToolName() { return toolName; }
|
||||
|
||||
/**
|
||||
* 设置工具名称。
|
||||
*
|
||||
* @param toolName 工具名称
|
||||
*/
|
||||
public void setToolName(String toolName) { this.toolName = toolName; }
|
||||
|
||||
/**
|
||||
* 获取业务资源 ID。
|
||||
*
|
||||
* @return 业务资源 ID
|
||||
*/
|
||||
public String getBusinessId() { return businessId; }
|
||||
|
||||
/**
|
||||
* 设置业务资源 ID。
|
||||
*
|
||||
* @param businessId 业务资源 ID
|
||||
*/
|
||||
public void setBusinessId(String businessId) { this.businessId = businessId; }
|
||||
|
||||
/**
|
||||
* 获取业务执行记录 ID。
|
||||
*
|
||||
* @return 业务执行记录 ID
|
||||
*/
|
||||
public String getBusinessExecutionId() { return businessExecutionId; }
|
||||
|
||||
/**
|
||||
* 设置业务执行记录 ID。
|
||||
*
|
||||
* @param businessExecutionId 业务执行记录 ID
|
||||
*/
|
||||
public void setBusinessExecutionId(String businessExecutionId) { this.businessExecutionId = businessExecutionId; }
|
||||
|
||||
/**
|
||||
* 获取会话内任务存储 key。
|
||||
*
|
||||
* @return 会话内任务存储 key
|
||||
*/
|
||||
public String getSessionScopedKey() { return sessionScopedKey; }
|
||||
|
||||
/**
|
||||
* 设置会话内任务存储 key。
|
||||
*
|
||||
* @param sessionScopedKey 会话内任务存储 key
|
||||
*/
|
||||
public void setSessionScopedKey(String sessionScopedKey) { this.sessionScopedKey = sessionScopedKey; }
|
||||
|
||||
/**
|
||||
* 获取任务 TTL 秒数。
|
||||
*
|
||||
* @return TTL 秒数
|
||||
*/
|
||||
public Long getTtlSeconds() { return ttlSeconds; }
|
||||
|
||||
/**
|
||||
* 设置任务 TTL 秒数。
|
||||
*
|
||||
* @param ttlSeconds TTL 秒数
|
||||
*/
|
||||
public void setTtlSeconds(Long ttlSeconds) { this.ttlSeconds = ttlSeconds; }
|
||||
|
||||
/**
|
||||
* 获取任务状态。
|
||||
*
|
||||
* @return 任务状态
|
||||
*/
|
||||
public AsyncToolTaskStatus getStatus() { return status; }
|
||||
|
||||
/**
|
||||
* 设置任务状态。
|
||||
*
|
||||
* @param status 任务状态
|
||||
*/
|
||||
public void setStatus(AsyncToolTaskStatus status) { this.status = status == null ? AsyncToolTaskStatus.PENDING : status; }
|
||||
|
||||
/**
|
||||
* 获取任务参数。
|
||||
*
|
||||
* @return 任务参数
|
||||
*/
|
||||
public Map<String, Object> getArguments() { return arguments; }
|
||||
|
||||
/**
|
||||
* 设置任务参数。
|
||||
*
|
||||
* @param arguments 任务参数
|
||||
*/
|
||||
public void setArguments(Map<String, Object> arguments) { this.arguments = arguments == null ? new LinkedHashMap<>() : arguments; }
|
||||
|
||||
/**
|
||||
* 获取任务摘要。
|
||||
*
|
||||
* @return 任务摘要
|
||||
*/
|
||||
public String getSummary() { return summary; }
|
||||
|
||||
/**
|
||||
* 设置任务摘要。
|
||||
*
|
||||
* @param summary 任务摘要
|
||||
*/
|
||||
public void setSummary(String summary) { this.summary = summary; }
|
||||
|
||||
/**
|
||||
* 获取任务结果。
|
||||
*
|
||||
* @return 任务结果
|
||||
*/
|
||||
public Object getResult() { return result; }
|
||||
|
||||
/**
|
||||
* 设置任务结果。
|
||||
*
|
||||
* @param result 任务结果
|
||||
*/
|
||||
public void setResult(Object result) { this.result = result; }
|
||||
|
||||
/**
|
||||
* 获取错误消息。
|
||||
*
|
||||
* @return 错误消息
|
||||
*/
|
||||
public String getErrorMessage() { return errorMessage; }
|
||||
|
||||
/**
|
||||
* 设置错误消息。
|
||||
*
|
||||
* @param errorMessage 错误消息
|
||||
*/
|
||||
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
|
||||
|
||||
/**
|
||||
* 获取错误类型。
|
||||
*
|
||||
* @return 错误类型
|
||||
*/
|
||||
public String getErrorType() { return errorType; }
|
||||
|
||||
/**
|
||||
* 设置错误类型。
|
||||
*
|
||||
* @param errorType 错误类型
|
||||
*/
|
||||
public void setErrorType(String errorType) { this.errorType = errorType; }
|
||||
|
||||
/**
|
||||
* 获取任务事件列表。
|
||||
*
|
||||
* @return 任务事件列表
|
||||
*/
|
||||
public List<AsyncToolTaskEvent> getEvents() { return events; }
|
||||
|
||||
/**
|
||||
* 设置任务事件列表。
|
||||
*
|
||||
* @param events 任务事件列表
|
||||
*/
|
||||
public void setEvents(List<AsyncToolTaskEvent> events) { this.events = events == null ? new ArrayList<>() : events; }
|
||||
|
||||
/**
|
||||
* 获取请求 ID。
|
||||
*
|
||||
* @return 请求 ID
|
||||
*/
|
||||
public String getRequestId() { return requestId; }
|
||||
|
||||
/**
|
||||
* 设置请求 ID。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
*/
|
||||
public void setRequestId(String requestId) { this.requestId = requestId; }
|
||||
|
||||
/**
|
||||
* 获取链路 ID。
|
||||
*
|
||||
* @return 链路 ID
|
||||
*/
|
||||
public String getTraceId() { return traceId; }
|
||||
|
||||
/**
|
||||
* 设置链路 ID。
|
||||
*
|
||||
* @param traceId 链路 ID
|
||||
*/
|
||||
public void setTraceId(String traceId) { this.traceId = traceId; }
|
||||
|
||||
/**
|
||||
* 获取 Agent Runtime session ID。
|
||||
*
|
||||
* @return session ID
|
||||
*/
|
||||
public String getSessionId() { return sessionId; }
|
||||
|
||||
/**
|
||||
* 设置 Agent Runtime session ID。
|
||||
*
|
||||
* @param sessionId session ID
|
||||
*/
|
||||
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
|
||||
|
||||
/**
|
||||
* 获取 Agent ID。
|
||||
*
|
||||
* @return Agent ID
|
||||
*/
|
||||
public String getAgentId() { return agentId; }
|
||||
|
||||
/**
|
||||
* 设置 Agent ID。
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
*/
|
||||
public void setAgentId(String agentId) { this.agentId = agentId; }
|
||||
|
||||
/**
|
||||
* 获取工具调用 ID。
|
||||
*
|
||||
* @return 工具调用 ID
|
||||
*/
|
||||
public String getToolCallId() { return toolCallId; }
|
||||
|
||||
/**
|
||||
* 设置工具调用 ID。
|
||||
*
|
||||
* @param toolCallId 工具调用 ID
|
||||
*/
|
||||
public void setToolCallId(String toolCallId) { this.toolCallId = toolCallId; }
|
||||
|
||||
/**
|
||||
* 获取创建时间。
|
||||
*
|
||||
* @return 创建时间
|
||||
*/
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
|
||||
/**
|
||||
* 设置创建时间。
|
||||
*
|
||||
* @param createdAt 创建时间
|
||||
*/
|
||||
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt == null ? Instant.now() : createdAt; }
|
||||
|
||||
/**
|
||||
* 获取更新时间。
|
||||
*
|
||||
* @return 更新时间
|
||||
*/
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
|
||||
/**
|
||||
* 设置更新时间。
|
||||
*
|
||||
* @param updatedAt 更新时间
|
||||
*/
|
||||
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt == null ? Instant.now() : updatedAt; }
|
||||
|
||||
/**
|
||||
* 获取业务载荷。
|
||||
*
|
||||
* @return 业务载荷
|
||||
*/
|
||||
public Map<String, Object> getPayload() { return payload; }
|
||||
|
||||
/**
|
||||
* 设置业务载荷。
|
||||
*
|
||||
* @param payload 业务载荷
|
||||
*/
|
||||
public void setPayload(Map<String, Object> payload) { this.payload = payload == null ? new LinkedHashMap<>() : payload; }
|
||||
|
||||
/**
|
||||
* 获取元数据。
|
||||
*
|
||||
* @return 元数据
|
||||
*/
|
||||
public Map<String, Object> getMetadata() { return metadata; }
|
||||
|
||||
/**
|
||||
* 设置元数据。
|
||||
*
|
||||
* @param metadata 元数据
|
||||
*/
|
||||
public void setMetadata(Map<String, Object> metadata) { this.metadata = metadata == null ? new LinkedHashMap<>() : metadata; }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package tech.easyflow.agent.runtime.asynctool;
|
||||
|
||||
import com.easyagents.agent.runtime.tool.asynctool.AsyncToolTaskStatus;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
/**
|
||||
* Agent 异步工具任务运行态存储。
|
||||
*/
|
||||
public interface AgentAsyncToolTaskStore {
|
||||
|
||||
/**
|
||||
* 创建任务记录。
|
||||
*
|
||||
* @param record 任务记录
|
||||
*/
|
||||
void create(AgentAsyncToolTaskRecord record);
|
||||
|
||||
/**
|
||||
* 获取当前 session 下的任务记录。
|
||||
*
|
||||
* @param sessionId Agent Runtime session ID
|
||||
* @param taskId 任务 ID
|
||||
* @return 任务记录
|
||||
*/
|
||||
Optional<AgentAsyncToolTaskRecord> get(String sessionId, String taskId);
|
||||
|
||||
/**
|
||||
* 更新当前 session 下的任务记录。
|
||||
*
|
||||
* @param sessionId Agent Runtime session ID
|
||||
* @param taskId 任务 ID
|
||||
* @param updater 更新函数
|
||||
* @return 更新后的任务记录
|
||||
*/
|
||||
Optional<AgentAsyncToolTaskRecord> update(String sessionId, String taskId, UnaryOperator<AgentAsyncToolTaskRecord> updater);
|
||||
|
||||
/**
|
||||
* 查询当前 session 下可见任务。
|
||||
*
|
||||
* @param sessionId Agent Runtime session ID
|
||||
* @param status 状态过滤;为空时返回全部未过期任务
|
||||
* @return 任务列表
|
||||
*/
|
||||
List<AgentAsyncToolTaskRecord> list(String sessionId, AsyncToolTaskStatus status);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package tech.easyflow.agent.runtime.asynctool;
|
||||
|
||||
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.PluginItem;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Plugin 异步工具子能力实现。
|
||||
*/
|
||||
public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools {
|
||||
|
||||
private final PluginItem pluginItem;
|
||||
private final String toolName;
|
||||
private final String displayName;
|
||||
private final PluginToolExecutor pluginToolExecutor;
|
||||
|
||||
/**
|
||||
* 创建 Plugin 异步工具子能力。
|
||||
*
|
||||
* @param pluginItem 插件工具快照
|
||||
* @param toolName runtime 工具名
|
||||
* @param displayName 用户可见名称
|
||||
* @param pluginToolExecutor Plugin 执行器
|
||||
* @param taskStore 任务存储
|
||||
* @param taskExecutor 后台执行器
|
||||
*/
|
||||
public PluginAsyncSubTools(PluginItem pluginItem,
|
||||
String toolName,
|
||||
String displayName,
|
||||
PluginToolExecutor pluginToolExecutor,
|
||||
AgentAsyncToolTaskStore taskStore,
|
||||
ThreadPoolTaskExecutor taskExecutor) {
|
||||
super(taskStore, taskExecutor);
|
||||
this.pluginItem = pluginItem;
|
||||
this.toolName = toolName;
|
||||
this.displayName = displayName;
|
||||
this.pluginToolExecutor = pluginToolExecutor;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected String toolType() {
|
||||
return AgentToolType.PLUGIN.name();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected String toolName() {
|
||||
return toolName;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected String displayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected String businessId() {
|
||||
return pluginItem == null || pluginItem.getId() == null ? null : String.valueOf(pluginItem.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected AgentToolExecutionResult executeBusiness(Map<String, Object> arguments) {
|
||||
return pluginToolExecutor.execute(pluginItem, arguments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package tech.easyflow.agent.runtime.asynctool;
|
||||
|
||||
import com.easyagents.agent.runtime.tool.asynctool.AsyncToolTaskStatus;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.data.redis.core.Cursor;
|
||||
import org.springframework.data.redis.core.ScanOptions;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import tech.easyflow.agent.config.AgentRuntimeProperties;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
/**
|
||||
* 基于 Redis 单 key 的 Agent 异步工具任务存储。
|
||||
*/
|
||||
@Service
|
||||
public class RedisAgentAsyncToolTaskStore implements AgentAsyncToolTaskStore {
|
||||
|
||||
private static final String KEY_PREFIX = "easyflow:agent:async-tool:";
|
||||
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AgentRuntimeProperties properties;
|
||||
|
||||
/**
|
||||
* 创建 Redis 任务存储。
|
||||
*
|
||||
* @param stringRedisTemplate Redis 字符串模板
|
||||
* @param objectMapper JSON mapper
|
||||
* @param properties Agent runtime 配置
|
||||
*/
|
||||
public RedisAgentAsyncToolTaskStore(StringRedisTemplate stringRedisTemplate,
|
||||
ObjectMapper objectMapper,
|
||||
AgentRuntimeProperties properties) {
|
||||
this.stringRedisTemplate = stringRedisTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public void create(AgentAsyncToolTaskRecord record) {
|
||||
if (record == null) {
|
||||
throw new BusinessException("异步工具任务不能为空");
|
||||
}
|
||||
String sessionId = requireText(record.getSessionId(), "异步工具任务 sessionId 不能为空");
|
||||
String taskId = requireText(record.getTaskId(), "异步工具任务 taskId 不能为空");
|
||||
record.setSessionScopedKey(key(sessionId, taskId));
|
||||
Duration ttl = taskTtl();
|
||||
record.setTtlSeconds(ttl.toSeconds());
|
||||
write(record, ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public Optional<AgentAsyncToolTaskRecord> get(String sessionId, String taskId) {
|
||||
String value = stringRedisTemplate.opsForValue().get(key(sessionId, taskId));
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(read(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public Optional<AgentAsyncToolTaskRecord> update(String sessionId,
|
||||
String taskId,
|
||||
UnaryOperator<AgentAsyncToolTaskRecord> updater) {
|
||||
Optional<AgentAsyncToolTaskRecord> existing = get(sessionId, taskId);
|
||||
if (existing.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
AgentAsyncToolTaskRecord updated = updater == null ? existing.get() : updater.apply(existing.get());
|
||||
if (updated == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
updated.setUpdatedAt(Instant.now());
|
||||
write(updated, remainingTtl(sessionId, taskId));
|
||||
return Optional.of(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public List<AgentAsyncToolTaskRecord> list(String sessionId, AsyncToolTaskStatus status) {
|
||||
String safeSessionId = requireText(sessionId, "异步工具任务 sessionId 不能为空");
|
||||
List<AgentAsyncToolTaskRecord> result = new ArrayList<>();
|
||||
ScanOptions options = ScanOptions.scanOptions().match(KEY_PREFIX + safeSessionId + ":*").count(100).build();
|
||||
try (Cursor<String> cursor = stringRedisTemplate.scan(options)) {
|
||||
while (cursor.hasNext()) {
|
||||
String key = cursor.next();
|
||||
String value = stringRedisTemplate.opsForValue().get(key);
|
||||
if (!StringUtils.hasText(value)) {
|
||||
continue;
|
||||
}
|
||||
AgentAsyncToolTaskRecord record = read(value);
|
||||
if (status == null || status == record.getStatus()) {
|
||||
result.add(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
result.sort(Comparator.comparing(AgentAsyncToolTaskRecord::getCreatedAt,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
return result;
|
||||
}
|
||||
|
||||
private void write(AgentAsyncToolTaskRecord record, Duration ttl) {
|
||||
try {
|
||||
stringRedisTemplate.opsForValue().set(record.getSessionScopedKey(),
|
||||
objectMapper.writeValueAsString(record), Math.max(1L, ttl.toSeconds()), TimeUnit.SECONDS);
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("写入异步工具任务状态失败:" + safeMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
private AgentAsyncToolTaskRecord read(String value) {
|
||||
try {
|
||||
return objectMapper.readValue(value, AgentAsyncToolTaskRecord.class);
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("读取异步工具任务状态失败:" + safeMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
private Duration remainingTtl(String sessionId, String taskId) {
|
||||
Long seconds = stringRedisTemplate.getExpire(key(sessionId, taskId), TimeUnit.SECONDS);
|
||||
if (seconds == null || seconds <= 0L) {
|
||||
return taskTtl();
|
||||
}
|
||||
return Duration.ofSeconds(seconds);
|
||||
}
|
||||
|
||||
private Duration taskTtl() {
|
||||
Duration ttl = properties == null ? Duration.ofHours(24) : properties.getAsyncToolTaskTtl();
|
||||
return ttl == null || ttl.isZero() || ttl.isNegative() ? Duration.ofHours(24) : ttl;
|
||||
}
|
||||
|
||||
private String key(String sessionId, String taskId) {
|
||||
return KEY_PREFIX
|
||||
+ requireText(sessionId, "异步工具任务 sessionId 不能为空")
|
||||
+ ":"
|
||||
+ requireText(taskId, "异步工具任务 taskId 不能为空");
|
||||
}
|
||||
|
||||
private String requireText(String value, String message) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
throw new BusinessException(message);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private String safeMessage(Exception e) {
|
||||
return e == null || e.getMessage() == null || e.getMessage().isBlank()
|
||||
? "未知错误"
|
||||
: e.getMessage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package tech.easyflow.agent.runtime.asynctool;
|
||||
|
||||
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.WorkflowToolExecutor;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Workflow 异步工具子能力实现。
|
||||
*/
|
||||
public class WorkflowAsyncSubTools extends AbstractAgentAsyncSubTools {
|
||||
|
||||
private final Workflow workflow;
|
||||
private final String toolName;
|
||||
private final String displayName;
|
||||
private final WorkflowToolExecutor workflowToolExecutor;
|
||||
|
||||
/**
|
||||
* 创建 Workflow 异步工具子能力。
|
||||
*
|
||||
* @param workflow 工作流快照
|
||||
* @param toolName runtime 工具名
|
||||
* @param displayName 用户可见名称
|
||||
* @param workflowToolExecutor Workflow 执行器
|
||||
* @param taskStore 任务存储
|
||||
* @param taskExecutor 后台执行器
|
||||
*/
|
||||
public WorkflowAsyncSubTools(Workflow workflow,
|
||||
String toolName,
|
||||
String displayName,
|
||||
WorkflowToolExecutor workflowToolExecutor,
|
||||
AgentAsyncToolTaskStore taskStore,
|
||||
ThreadPoolTaskExecutor taskExecutor) {
|
||||
super(taskStore, taskExecutor);
|
||||
this.workflow = workflow;
|
||||
this.toolName = toolName;
|
||||
this.displayName = displayName;
|
||||
this.workflowToolExecutor = workflowToolExecutor;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected String toolType() {
|
||||
return AgentToolType.WORKFLOW.name();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected String toolName() {
|
||||
return toolName;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected String displayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected String businessId() {
|
||||
return workflow == null || workflow.getId() == null ? null : String.valueOf(workflow.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
protected AgentToolExecutionResult executeBusiness(Map<String, Object> arguments) {
|
||||
return workflowToolExecutor.execute(workflow, arguments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package tech.easyflow.agent.runtime.event;
|
||||
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
||||
|
||||
/**
|
||||
* Agent 运行事件落库记录器。
|
||||
*/
|
||||
public interface AgentRunEventRecorder {
|
||||
|
||||
/**
|
||||
* 记录运行事件。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
* @param chatContext 聊天上下文
|
||||
* @param event 运行时事件
|
||||
*/
|
||||
void record(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package tech.easyflow.agent.runtime.event;
|
||||
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.agent.entity.AgentRunEventRecord;
|
||||
import tech.easyflow.agent.mapper.AgentRunEventRecordMapper;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeExtKeys;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* MySQL Agent 运行事件记录器。
|
||||
*/
|
||||
@Service
|
||||
public class MySqlAgentRunEventRecorder implements AgentRunEventRecorder {
|
||||
|
||||
private final AgentRunEventRecordMapper eventRecordMapper;
|
||||
|
||||
/**
|
||||
* 创建记录器。
|
||||
*
|
||||
* @param eventRecordMapper 事件 Mapper
|
||||
*/
|
||||
public MySqlAgentRunEventRecorder(AgentRunEventRecordMapper eventRecordMapper) {
|
||||
this.eventRecordMapper = eventRecordMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void record(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) {
|
||||
if (event == null || event.getEventType() == null || !shouldPersist(event.getEventType())) {
|
||||
return;
|
||||
}
|
||||
AgentRunEventRecord record = new AgentRunEventRecord();
|
||||
record.setTenantId(chatContext == null ? null : chatContext.getTenantId());
|
||||
record.setAgentId(resolveAgentId(event, chatContext));
|
||||
record.setChatSessionId(chatContext == null ? null : chatContext.getSessionId());
|
||||
record.setRoundId(resolveNumber(chatContext, ChatRuntimeExtKeys.CURRENT_ROUND_ID));
|
||||
record.setRoundNo(resolveInteger(chatContext, ChatRuntimeExtKeys.CURRENT_ROUND_NO));
|
||||
record.setVariantIndex(resolveInteger(chatContext, ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX, 1));
|
||||
record.setRequestId(firstText(requestId, stringValue(event.getPayload().get("requestId"))));
|
||||
record.setEventId(event.getEventId());
|
||||
record.setEventType(event.getEventType().name());
|
||||
record.setEventPhase(stringValue(event.getMetadata().get("phase")));
|
||||
record.setToolCallId(firstText(event.getToolCallId(), stringValue(event.getPayload().get("toolCallId"))));
|
||||
record.setPayloadJson(new LinkedHashMap<>(event.getPayload() == null ? Map.of() : event.getPayload()));
|
||||
record.setMetadataJson(new LinkedHashMap<>(event.getMetadata() == null ? Map.of() : event.getMetadata()));
|
||||
record.setCreated(toDate(event.getCreatedAt()));
|
||||
record.setCreatedBy(chatContext == null ? null : chatContext.getUserId());
|
||||
eventRecordMapper.insert(record);
|
||||
}
|
||||
|
||||
private boolean shouldPersist(AgentRuntimeEventType type) {
|
||||
return type != AgentRuntimeEventType.MESSAGE_DELTA
|
||||
&& type != AgentRuntimeEventType.REASONING_DELTA
|
||||
&& type != AgentRuntimeEventType.STARTED
|
||||
&& type != AgentRuntimeEventType.COMPLETED;
|
||||
}
|
||||
|
||||
private BigInteger resolveAgentId(AgentRuntimeEvent event, ChatRuntimeContext chatContext) {
|
||||
String agentId = firstText(event.getAgentId(), stringValue(event.getPayload().get("agentId")));
|
||||
if (agentId != null && !agentId.isBlank()) {
|
||||
try {
|
||||
return new BigInteger(agentId);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return chatContext == null ? null : chatContext.getAssistantId();
|
||||
}
|
||||
}
|
||||
return chatContext == null ? null : chatContext.getAssistantId();
|
||||
}
|
||||
|
||||
private BigInteger resolveNumber(ChatRuntimeContext context, String key) {
|
||||
Object value = context == null || context.getExt() == null ? null : context.getExt().get(key);
|
||||
if (value instanceof BigInteger bigInteger) {
|
||||
return bigInteger;
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return BigInteger.valueOf(number.longValue());
|
||||
}
|
||||
String text = stringValue(value);
|
||||
if (text == null || text.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new BigInteger(text);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Integer resolveInteger(ChatRuntimeContext context, String key) {
|
||||
return resolveInteger(context, key, null);
|
||||
}
|
||||
|
||||
private Integer resolveInteger(ChatRuntimeContext context, String key, Integer defaultValue) {
|
||||
Object value = context == null || context.getExt() == null ? null : context.getExt().get(key);
|
||||
if (value instanceof Number number) {
|
||||
return number.intValue();
|
||||
}
|
||||
String text = stringValue(value);
|
||||
if (text == null || text.isBlank()) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(text);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private Date toDate(Instant instant) {
|
||||
return instant == null ? new Date() : Date.from(instant);
|
||||
}
|
||||
|
||||
private String firstText(String left, String right) {
|
||||
return left != null && !left.isBlank() ? left : right;
|
||||
}
|
||||
|
||||
private String stringValue(Object value) {
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package tech.easyflow.agent.runtime.hitl;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.agent.entity.AgentHitlPending;
|
||||
import tech.easyflow.common.cache.DistributedScheduledLock;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Agent 工具审批 pending 过期清理任务。
|
||||
*/
|
||||
@Component
|
||||
public class AgentHitlPendingExpirationTask {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AgentHitlPendingExpirationTask.class);
|
||||
private static final int BATCH_SIZE = 100;
|
||||
|
||||
private final AgentHitlPendingService pendingService;
|
||||
|
||||
/**
|
||||
* 创建任务。
|
||||
*
|
||||
* @param pendingService pending 服务
|
||||
*/
|
||||
public AgentHitlPendingExpirationTask(AgentHitlPendingService pendingService) {
|
||||
this.pendingService = pendingService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 定期将超时 pending 标记为 EXPIRED。
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "${easyflow.agent.runtime.hitl-expire-scan-delay:60000}", initialDelay = 60000L)
|
||||
@DistributedScheduledLock(key = "easyflow:schedule:agent-hitl:expire-pending", leaseSeconds = 300L)
|
||||
public void expirePending() {
|
||||
try {
|
||||
List<AgentHitlPending> expired = pendingService.expirePending(BATCH_SIZE);
|
||||
if (!expired.isEmpty()) {
|
||||
LOG.info("Expired Agent HITL pending records, count={}", expired.size());
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
LOG.warn("Expire Agent HITL pending records failed, message={}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package tech.easyflow.agent.runtime.hitl;
|
||||
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||
import tech.easyflow.agent.entity.AgentHitlPending;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Agent 工具审批 pending 持久化服务。
|
||||
*/
|
||||
public interface AgentHitlPendingService {
|
||||
|
||||
/**
|
||||
* 从 TOOL_APPROVAL_REQUIRED 事件创建或刷新 pending。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
* @param chatContext 聊天上下文
|
||||
* @param event 运行时事件
|
||||
*/
|
||||
void recordApprovalRequired(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event);
|
||||
|
||||
/**
|
||||
* 批准并原子消费 pending。
|
||||
*
|
||||
* @param resumeToken 恢复令牌
|
||||
* @param operatorId 操作人 ID
|
||||
* @return pending 记录
|
||||
*/
|
||||
AgentHitlPending approve(String resumeToken, BigInteger operatorId);
|
||||
|
||||
/**
|
||||
* 拒绝并原子消费 pending。
|
||||
*
|
||||
* @param resumeToken 恢复令牌
|
||||
* @param operatorId 操作人 ID
|
||||
* @param reason 拒绝原因
|
||||
* @return pending 记录
|
||||
*/
|
||||
AgentHitlPending reject(String resumeToken, BigInteger operatorId, String reason);
|
||||
|
||||
/**
|
||||
* 取消指定请求的 pending。
|
||||
*
|
||||
* @param requestId 请求 ID
|
||||
* @param reason 取消原因
|
||||
*/
|
||||
void cancelByRequestId(String requestId, String reason);
|
||||
|
||||
/**
|
||||
* 删除指定聊天会话的 pending。
|
||||
*
|
||||
* @param chatSessionId 聊天会话 ID
|
||||
*/
|
||||
void deleteByChatSessionId(BigInteger chatSessionId);
|
||||
|
||||
/**
|
||||
* 删除指定运行会话的 pending。
|
||||
*
|
||||
* @param runtimeSessionId 运行会话 ID
|
||||
*/
|
||||
void deleteByRuntimeSessionId(String runtimeSessionId);
|
||||
|
||||
/**
|
||||
* 过期 pending 并返回被过期的记录。
|
||||
*
|
||||
* @param limit 每批数量
|
||||
* @return 过期记录
|
||||
*/
|
||||
List<AgentHitlPending> expirePending(int limit);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package tech.easyflow.agent.runtime.hitl;
|
||||
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import tech.easyflow.agent.config.AgentRuntimeProperties;
|
||||
import tech.easyflow.agent.entity.AgentHitlPending;
|
||||
import tech.easyflow.agent.mapper.AgentHitlPendingMapper;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Agent 工具审批 pending 持久化服务实现。
|
||||
*/
|
||||
@Service
|
||||
public class AgentHitlPendingServiceImpl implements AgentHitlPendingService {
|
||||
|
||||
private final AgentHitlPendingMapper pendingMapper;
|
||||
private final AgentRuntimeProperties properties;
|
||||
|
||||
/**
|
||||
* 创建服务。
|
||||
*
|
||||
* @param pendingMapper pending Mapper
|
||||
* @param properties Agent 运行态配置
|
||||
*/
|
||||
public AgentHitlPendingServiceImpl(AgentHitlPendingMapper pendingMapper,
|
||||
AgentRuntimeProperties properties) {
|
||||
this.pendingMapper = pendingMapper;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void recordApprovalRequired(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) {
|
||||
if (event == null || event.getPayload() == null) {
|
||||
return;
|
||||
}
|
||||
String resumeToken = stringValue(event.getPayload().get("resumeToken"));
|
||||
if (!StringUtils.hasText(resumeToken)) {
|
||||
return;
|
||||
}
|
||||
AgentHitlPending pending = findByToken(resumeToken);
|
||||
Date now = new Date();
|
||||
if (pending == null) {
|
||||
pending = new AgentHitlPending();
|
||||
pending.setResumeToken(resumeToken);
|
||||
pending.setCreated(now);
|
||||
pending.setCreatedBy(chatContext == null ? null : chatContext.getUserId());
|
||||
pending.setIsDeleted(0);
|
||||
}
|
||||
pending.setTenantId(chatContext == null ? null : chatContext.getTenantId());
|
||||
pending.setAgentId(resolveAgentId(event, chatContext));
|
||||
pending.setChatSessionId(chatContext == null ? null : chatContext.getSessionId());
|
||||
pending.setRuntimeSessionId(firstText(event.getSessionId(), stringValue(event.getPayload().get("sessionId"))));
|
||||
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.setStatus(AgentHitlPendingStatus.PENDING.name());
|
||||
pending.setExpiresAt(resolveExpiresAt(event));
|
||||
pending.setMetadataJson(metadata(event));
|
||||
pending.setModified(now);
|
||||
pending.setModifiedBy(chatContext == null ? null : chatContext.getUserId());
|
||||
pendingMapper.insertOrUpdate(pending);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AgentHitlPending approve(String resumeToken, BigInteger operatorId) {
|
||||
return consume(resumeToken, operatorId, AgentHitlPendingStatus.APPROVED, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AgentHitlPending reject(String resumeToken, BigInteger operatorId, String reason) {
|
||||
return consume(resumeToken, operatorId, AgentHitlPendingStatus.REJECTED, reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancelByRequestId(String requestId, String reason) {
|
||||
if (!StringUtils.hasText(requestId)) {
|
||||
return;
|
||||
}
|
||||
List<AgentHitlPending> records = pendingMapper.selectListByQuery(QueryWrapper.create()
|
||||
.eq("request_id", requestId)
|
||||
.eq("status", AgentHitlPendingStatus.PENDING.name())
|
||||
.eq("is_deleted", 0));
|
||||
Date now = new Date();
|
||||
for (AgentHitlPending record : records) {
|
||||
record.setStatus(AgentHitlPendingStatus.CANCELLED.name());
|
||||
record.setRejectReason(reason);
|
||||
record.setConsumedAt(now);
|
||||
record.setModified(now);
|
||||
pendingMapper.update(record);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteByChatSessionId(BigInteger chatSessionId) {
|
||||
if (chatSessionId == null) {
|
||||
return;
|
||||
}
|
||||
List<AgentHitlPending> records = pendingMapper.selectListByQuery(QueryWrapper.create()
|
||||
.eq("chat_session_id", chatSessionId)
|
||||
.eq("is_deleted", 0));
|
||||
softDelete(records);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteByRuntimeSessionId(String runtimeSessionId) {
|
||||
if (!StringUtils.hasText(runtimeSessionId)) {
|
||||
return;
|
||||
}
|
||||
List<AgentHitlPending> records = pendingMapper.selectListByQuery(QueryWrapper.create()
|
||||
.eq("runtime_session_id", runtimeSessionId)
|
||||
.eq("is_deleted", 0));
|
||||
softDelete(records);
|
||||
}
|
||||
|
||||
private void softDelete(List<AgentHitlPending> records) {
|
||||
if (records == null || records.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Date now = new Date();
|
||||
for (AgentHitlPending record : records) {
|
||||
record.setIsDeleted(1);
|
||||
record.setModified(now);
|
||||
pendingMapper.update(record);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public List<AgentHitlPending> expirePending(int limit) {
|
||||
List<AgentHitlPending> records = pendingMapper.selectListByQuery(QueryWrapper.create()
|
||||
.eq("status", AgentHitlPendingStatus.PENDING.name())
|
||||
.eq("is_deleted", 0)
|
||||
.le("expires_at", new Date())
|
||||
.limit(Math.max(1, limit)));
|
||||
Date now = new Date();
|
||||
for (AgentHitlPending record : records) {
|
||||
record.setStatus(AgentHitlPendingStatus.EXPIRED.name());
|
||||
record.setRejectReason("审批超时,已自动拒绝");
|
||||
record.setConsumedAt(now);
|
||||
record.setModified(now);
|
||||
pendingMapper.update(record);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
private AgentHitlPending consume(String resumeToken,
|
||||
BigInteger operatorId,
|
||||
AgentHitlPendingStatus targetStatus,
|
||||
String reason) {
|
||||
if (!StringUtils.hasText(resumeToken)) {
|
||||
throw new BusinessException("Agent 恢复令牌不能为空");
|
||||
}
|
||||
AgentHitlPending pending = findByToken(resumeToken);
|
||||
if (pending == null || Integer.valueOf(1).equals(pending.getIsDeleted())) {
|
||||
throw new BusinessException("Agent 审批请求不存在或已失效");
|
||||
}
|
||||
if (!AgentHitlPendingStatus.PENDING.name().equals(pending.getStatus())) {
|
||||
throw new BusinessException("Agent 审批请求已处理");
|
||||
}
|
||||
if (pending.getExpiresAt() != null && pending.getExpiresAt().before(new Date())) {
|
||||
markConsumed(pending, operatorId, AgentHitlPendingStatus.EXPIRED, "审批超时,已自动拒绝");
|
||||
throw new BusinessException("Agent 审批请求已过期");
|
||||
}
|
||||
if (!markConsumed(pending, operatorId, targetStatus, reason)) {
|
||||
throw new BusinessException("Agent 审批请求已处理");
|
||||
}
|
||||
pending.setStatus(targetStatus.name());
|
||||
pending.setRejectReason(reason);
|
||||
pending.setConsumedAt(new Date());
|
||||
pending.setModifiedBy(operatorId);
|
||||
return pending;
|
||||
}
|
||||
|
||||
private boolean markConsumed(AgentHitlPending pending,
|
||||
BigInteger operatorId,
|
||||
AgentHitlPendingStatus targetStatus,
|
||||
String reason) {
|
||||
Date now = new Date();
|
||||
AgentHitlPending update = new AgentHitlPending();
|
||||
update.setStatus(targetStatus.name());
|
||||
update.setRejectReason(reason);
|
||||
update.setConsumedAt(now);
|
||||
update.setModified(now);
|
||||
update.setModifiedBy(operatorId);
|
||||
// 用 status=PENDING 作为消费条件,避免两个审批请求同时把同一个 token 消费两次。
|
||||
return pendingMapper.updateByQuery(update, QueryWrapper.create()
|
||||
.eq("id", pending.getId())
|
||||
.eq("status", AgentHitlPendingStatus.PENDING.name())
|
||||
.eq("is_deleted", 0)) > 0;
|
||||
}
|
||||
|
||||
private AgentHitlPending findByToken(String resumeToken) {
|
||||
return pendingMapper.selectOneByQuery(QueryWrapper.create()
|
||||
.eq("resume_token", resumeToken)
|
||||
.eq("is_deleted", 0)
|
||||
.limit(1));
|
||||
}
|
||||
|
||||
private BigInteger resolveAgentId(AgentRuntimeEvent event, ChatRuntimeContext chatContext) {
|
||||
String agentId = firstText(event.getAgentId(), stringValue(event.getPayload().get("agentId")));
|
||||
if (StringUtils.hasText(agentId)) {
|
||||
try {
|
||||
return new BigInteger(agentId);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return chatContext == null ? null : chatContext.getAssistantId();
|
||||
}
|
||||
}
|
||||
return chatContext == null ? null : chatContext.getAssistantId();
|
||||
}
|
||||
|
||||
private Map<String, Object> metadata(AgentRuntimeEvent event) {
|
||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
metadata.putAll(event.getMetadata() == null ? Map.of() : event.getMetadata());
|
||||
Object approvalMetadata = event.getPayload().get("approvalMetadata");
|
||||
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<>();
|
||||
}
|
||||
|
||||
private Date dateValue(Object value) {
|
||||
if (value instanceof Date date) {
|
||||
return date;
|
||||
}
|
||||
String text = stringValue(value);
|
||||
if (!StringUtils.hasText(text)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Date.from(Instant.parse(text));
|
||||
} catch (RuntimeException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Date resolveExpiresAt(AgentRuntimeEvent event) {
|
||||
Date eventExpiresAt = dateValue(event.getPayload().get("expiresAt"));
|
||||
if (eventExpiresAt != null) {
|
||||
return eventExpiresAt;
|
||||
}
|
||||
return Date.from(Instant.now().plus(properties.getHitlPendingTimeout()));
|
||||
}
|
||||
|
||||
private Object firstNonNull(Object left, Object right) {
|
||||
return left == null ? right : left;
|
||||
}
|
||||
|
||||
private String firstText(String left, String right) {
|
||||
return StringUtils.hasText(left) ? left : right;
|
||||
}
|
||||
|
||||
private String stringValue(Object value) {
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package tech.easyflow.agent.runtime.hitl;
|
||||
|
||||
/**
|
||||
* Agent HITL pending 状态。
|
||||
*/
|
||||
public enum AgentHitlPendingStatus {
|
||||
/**
|
||||
* 等待审批。
|
||||
*/
|
||||
PENDING,
|
||||
|
||||
/**
|
||||
* 已批准。
|
||||
*/
|
||||
APPROVED,
|
||||
|
||||
/**
|
||||
* 已拒绝。
|
||||
*/
|
||||
REJECTED,
|
||||
|
||||
/**
|
||||
* 已过期。
|
||||
*/
|
||||
EXPIRED,
|
||||
|
||||
/**
|
||||
* 已取消。
|
||||
*/
|
||||
CANCELLED
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package tech.easyflow.agent.runtime.lock;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* Agent 会话级运行锁。
|
||||
*/
|
||||
public interface AgentRunLock {
|
||||
|
||||
/**
|
||||
* 获取指定 Agent 会话的运行锁。
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
* @param sessionId 运行时会话 ID
|
||||
* @return 锁句柄
|
||||
*/
|
||||
Handle acquire(BigInteger agentId, String sessionId);
|
||||
|
||||
/**
|
||||
* Agent 运行锁句柄。
|
||||
*/
|
||||
interface Handle extends AutoCloseable {
|
||||
|
||||
/**
|
||||
* 续期锁。
|
||||
*
|
||||
* @return 续期成功时为 true
|
||||
*/
|
||||
boolean renew();
|
||||
|
||||
/**
|
||||
* 释放锁。
|
||||
*/
|
||||
void release();
|
||||
|
||||
@Override
|
||||
default void close() {
|
||||
release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package tech.easyflow.agent.runtime.lock;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.agent.config.AgentRuntimeProperties;
|
||||
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* 基于 Redis 的 Agent 会话级运行锁。
|
||||
*/
|
||||
@Component
|
||||
public class RedisAgentRunLock implements AgentRunLock {
|
||||
|
||||
private static final String LOCK_PREFIX = "easyflow:agent:run:";
|
||||
private static final ScheduledExecutorService RENEW_EXECUTOR = Executors.newSingleThreadScheduledExecutor(task -> {
|
||||
Thread thread = new Thread(task, "agent-run-lock-renew");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
|
||||
private final RedisLockExecutor redisLockExecutor;
|
||||
private final AgentRuntimeProperties properties;
|
||||
|
||||
/**
|
||||
* 创建 Redis Agent 运行锁。
|
||||
*
|
||||
* @param redisLockExecutor Redis 锁执行器
|
||||
* @param properties Agent 运行态配置
|
||||
*/
|
||||
public RedisAgentRunLock(RedisLockExecutor redisLockExecutor, AgentRuntimeProperties properties) {
|
||||
this.redisLockExecutor = redisLockExecutor;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Handle acquire(BigInteger agentId, String sessionId) {
|
||||
try {
|
||||
RedisLockExecutor.LockHandle handle = redisLockExecutor.acquire(
|
||||
lockKey(agentId, sessionId),
|
||||
properties.getLockWaitTimeout(),
|
||||
properties.getLockLeaseTimeout());
|
||||
return new RedisHandle(handle, scheduleRenew(handle));
|
||||
} catch (IllegalStateException e) {
|
||||
throw new BusinessException("当前 Agent 会话正在运行,请稍后再试");
|
||||
}
|
||||
}
|
||||
|
||||
private ScheduledFuture<?> scheduleRenew(RedisLockExecutor.LockHandle handle) {
|
||||
long intervalMillis = Math.max(1000L, properties.getLockRenewInterval().toMillis());
|
||||
return RENEW_EXECUTOR.scheduleAtFixedRate(handle::renew, intervalMillis, intervalMillis, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
private String lockKey(BigInteger agentId, String sessionId) {
|
||||
return LOCK_PREFIX + "agent:" + (agentId == null ? "unknown" : agentId)
|
||||
+ ":session:" + (sessionId == null ? "unknown" : sessionId);
|
||||
}
|
||||
|
||||
private static final class RedisHandle implements Handle {
|
||||
|
||||
private final RedisLockExecutor.LockHandle delegate;
|
||||
private final ScheduledFuture<?> renewTask;
|
||||
private final AtomicBoolean released = new AtomicBoolean(false);
|
||||
|
||||
private RedisHandle(RedisLockExecutor.LockHandle delegate, ScheduledFuture<?> renewTask) {
|
||||
this.delegate = delegate;
|
||||
this.renewTask = renewTask;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean renew() {
|
||||
return delegate.renew();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void release() {
|
||||
if (!released.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
if (renewTask != null) {
|
||||
renewTask.cancel(false);
|
||||
}
|
||||
delegate.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package tech.easyflow.agent.runtime.session;
|
||||
|
||||
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
|
||||
import io.agentscope.core.state.State;
|
||||
import io.agentscope.core.util.JsonUtils;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import tech.easyflow.agent.config.AgentRuntimeProperties;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Agent 草稿试运行 Redis-only session store。
|
||||
*/
|
||||
@Service
|
||||
public class DraftAgentSessionStore implements AgentSessionStore {
|
||||
|
||||
private static final String REDIS_PREFIX = "easyflow:agent:draft-session:";
|
||||
private static final String ENVELOPE_VERSION = "1";
|
||||
private static final String SINGLE_STATES = "singleStates";
|
||||
private static final String LIST_STATES = "listStates";
|
||||
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final AgentRuntimeProperties properties;
|
||||
|
||||
/**
|
||||
* 创建草稿试运行 session store。
|
||||
*
|
||||
* @param stringRedisTemplate Redis 模板
|
||||
* @param properties Agent 运行态配置
|
||||
*/
|
||||
public DraftAgentSessionStore(StringRedisTemplate stringRedisTemplate,
|
||||
AgentRuntimeProperties properties) {
|
||||
this.stringRedisTemplate = stringRedisTemplate;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存单个状态项。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
* @param name 状态名称
|
||||
* @param state 状态值
|
||||
*/
|
||||
@Override
|
||||
public void save(String sessionKey, String name, State state) {
|
||||
if (!StringUtils.hasText(sessionKey) || !StringUtils.hasText(name) || state == null) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> envelope = loadEnvelope(sessionKey);
|
||||
singleStates(envelope).put(name, JsonUtils.getJsonCodec().toJson(state));
|
||||
writeCache(sessionKey, envelope);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存状态列表。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
* @param name 状态名称
|
||||
* @param states 状态列表
|
||||
*/
|
||||
@Override
|
||||
public void saveList(String sessionKey, String name, List<? extends State> states) {
|
||||
if (!StringUtils.hasText(sessionKey) || !StringUtils.hasText(name)) {
|
||||
return;
|
||||
}
|
||||
List<String> values = new ArrayList<>();
|
||||
if (states != null) {
|
||||
for (State state : states) {
|
||||
values.add(JsonUtils.getJsonCodec().toJson(state));
|
||||
}
|
||||
}
|
||||
Map<String, Object> envelope = loadEnvelope(sessionKey);
|
||||
listStates(envelope).put(name, values);
|
||||
writeCache(sessionKey, envelope);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个状态项。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
* @param name 状态名称
|
||||
* @param type 状态类型
|
||||
* @param <T> 状态类型
|
||||
* @return 可选状态
|
||||
*/
|
||||
@Override
|
||||
public <T extends State> Optional<T> get(String sessionKey, String name, Class<T> type) {
|
||||
if (!StringUtils.hasText(sessionKey) || !StringUtils.hasText(name) || type == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Object json = singleStates(loadEnvelope(sessionKey)).get(name);
|
||||
if (!(json instanceof String text) || text.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(JsonUtils.getJsonCodec().fromJson(text, type));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态列表。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
* @param name 状态名称
|
||||
* @param itemType 状态元素类型
|
||||
* @param <T> 状态元素类型
|
||||
* @return 状态列表
|
||||
*/
|
||||
@Override
|
||||
public <T extends State> List<T> getList(String sessionKey, String name, Class<T> itemType) {
|
||||
if (!StringUtils.hasText(sessionKey) || !StringUtils.hasText(name) || itemType == null) {
|
||||
return List.of();
|
||||
}
|
||||
Object raw = listStates(loadEnvelope(sessionKey)).get(name);
|
||||
if (!(raw instanceof List<?> values) || values.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<T> result = new ArrayList<>();
|
||||
for (Object value : values) {
|
||||
if (value instanceof String text && !text.isBlank()) {
|
||||
result.add(JsonUtils.getJsonCodec().fromJson(text, itemType));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断会话键是否存在。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
* @return 存在时为 true
|
||||
*/
|
||||
@Override
|
||||
public boolean exists(String sessionKey) {
|
||||
return StringUtils.hasText(sessionKey) && readCache(sessionKey) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定会话键下的全部状态。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
*/
|
||||
@Override
|
||||
public void delete(String sessionKey) {
|
||||
if (!StringUtils.hasText(sessionKey)) {
|
||||
return;
|
||||
}
|
||||
deleteCache(sessionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出当前存储中的会话键。
|
||||
*
|
||||
* <p>草稿 session 使用哈希 Redis key,不维护反向索引,避免为试运行引入额外持久化状态。</p>
|
||||
*
|
||||
* @return 空集合
|
||||
*/
|
||||
@Override
|
||||
public Set<String> listSessionKeys() {
|
||||
return new LinkedHashSet<>();
|
||||
}
|
||||
|
||||
private Map<String, Object> loadEnvelope(String sessionKey) {
|
||||
Map<String, Object> cached = readCache(sessionKey);
|
||||
return cached == null ? emptyEnvelope() : deepCopy(cached);
|
||||
}
|
||||
|
||||
private Map<String, Object> emptyEnvelope() {
|
||||
Map<String, Object> envelope = new LinkedHashMap<>();
|
||||
envelope.put("version", ENVELOPE_VERSION);
|
||||
envelope.put(SINGLE_STATES, new LinkedHashMap<String, Object>());
|
||||
envelope.put(LIST_STATES, new LinkedHashMap<String, Object>());
|
||||
return envelope;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> singleStates(Map<String, Object> envelope) {
|
||||
return (Map<String, Object>) envelope.computeIfAbsent(SINGLE_STATES, key -> new LinkedHashMap<String, Object>());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> listStates(Map<String, Object> envelope) {
|
||||
return (Map<String, Object>) envelope.computeIfAbsent(LIST_STATES, key -> new LinkedHashMap<String, Object>());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> readCache(String sessionKey) {
|
||||
try {
|
||||
String value = stringRedisTemplate.opsForValue().get(cacheKey(sessionKey));
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return null;
|
||||
}
|
||||
return JsonUtils.getJsonCodec().fromJson(value, Map.class);
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void writeCache(String sessionKey, Map<String, Object> envelope) {
|
||||
long seconds = Math.max(1L, properties.getSessionCacheTtl().toSeconds());
|
||||
stringRedisTemplate.opsForValue().set(cacheKey(sessionKey), JsonUtils.getJsonCodec().toJson(envelope),
|
||||
seconds, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private void deleteCache(String sessionKey) {
|
||||
stringRedisTemplate.delete(cacheKey(sessionKey));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> deepCopy(Map<String, Object> source) {
|
||||
if (source == null || source.isEmpty()) {
|
||||
return emptyEnvelope();
|
||||
}
|
||||
return JsonUtils.getJsonCodec().fromJson(JsonUtils.getJsonCodec().toJson(source), Map.class);
|
||||
}
|
||||
|
||||
private String cacheKey(String sessionKey) {
|
||||
return REDIS_PREFIX + hash(sessionKey);
|
||||
}
|
||||
|
||||
private String hash(String value) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
return value.replace(':', '_');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package tech.easyflow.agent.runtime.session;
|
||||
|
||||
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import io.agentscope.core.state.State;
|
||||
import io.agentscope.core.util.JsonUtils;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import tech.easyflow.agent.config.AgentRuntimeProperties;
|
||||
import tech.easyflow.agent.entity.AgentSession;
|
||||
import tech.easyflow.agent.mapper.AgentSessionMapper;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* session store 持久化实现
|
||||
*/
|
||||
@Service
|
||||
public class EasyFlowAgentSessionStore implements AgentSessionStore {
|
||||
|
||||
private static final String REDIS_PREFIX = "easyflow:agent:session:";
|
||||
private static final String ENVELOPE_VERSION = "1";
|
||||
private static final String SINGLE_STATES = "singleStates";
|
||||
private static final String LIST_STATES = "listStates";
|
||||
|
||||
private final AgentSessionMapper agentSessionMapper;
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final AgentRuntimeProperties properties;
|
||||
|
||||
/**
|
||||
* 创建 EasyFlow Agent session store。
|
||||
*
|
||||
* @param agentSessionMapper session Mapper
|
||||
* @param stringRedisTemplate Redis 模板
|
||||
* @param properties Agent 运行态配置
|
||||
*/
|
||||
public EasyFlowAgentSessionStore(AgentSessionMapper agentSessionMapper,
|
||||
StringRedisTemplate stringRedisTemplate,
|
||||
AgentRuntimeProperties properties) {
|
||||
this.agentSessionMapper = agentSessionMapper;
|
||||
this.stringRedisTemplate = stringRedisTemplate;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定业务会话元信息。
|
||||
*
|
||||
* <p>AgentScope Session API 不会把 EasyFlow 的 {@code agentId/chatSessionId/tenantId} 传入
|
||||
* {@code save(...)},因此运行入口必须先调用本方法建立或刷新元信息。后续 state 写入会复用
|
||||
* 这些字段,避免表里只剩裸 sessionKey。</p>
|
||||
*
|
||||
* @param metadata 业务会话元信息
|
||||
*/
|
||||
public void bindSession(AgentSessionMetadata metadata) {
|
||||
if (metadata == null || !StringUtils.hasText(metadata.sessionKey())) {
|
||||
return;
|
||||
}
|
||||
AgentSession session = findBySessionKey(metadata.sessionKey());
|
||||
Date now = new Date();
|
||||
if (session == null) {
|
||||
session = new AgentSession();
|
||||
session.setTenantId(metadata.tenantId());
|
||||
session.setAgentId(metadata.agentId());
|
||||
session.setChatSessionId(metadata.chatSessionId());
|
||||
session.setRuntimeSessionId(metadata.runtimeSessionId());
|
||||
session.setSessionKey(metadata.sessionKey());
|
||||
session.setStateJson(emptyEnvelope());
|
||||
session.setVersion(0L);
|
||||
session.setCacheVersion(0L);
|
||||
session.setCreated(now);
|
||||
session.setCreatedBy(metadata.operatorId());
|
||||
session.setIsDeleted(0);
|
||||
}
|
||||
session.setTenantId(firstNonNull(metadata.tenantId(), session.getTenantId()));
|
||||
session.setAgentId(firstNonNull(metadata.agentId(), session.getAgentId()));
|
||||
session.setChatSessionId(firstNonNull(metadata.chatSessionId(), session.getChatSessionId()));
|
||||
session.setRuntimeSessionId(firstText(metadata.runtimeSessionId(), session.getRuntimeSessionId()));
|
||||
session.setLastAccessAt(now);
|
||||
session.setModified(now);
|
||||
session.setModifiedBy(metadata.operatorId());
|
||||
agentSessionMapper.insertOrUpdate(session);
|
||||
writeCache(session.getSessionKey(), session.getStateJson());
|
||||
}
|
||||
|
||||
/**
|
||||
* 按聊天会话清理 AgentScope session。
|
||||
*
|
||||
* @param chatSessionId 聊天会话 ID
|
||||
*/
|
||||
public void deleteByChatSessionId(BigInteger chatSessionId) {
|
||||
if (chatSessionId == null) {
|
||||
return;
|
||||
}
|
||||
List<AgentSession> sessions = agentSessionMapper.selectListByQuery(QueryWrapper.create()
|
||||
.eq("chat_session_id", chatSessionId)
|
||||
.eq("is_deleted", 0));
|
||||
Date now = new Date();
|
||||
for (AgentSession session : sessions) {
|
||||
session.setIsDeleted(1);
|
||||
session.setModified(now);
|
||||
agentSessionMapper.update(session);
|
||||
deleteCache(session.getSessionKey());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(String sessionKey, String name, State state) {
|
||||
if (!StringUtils.hasText(sessionKey) || !StringUtils.hasText(name) || state == null) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> envelope = loadEnvelope(sessionKey);
|
||||
singleStates(envelope).put(name, JsonUtils.getJsonCodec().toJson(state));
|
||||
persistEnvelope(sessionKey, envelope);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveList(String sessionKey, String name, List<? extends State> states) {
|
||||
if (!StringUtils.hasText(sessionKey) || !StringUtils.hasText(name)) {
|
||||
return;
|
||||
}
|
||||
List<String> values = new ArrayList<>();
|
||||
if (states != null) {
|
||||
for (State state : states) {
|
||||
values.add(JsonUtils.getJsonCodec().toJson(state));
|
||||
}
|
||||
}
|
||||
Map<String, Object> envelope = loadEnvelope(sessionKey);
|
||||
listStates(envelope).put(name, values);
|
||||
persistEnvelope(sessionKey, envelope);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T extends State> Optional<T> get(String sessionKey, String name, Class<T> type) {
|
||||
if (!StringUtils.hasText(sessionKey) || !StringUtils.hasText(name) || type == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Object json = singleStates(loadEnvelope(sessionKey)).get(name);
|
||||
if (!(json instanceof String text) || text.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(JsonUtils.getJsonCodec().fromJson(text, type));
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T extends State> List<T> getList(String sessionKey, String name, Class<T> itemType) {
|
||||
if (!StringUtils.hasText(sessionKey) || !StringUtils.hasText(name) || itemType == null) {
|
||||
return List.of();
|
||||
}
|
||||
Object raw = listStates(loadEnvelope(sessionKey)).get(name);
|
||||
if (!(raw instanceof List<?> values) || values.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<T> result = new ArrayList<>();
|
||||
for (Object value : values) {
|
||||
if (value instanceof String text && !text.isBlank()) {
|
||||
result.add(JsonUtils.getJsonCodec().fromJson(text, itemType));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String sessionKey) {
|
||||
if (!StringUtils.hasText(sessionKey)) {
|
||||
return false;
|
||||
}
|
||||
if (readCache(sessionKey) != null) {
|
||||
return true;
|
||||
}
|
||||
return findBySessionKey(sessionKey) != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String sessionKey) {
|
||||
if (!StringUtils.hasText(sessionKey)) {
|
||||
return;
|
||||
}
|
||||
AgentSession session = findBySessionKey(sessionKey);
|
||||
if (session != null) {
|
||||
session.setIsDeleted(1);
|
||||
session.setModified(new Date());
|
||||
agentSessionMapper.update(session);
|
||||
}
|
||||
deleteCache(sessionKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> listSessionKeys() {
|
||||
List<AgentSession> sessions = agentSessionMapper.selectListByQuery(QueryWrapper.create()
|
||||
.eq("is_deleted", 0)
|
||||
.select("session_key"));
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
for (AgentSession session : sessions) {
|
||||
if (StringUtils.hasText(session.getSessionKey())) {
|
||||
keys.add(session.getSessionKey());
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private void persistEnvelope(String sessionKey, Map<String, Object> envelope) {
|
||||
AgentSession session = findBySessionKey(sessionKey);
|
||||
Date now = new Date();
|
||||
if (session == null) {
|
||||
session = new AgentSession();
|
||||
session.setRuntimeSessionId(sessionKey);
|
||||
session.setSessionKey(sessionKey);
|
||||
session.setCreated(now);
|
||||
session.setVersion(0L);
|
||||
session.setCacheVersion(0L);
|
||||
session.setIsDeleted(0);
|
||||
}
|
||||
long nextVersion = session.getVersion() == null ? 1L : session.getVersion() + 1L;
|
||||
session.setStateJson(envelope);
|
||||
session.setVersion(nextVersion);
|
||||
session.setCacheVersion(nextVersion);
|
||||
session.setLastAccessAt(now);
|
||||
session.setModified(now);
|
||||
session.setIsDeleted(0);
|
||||
// 同步写会话状态
|
||||
agentSessionMapper.insertOrUpdate(session);
|
||||
// 同步写缓存
|
||||
writeCache(sessionKey, envelope);
|
||||
}
|
||||
|
||||
private Map<String, Object> loadEnvelope(String sessionKey) {
|
||||
Map<String, Object> cached = readCache(sessionKey);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
AgentSession session = findBySessionKey(sessionKey);
|
||||
if (session == null || session.getStateJson() == null || session.getStateJson().isEmpty()) {
|
||||
return emptyEnvelope();
|
||||
}
|
||||
writeCache(sessionKey, session.getStateJson());
|
||||
return deepCopy(session.getStateJson());
|
||||
}
|
||||
|
||||
private AgentSession findBySessionKey(String sessionKey) {
|
||||
return agentSessionMapper.selectOneByQuery(QueryWrapper.create()
|
||||
.eq("session_key", sessionKey)
|
||||
.eq("is_deleted", 0)
|
||||
.limit(1));
|
||||
}
|
||||
|
||||
private Map<String, Object> emptyEnvelope() {
|
||||
Map<String, Object> envelope = new LinkedHashMap<>();
|
||||
envelope.put("version", ENVELOPE_VERSION);
|
||||
envelope.put(SINGLE_STATES, new LinkedHashMap<String, Object>());
|
||||
envelope.put(LIST_STATES, new LinkedHashMap<String, Object>());
|
||||
return envelope;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> singleStates(Map<String, Object> envelope) {
|
||||
return (Map<String, Object>) envelope.computeIfAbsent(SINGLE_STATES, key -> new LinkedHashMap<String, Object>());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> listStates(Map<String, Object> envelope) {
|
||||
return (Map<String, Object>) envelope.computeIfAbsent(LIST_STATES, key -> new LinkedHashMap<String, Object>());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> readCache(String sessionKey) {
|
||||
try {
|
||||
String value = stringRedisTemplate.opsForValue().get(cacheKey(sessionKey));
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return null;
|
||||
}
|
||||
return JsonUtils.getJsonCodec().fromJson(value, Map.class);
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void writeCache(String sessionKey, Map<String, Object> envelope) {
|
||||
try {
|
||||
long seconds = Math.max(1L, properties.getSessionCacheTtl().toSeconds());
|
||||
stringRedisTemplate.opsForValue().set(cacheKey(sessionKey), JsonUtils.getJsonCodec().toJson(envelope),
|
||||
seconds, TimeUnit.SECONDS);
|
||||
} catch (RuntimeException e) {
|
||||
// MySQL 是 AgentScope session 的真相源。Redis 只做热缓存,写缓存失败不能掩盖已完成的持久化写入。
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteCache(String sessionKey) {
|
||||
try {
|
||||
stringRedisTemplate.delete(cacheKey(sessionKey));
|
||||
} catch (RuntimeException ignored) {
|
||||
// 清理缓存失败不应掩盖 MySQL 删除结果,后续 TTL 会自然回收。
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> deepCopy(Map<String, Object> source) {
|
||||
if (source == null || source.isEmpty()) {
|
||||
return emptyEnvelope();
|
||||
}
|
||||
return JsonUtils.getJsonCodec().fromJson(JsonUtils.getJsonCodec().toJson(source), Map.class);
|
||||
}
|
||||
|
||||
private String cacheKey(String sessionKey) {
|
||||
return REDIS_PREFIX + hash(sessionKey);
|
||||
}
|
||||
|
||||
private String hash(String value) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
return value.replace(':', '_');
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T firstNonNull(T left, T right) {
|
||||
return left == null ? right : left;
|
||||
}
|
||||
|
||||
private String firstText(String left, String right) {
|
||||
return StringUtils.hasText(left) ? left : right;
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentScope session 绑定的 EasyFlow 业务元信息。
|
||||
*
|
||||
* @param sessionKey AgentScope session key
|
||||
* @param runtimeSessionId runtime session ID
|
||||
* @param agentId Agent ID
|
||||
* @param chatSessionId chatlog 会话 ID
|
||||
* @param tenantId 租户 ID
|
||||
* @param operatorId 操作人 ID
|
||||
*/
|
||||
public record AgentSessionMetadata(String sessionKey,
|
||||
String runtimeSessionId,
|
||||
BigInteger agentId,
|
||||
BigInteger chatSessionId,
|
||||
BigInteger tenantId,
|
||||
BigInteger operatorId) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package tech.easyflow.agent.runtime.tool;
|
||||
|
||||
/**
|
||||
* Agent 工具执行模式。
|
||||
*/
|
||||
public enum AgentToolExecutionMode {
|
||||
|
||||
/**
|
||||
* 同步执行。
|
||||
*/
|
||||
SYNC,
|
||||
|
||||
/**
|
||||
* 异步执行。
|
||||
*/
|
||||
ASYNC;
|
||||
|
||||
/**
|
||||
* 解析执行模式。
|
||||
*
|
||||
* @param value 原始配置值
|
||||
* @return 执行模式;非法或为空时返回 SYNC
|
||||
*/
|
||||
public static AgentToolExecutionMode from(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return SYNC;
|
||||
}
|
||||
for (AgentToolExecutionMode mode : values()) {
|
||||
if (mode.name().equalsIgnoreCase(value.trim())) {
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
return SYNC;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package tech.easyflow.agent.runtime.tool;
|
||||
|
||||
/**
|
||||
* Agent 业务工具执行结果。
|
||||
*/
|
||||
public class AgentToolExecutionResult {
|
||||
|
||||
private Object result;
|
||||
private String businessExecutionId;
|
||||
|
||||
/**
|
||||
* 创建执行结果。
|
||||
*
|
||||
* @param result 业务结果
|
||||
* @param businessExecutionId 业务执行记录 ID
|
||||
*/
|
||||
public AgentToolExecutionResult(Object result, String businessExecutionId) {
|
||||
this.result = result;
|
||||
this.businessExecutionId = businessExecutionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取业务结果。
|
||||
*
|
||||
* @return 业务结果
|
||||
*/
|
||||
public Object getResult() {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置业务结果。
|
||||
*
|
||||
* @param result 业务结果
|
||||
*/
|
||||
public void setResult(Object result) {
|
||||
this.result = result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取业务执行记录 ID。
|
||||
*
|
||||
* @return 业务执行记录 ID
|
||||
*/
|
||||
public String getBusinessExecutionId() {
|
||||
return businessExecutionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置业务执行记录 ID。
|
||||
*
|
||||
* @param businessExecutionId 业务执行记录 ID
|
||||
*/
|
||||
public void setBusinessExecutionId(String businessExecutionId) {
|
||||
this.businessExecutionId = businessExecutionId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package tech.easyflow.agent.runtime.tool;
|
||||
|
||||
import com.easyagents.agent.runtime.mcp.McpSpec;
|
||||
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 工具运行时编译结果。
|
||||
*/
|
||||
public class AgentToolRuntimeCompilation {
|
||||
|
||||
private List<AgentToolSpec> toolSpecs = new ArrayList<>();
|
||||
private List<McpSpec> mcpSpecs = new ArrayList<>();
|
||||
private Map<String, AgentToolInvoker> toolInvokers = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 获取普通工具声明。
|
||||
*
|
||||
* @return 普通工具声明
|
||||
*/
|
||||
public List<AgentToolSpec> getToolSpecs() {
|
||||
return toolSpecs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置普通工具声明。
|
||||
*
|
||||
* @param toolSpecs 普通工具声明
|
||||
*/
|
||||
public void setToolSpecs(List<AgentToolSpec> toolSpecs) {
|
||||
this.toolSpecs = toolSpecs == null ? new ArrayList<>() : toolSpecs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 MCP 声明。
|
||||
*
|
||||
* @return MCP 声明
|
||||
*/
|
||||
public List<McpSpec> getMcpSpecs() {
|
||||
return mcpSpecs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 MCP 声明。
|
||||
*
|
||||
* @param mcpSpecs MCP 声明
|
||||
*/
|
||||
public void setMcpSpecs(List<McpSpec> mcpSpecs) {
|
||||
this.mcpSpecs = mcpSpecs == null ? new ArrayList<>() : mcpSpecs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具调用器。
|
||||
*
|
||||
* @return 工具调用器
|
||||
*/
|
||||
public Map<String, AgentToolInvoker> getToolInvokers() {
|
||||
return toolInvokers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置工具调用器。
|
||||
*
|
||||
* @param toolInvokers 工具调用器
|
||||
*/
|
||||
public void setToolInvokers(Map<String, AgentToolInvoker> toolInvokers) {
|
||||
this.toolInvokers = toolInvokers == null ? new LinkedHashMap<>() : toolInvokers;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
package tech.easyflow.agent.runtime.tool;
|
||||
|
||||
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
|
||||
import com.easyagents.agent.runtime.mcp.McpSpec;
|
||||
import com.easyagents.agent.runtime.mcp.McpTransportType;
|
||||
import com.easyagents.agent.runtime.tool.*;
|
||||
import com.easyagents.agent.runtime.tool.asynctool.AsyncToolSpec;
|
||||
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.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||
import tech.easyflow.agent.enums.AgentToolType;
|
||||
import tech.easyflow.agent.runtime.asynctool.AgentAsyncToolTaskStore;
|
||||
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.PluginItem;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.service.McpService;
|
||||
import tech.easyflow.ai.service.PluginItemService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Agent 工具运行时编译器。
|
||||
*/
|
||||
@Component
|
||||
public class AgentToolRuntimeCompiler {
|
||||
|
||||
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_]*$");
|
||||
|
||||
@Resource
|
||||
private WorkflowService workflowService;
|
||||
@Resource
|
||||
private PluginItemService pluginItemService;
|
||||
@Resource
|
||||
private McpService mcpService;
|
||||
@Resource
|
||||
private ObjectMapper objectMapper;
|
||||
@Resource
|
||||
private WorkflowToolExecutor workflowToolExecutor;
|
||||
@Resource
|
||||
private PluginToolExecutor pluginToolExecutor;
|
||||
@Resource
|
||||
private AgentAsyncToolTaskStore asyncToolTaskStore;
|
||||
@Resource(name = "agentAsyncToolExecutor")
|
||||
private ThreadPoolTaskExecutor agentAsyncToolExecutor;
|
||||
|
||||
/**
|
||||
* 编译 Agent 工具配置。
|
||||
*
|
||||
* @param agent Agent 业务定义
|
||||
* @return 工具编译结果
|
||||
*/
|
||||
public AgentToolRuntimeCompilation compile(Agent agent) {
|
||||
AgentToolRuntimeCompilation compilation = new AgentToolRuntimeCompilation();
|
||||
if (agent == null || agent.getToolBindings() == null) {
|
||||
return compilation;
|
||||
}
|
||||
List<AgentToolSpec> specs = new ArrayList<>();
|
||||
Map<String, AgentToolInvoker> invokers = new LinkedHashMap<>();
|
||||
List<McpSpec> mcpSpecs = new ArrayList<>();
|
||||
Map<BigInteger, McpSpec> mcpSpecMap = new LinkedHashMap<>();
|
||||
Set<String> compiledToolNames = new LinkedHashSet<>();
|
||||
AsyncToolSpecExpander asyncExpander = new AsyncToolSpecExpander();
|
||||
for (AgentToolBinding binding : agent.getToolBindings()) {
|
||||
if (!Boolean.TRUE.equals(binding.getEnabled())) {
|
||||
continue;
|
||||
}
|
||||
AgentToolType type = AgentToolType.from(binding.getToolType());
|
||||
if (type == AgentToolType.MCP) {
|
||||
McpSpec mcpSpec = mcpSpecMap.computeIfAbsent(binding.getTargetId(), ignored -> buildMcpSpec(binding));
|
||||
applyMcpToolBinding(mcpSpec, binding);
|
||||
if (!mcpSpecs.contains(mcpSpec)) {
|
||||
mcpSpecs.add(mcpSpec);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (executionMode(binding) == AgentToolExecutionMode.ASYNC) {
|
||||
AsyncToolSpec asyncSpec = buildAsyncToolSpec(type, binding);
|
||||
addExpandedTools(specs, invokers, compiledToolNames,
|
||||
asyncExpander.expandSpecs(asyncSpec),
|
||||
asyncExpander.expandInvokers(asyncSpec));
|
||||
continue;
|
||||
}
|
||||
CompiledSyncTool syncTool = buildSyncTool(type, binding);
|
||||
addCompiledTool(specs, invokers, compiledToolNames, syncTool.spec(), syncTool.invoker());
|
||||
}
|
||||
compilation.setToolSpecs(specs);
|
||||
compilation.setMcpSpecs(mcpSpecs);
|
||||
compilation.setToolInvokers(invokers);
|
||||
return compilation;
|
||||
}
|
||||
|
||||
private void addExpandedTools(List<AgentToolSpec> specs,
|
||||
Map<String, AgentToolInvoker> invokers,
|
||||
Set<String> compiledToolNames,
|
||||
List<AgentToolSpec> expandedSpecs,
|
||||
Map<String, AgentToolInvoker> expandedInvokers) {
|
||||
for (AgentToolSpec spec : expandedSpecs) {
|
||||
addCompiledTool(specs, invokers, compiledToolNames, spec,
|
||||
expandedInvokers == null ? null : expandedInvokers.get(spec.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
private void addCompiledTool(List<AgentToolSpec> specs,
|
||||
Map<String, AgentToolInvoker> invokers,
|
||||
Set<String> compiledToolNames,
|
||||
AgentToolSpec spec,
|
||||
AgentToolInvoker invoker) {
|
||||
String name = spec == null ? null : spec.getName();
|
||||
if (name == null || name.isBlank()) {
|
||||
throw new BusinessException("Agent 工具运行名不能为空");
|
||||
}
|
||||
if (!compiledToolNames.add(name)) {
|
||||
throw new BusinessException("Agent 工具运行名冲突:" + name + ",请调整工具名称");
|
||||
}
|
||||
specs.add(spec);
|
||||
if (invoker != null) {
|
||||
invokers.put(name, invoker);
|
||||
}
|
||||
}
|
||||
|
||||
private CompiledSyncTool buildSyncTool(AgentToolType type, AgentToolBinding binding) {
|
||||
if (type == AgentToolType.WORKFLOW) {
|
||||
Workflow workflow = requireWorkflow(binding);
|
||||
Tool tool = workflowToolExecutor.buildTool(workflow);
|
||||
AgentToolSpec spec = toToolSpec(tool, binding);
|
||||
AgentToolInvoker invoker = (arguments, context) -> invokeSafely(spec.getName(),
|
||||
() -> workflowToolExecutor.execute(workflow, arguments).getResult());
|
||||
return new CompiledSyncTool(spec, invoker);
|
||||
}
|
||||
if (type == AgentToolType.PLUGIN) {
|
||||
PluginItem pluginItem = requirePlugin(binding);
|
||||
Tool tool = pluginToolExecutor.buildTool(pluginItem);
|
||||
AgentToolSpec spec = toToolSpec(tool, binding);
|
||||
AgentToolInvoker invoker = (arguments, context) -> invokeSafely(spec.getName(),
|
||||
() -> pluginToolExecutor.execute(pluginItem, arguments).getResult());
|
||||
return new CompiledSyncTool(spec, invoker);
|
||||
}
|
||||
throw new BusinessException("不支持的 Agent 工具类型:" + type.name());
|
||||
}
|
||||
|
||||
private AsyncToolSpec buildAsyncToolSpec(AgentToolType type, AgentToolBinding binding) {
|
||||
if (type == AgentToolType.WORKFLOW) {
|
||||
Workflow workflow = requireWorkflow(binding);
|
||||
Tool tool = workflowToolExecutor.buildTool(workflow);
|
||||
String asyncName = asyncToolName(tool, binding, "workflow");
|
||||
String toolDisplayName = displayName(tool, workflow.getTitle());
|
||||
AsyncToolSpec spec = baseAsyncSpec(asyncName, tool, binding, toolDisplayName);
|
||||
spec.setSubTools(new WorkflowAsyncSubTools(workflow, asyncName, toolDisplayName,
|
||||
workflowToolExecutor, asyncToolTaskStore, agentAsyncToolExecutor));
|
||||
return spec;
|
||||
}
|
||||
if (type == AgentToolType.PLUGIN) {
|
||||
PluginItem pluginItem = requirePlugin(binding);
|
||||
Tool tool = pluginToolExecutor.buildTool(pluginItem);
|
||||
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,
|
||||
pluginToolExecutor, asyncToolTaskStore, agentAsyncToolExecutor));
|
||||
return spec;
|
||||
}
|
||||
throw new BusinessException("不支持的 Agent 异步工具类型:" + type.name());
|
||||
}
|
||||
|
||||
private AsyncToolSpec baseAsyncSpec(String asyncName, Tool tool, AgentToolBinding binding, String toolDisplayName) {
|
||||
AsyncToolSpec spec = new AsyncToolSpec();
|
||||
spec.setName(asyncName);
|
||||
spec.setDescription(safeDescription(tool == null ? null : tool.getDescription()));
|
||||
spec.setSubmitParametersSchema(toSchema(tool == null ? null : tool.getParameters()));
|
||||
spec.setApprovalRequired(Boolean.TRUE.equals(binding.getHitlEnabled()));
|
||||
if (Boolean.TRUE.equals(binding.getHitlEnabled())) {
|
||||
spec.setApprovalRequest(buildBindingApprovalRequest(binding));
|
||||
}
|
||||
spec.getMetadata().put("bindingId", binding.getId());
|
||||
spec.getMetadata().put("targetId", binding.getTargetId());
|
||||
spec.getMetadata().put("toolType", binding.getToolType());
|
||||
// 异步子工具名服务 runtime 调用,事件和聊天展示必须保留业务名称。
|
||||
spec.getMetadata().put("toolDisplayName", toolDisplayName);
|
||||
return spec;
|
||||
}
|
||||
|
||||
private AgentToolResult invokeSafely(String toolName, 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());
|
||||
}
|
||||
}
|
||||
|
||||
private AgentToolExecutionMode executionMode(AgentToolBinding binding) {
|
||||
Object value = binding == null || binding.getOptionsJson() == null ? null : binding.getOptionsJson().get("executionMode");
|
||||
return AgentToolExecutionMode.from(value == null ? null : String.valueOf(value));
|
||||
}
|
||||
|
||||
private Workflow requireWorkflow(AgentToolBinding binding) {
|
||||
Workflow workflow = snapshotOrPublishedWorkflow(binding);
|
||||
if (workflow == null) {
|
||||
throw new BusinessException("绑定工作流不存在");
|
||||
}
|
||||
return workflow;
|
||||
}
|
||||
|
||||
private PluginItem requirePlugin(AgentToolBinding binding) {
|
||||
PluginItem pluginItem = snapshotOrCurrentPlugin(binding);
|
||||
if (pluginItem == null) {
|
||||
throw new BusinessException("绑定插件不存在");
|
||||
}
|
||||
return pluginItem;
|
||||
}
|
||||
|
||||
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())) {
|
||||
spec.setApprovalRequest(buildBindingApprovalRequest(binding));
|
||||
}
|
||||
spec.getMetadata().put("bindingId", binding.getId());
|
||||
spec.getMetadata().put("targetId", binding.getTargetId());
|
||||
spec.getMetadata().put("toolType", binding.getToolType());
|
||||
spec.getMetadata().put("toolDisplayName", displayName(tool, binding.getToolName()));
|
||||
return spec;
|
||||
}
|
||||
|
||||
private AgentToolApprovalRequest buildBindingApprovalRequest(AgentToolBinding binding) {
|
||||
AgentToolApprovalRequest request = new AgentToolApprovalRequest();
|
||||
String name = binding == null ? "工具" : binding.getToolName();
|
||||
request.setApprovalPrompt(stringValue(binding == null ? null : binding.getHitlConfigJson(), "prompt", "是否批准执行工具:" + name));
|
||||
Map<String, Object> metadata = sanitizedHitlMetadata(binding == null ? null : binding.getHitlConfigJson());
|
||||
if (binding != null) {
|
||||
metadata.put("toolType", binding.getToolType());
|
||||
metadata.put("bindingId", binding.getId());
|
||||
metadata.put("targetId", binding.getTargetId());
|
||||
}
|
||||
request.setMetadata(metadata);
|
||||
return request;
|
||||
}
|
||||
|
||||
private McpSpec buildMcpSpec(AgentToolBinding binding) {
|
||||
Mcp mcp = snapshotOrCurrentMcp(binding);
|
||||
if (mcp == null) {
|
||||
throw new BusinessException("绑定 MCP 不存在");
|
||||
}
|
||||
Map.Entry<String, Map<String, Object>> server = firstMcpServer(mcp);
|
||||
Map<String, Object> serverConfig = server.getValue();
|
||||
McpSpec spec = new McpSpec();
|
||||
spec.setName(mcpRuntimeName(mcp));
|
||||
spec.setDescription(firstNonBlank(mcp.getDescription(), mcp.getTitle()));
|
||||
spec.setTransportType(parseMcpTransportType(mcp, serverConfig));
|
||||
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;
|
||||
}
|
||||
|
||||
private void applyMcpToolBinding(McpSpec spec, AgentToolBinding binding) {
|
||||
if (Boolean.TRUE.equals(binding.getHitlEnabled())) {
|
||||
spec.setApprovalRequired(true);
|
||||
spec.setApprovalRequest(buildBindingApprovalRequest(binding));
|
||||
}
|
||||
}
|
||||
|
||||
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 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<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"));
|
||||
}
|
||||
|
||||
private void putOptionalString(Map<String, Object> target, String key, String value) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
target.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveRuntimeToolName(Tool tool, AgentToolBinding binding) {
|
||||
String bindingName = binding == null ? null : binding.getToolName();
|
||||
if (ChatToolNameHelper.isSafeToolName(bindingName)) {
|
||||
return bindingName;
|
||||
}
|
||||
String toolName = tool == null ? null : tool.getName();
|
||||
if (ChatToolNameHelper.isSafeToolName(toolName)) {
|
||||
return toolName;
|
||||
}
|
||||
BigInteger targetId = binding == null ? null : binding.getTargetId();
|
||||
return ChatToolNameHelper.buildFallbackName("tool", targetId);
|
||||
}
|
||||
|
||||
private String asyncToolName(Tool tool, AgentToolBinding binding, String fallbackPrefix) {
|
||||
String base = resolveRuntimeToolName(tool, binding).toLowerCase(Locale.ROOT)
|
||||
.replaceAll("[^a-z0-9_]", "_")
|
||||
.replaceAll("_+", "_");
|
||||
if (!base.isBlank() && Character.isDigit(base.charAt(0))) {
|
||||
base = fallbackPrefix + "_" + base;
|
||||
}
|
||||
if (ASYNC_SAFE_NAME.matcher(base).matches()) {
|
||||
return base;
|
||||
}
|
||||
return fallbackPrefix + "_" + (binding == null || binding.getTargetId() == null ? "unknown" : binding.getTargetId());
|
||||
}
|
||||
|
||||
private String displayName(Tool tool, String fallback) {
|
||||
String value = tool == null ? null : tool.getName();
|
||||
return firstNonBlank(firstNonBlank(fallback, value), "工具调用");
|
||||
}
|
||||
|
||||
private String safeDescription(String description) {
|
||||
return description == null || description.isBlank() ? "EasyFlow Agent 工具" : description;
|
||||
}
|
||||
|
||||
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 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("_+", "_");
|
||||
return normalized.isBlank() ? "tool" : 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 Map<String, Object> mapValue(Map<String, Object> map, String key) {
|
||||
Object value = map == null ? null : map.get(key);
|
||||
if (value == null) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
if (value instanceof Map<?, ?> raw) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
raw.forEach((rawKey, rawValue) -> result.put(String.valueOf(rawKey), rawValue));
|
||||
return result;
|
||||
}
|
||||
throw new BusinessException("Agent 配置字段必须是对象:" + key);
|
||||
}
|
||||
|
||||
private Map<String, String> stringMapValue(Map<String, Object> map, String key) {
|
||||
Map<String, Object> raw = mapValue(map, key);
|
||||
Map<String, String> result = new LinkedHashMap<>();
|
||||
raw.forEach((rawKey, rawValue) -> {
|
||||
if (rawValue != null) {
|
||||
result.put(rawKey, String.valueOf(rawValue));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private String stringValue(Map<String, Object> map, String key, String defaultValue) {
|
||||
Object value = map == null ? null : map.get(key);
|
||||
if (value == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
String text = String.valueOf(value);
|
||||
return text.isBlank() ? defaultValue : text;
|
||||
}
|
||||
|
||||
private String firstNonBlank(String first, String second) {
|
||||
return first == null || first.isBlank() ? second : first;
|
||||
}
|
||||
|
||||
private BigInteger firstNonNull(BigInteger first, BigInteger second) {
|
||||
return first == null ? second : first;
|
||||
}
|
||||
|
||||
private record CompiledSyncTool(AgentToolSpec spec, AgentToolInvoker invoker) {
|
||||
}
|
||||
|
||||
private interface ToolCall {
|
||||
|
||||
/**
|
||||
* 调用工具。
|
||||
*
|
||||
* @return 工具结果
|
||||
*/
|
||||
Object invoke();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package tech.easyflow.agent.runtime.tool;
|
||||
|
||||
import com.easyagents.core.model.chat.tool.Tool;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.ai.entity.PluginItem;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Agent Plugin 工具执行器。
|
||||
*/
|
||||
@Service
|
||||
public class PluginToolExecutor {
|
||||
|
||||
/**
|
||||
* 构建 Plugin 工具声明来源。
|
||||
*
|
||||
* @param pluginItem 插件工具
|
||||
* @return 工具声明来源
|
||||
*/
|
||||
public Tool buildTool(PluginItem pluginItem) {
|
||||
return pluginItem.toFunction();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Plugin 工具。
|
||||
*
|
||||
* @param pluginItem 插件工具
|
||||
* @param arguments 执行参数
|
||||
* @return 执行结果
|
||||
*/
|
||||
public AgentToolExecutionResult execute(PluginItem pluginItem, Map<String, Object> arguments) {
|
||||
Object result = buildTool(pluginItem).invoke(arguments == null ? Map.of() : arguments);
|
||||
return new AgentToolExecutionResult(result, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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.stereotype.Service;
|
||||
import tech.easyflow.ai.easyagents.tool.WorkflowTool;
|
||||
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Agent Workflow 工具执行器。
|
||||
*/
|
||||
@Service
|
||||
public class WorkflowToolExecutor {
|
||||
|
||||
private final ChainExecutor chainExecutor;
|
||||
|
||||
/**
|
||||
* 创建 Workflow 工具执行器。
|
||||
*
|
||||
* @param chainExecutor 工作流执行器
|
||||
*/
|
||||
public WorkflowToolExecutor(ChainExecutor chainExecutor) {
|
||||
this.chainExecutor = chainExecutor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 Workflow 工具声明来源。
|
||||
*
|
||||
* @param workflow 工作流
|
||||
* @return 工具声明来源
|
||||
*/
|
||||
public Tool buildTool(Workflow workflow) {
|
||||
return new WorkflowTool(workflow, true, definitionId(workflow));
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Workflow 工具。
|
||||
*
|
||||
* @param workflow 工作流
|
||||
* @param arguments 执行参数
|
||||
* @return 执行结果
|
||||
*/
|
||||
public AgentToolExecutionResult execute(Workflow workflow, Map<String, Object> arguments) {
|
||||
Object result = chainExecutor.execute(definitionId(workflow), arguments == null ? Map.of() : arguments);
|
||||
return new AgentToolExecutionResult(result, resolveBusinessExecutionId(result));
|
||||
}
|
||||
|
||||
private String definitionId(Workflow workflow) {
|
||||
return PublishedWorkflowDefinitionIds.published(String.valueOf(workflow == null ? null : workflow.getId()));
|
||||
}
|
||||
|
||||
private String resolveBusinessExecutionId(Object result) {
|
||||
if (result instanceof Map<?, ?> map) {
|
||||
Object value = firstValue(map, "executionId", "executeId", "chainId", "runId");
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Object firstValue(Map<?, ?> map, String... keys) {
|
||||
for (String key : keys) {
|
||||
if (map.containsKey(key)) {
|
||||
return map.get(key);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package tech.easyflow.agent.service;
|
||||
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
/**
|
||||
* Agent 审批状态派生服务。
|
||||
*/
|
||||
public interface AgentApprovalStateService {
|
||||
|
||||
/**
|
||||
* 填充单个 Agent 的审批展示状态。
|
||||
*
|
||||
* @param agent Agent 资源
|
||||
*/
|
||||
void fillAgentApprovalState(Agent agent);
|
||||
|
||||
/**
|
||||
* 批量填充 Agent 的审批展示状态。
|
||||
*
|
||||
* @param agents Agent 资源集合
|
||||
*/
|
||||
void fillAgentApprovalState(Collection<Agent> agents);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package tech.easyflow.agent.service;
|
||||
|
||||
import com.mybatisflex.core.service.IService;
|
||||
import tech.easyflow.agent.entity.AgentCategory;
|
||||
|
||||
/**
|
||||
* Agent 分类服务。
|
||||
*/
|
||||
public interface AgentCategoryService extends IService<AgentCategory> {
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package tech.easyflow.agent.service;
|
||||
|
||||
import com.mybatisflex.core.service.IService;
|
||||
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Agent 知识库绑定服务。
|
||||
*/
|
||||
public interface AgentKnowledgeBindingService extends IService<AgentKnowledgeBinding> {
|
||||
|
||||
/**
|
||||
* 替换 Agent 知识库绑定。
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
* @param bindings 新绑定列表
|
||||
* @return 保存后的绑定列表
|
||||
*/
|
||||
List<AgentKnowledgeBinding> replaceBindings(BigInteger agentId, List<AgentKnowledgeBinding> bindings);
|
||||
|
||||
/**
|
||||
* 查询 Agent 启用知识库绑定。
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
* @return 启用绑定列表
|
||||
*/
|
||||
List<AgentKnowledgeBinding> listEnabled(BigInteger agentId);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user