Compare commits

...

7 Commits

Author SHA1 Message Date
cb379e071c chore: 发布 easyflow 1.0.0
- 将 Maven revision 升级为 1.0.0

- 同步 easy-agents 依赖版本为 1.0.0
2026-06-05 14:06:17 +08:00
8b80770960 feat: 增加代码混淆支持 2026-06-05 13:53:26 +08:00
c316eff5be feat: 归档 XL10 异步工具业务编译层
- 将 AgentDefinitionCompiler 升级为 AgentRuntimeCompiler

- 接入 Workflow 和 Plugin 的同步/异步工具编译与 Redis 任务态

- 增加异步执行配置开关、聊天时间线聚合和后端测试
2026-06-04 15:23:56 +08:00
1ea863cb2c chore: 调整 Dockerfile 构造 2026-05-31 20:11:38 +08:00
0f4d10c43c feat: 增强多实例分布式部署兼容
- 增加定时任务分布式锁并覆盖 chatlog、文档导入和 Agent HITL 过期扫描

- 增强 Redis MQ 多实例 consumer 标识、pending reclaim 和单条处理能力

- 增加文档导入状态 Redis 广播和 Agent HITL 跨节点路由确认
2026-05-29 18:27:46 +08:00
cc3bb9cff0 feat: 完成 Agent MCP 对接
- 增加 MCP 连接类型、环境检测接口和容器运行环境支持

- 将 Agent 编排改为绑定整体 MCP 并编译为 runtime McpSpec

- 优化 MCP 工具展示、审批、草稿试运行和画布回显稳定性
2026-05-29 11:09:21 +08:00
e39f7521e2 chore: 弃用 bot 对接的 mcptool 2026-05-28 11:30:56 +08:00
99 changed files with 8305 additions and 372 deletions

View File

@@ -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

View 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>(...);
}

View 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.** { *; }

View 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 { *; }

View 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.** { *; }

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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;
}
}
}

View File

@@ -12,6 +12,9 @@ import java.util.Collections;
import java.util.UUID;
import java.util.function.Supplier;
/**
* Redis 分布式锁执行器。
*/
@Component
public class RedisLockExecutor {
@@ -42,6 +45,14 @@ public class RedisLockExecutor {
@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();
@@ -49,6 +60,16 @@ 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 {
@@ -70,24 +91,46 @@ public class RedisLockExecutor {
* @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;
}
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);
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -27,5 +27,17 @@
<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>

View File

@@ -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,6 +39,7 @@ 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);
@@ -59,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;
}
@@ -191,5 +216,13 @@ public class MQProperties {
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();
}
}
}

View File

@@ -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;
}
}

View File

@@ -30,6 +30,7 @@ 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.ThreadPoolExecutor;
@@ -39,6 +40,7 @@ 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;
@@ -154,13 +156,24 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
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 {
reclaimPending(streamKey, subscription.getConsumerGroup(), consumerName);
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()
@@ -177,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(),
@@ -192,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
@@ -200,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) {
@@ -209,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;
}
}
@@ -233,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");
@@ -269,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());
@@ -288,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={}",

View File

@@ -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;
}
}
}

View File

@@ -37,6 +37,10 @@
<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>
@@ -63,5 +67,11 @@
<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>

View File

@@ -1,8 +1,10 @@
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 运行态生产化配置。
@@ -15,6 +17,36 @@ public class AgentRuntimeProperties {
*/
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 默认过期时间。
*/
@@ -35,6 +67,11 @@ public class AgentRuntimeProperties {
*/
private Duration lockRenewInterval = Duration.ofMinutes(1);
/**
* Agent 异步工具任务 Redis 运行态 TTL。
*/
private Duration asyncToolTaskTtl = Duration.ofHours(24);
/**
* 获取 Redis 热态 session 缓存 TTL。
*
@@ -53,6 +90,107 @@ public class AgentRuntimeProperties {
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 默认过期时间。
*
@@ -124,4 +262,34 @@ public class AgentRuntimeProperties {
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();
}
}

View File

@@ -0,0 +1,17 @@
package tech.easyflow.agent.distributed;
/**
* Agent 运行态远程命令动作。
*/
public enum AgentRuntimeCommandAction {
/**
* 批准工具执行。
*/
APPROVE,
/**
* 拒绝工具执行。
*/
REJECT
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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 运行节点响应等待被中断");
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -4,8 +4,12 @@ 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;
@@ -25,11 +29,24 @@ import java.util.function.Consumer;
@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;
}
/**
* 注册运行态。
@@ -53,6 +70,9 @@ public class AgentRunRegistry {
throw new BusinessException("当前 Agent 运行请求已存在");
}
owners.put(context.requestId(), context.owner());
if (routeRegistry != null) {
routeRegistry.registerRun(context.requestId());
}
}
/**
@@ -122,6 +142,9 @@ public class AgentRunRegistry {
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);
}
}
}
@@ -138,11 +161,20 @@ public class AgentRunRegistry {
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(resumeTokenIndex::remove);
tokens.forEach(token -> {
resumeTokenIndex.remove(token);
if (routeRegistry != null) {
routeRegistry.removeResumeToken(token);
}
});
}
if (routeRegistry != null) {
routeRegistry.removeRun(requestId);
}
}
@@ -210,6 +242,23 @@ public class AgentRunRegistry {
}
}
/**
* 当前恢复目标是否为草稿试运行。
*
* @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);
}
@@ -235,6 +284,9 @@ public class AgentRunRegistry {
tokens.remove(resumeToken);
}
resumeTokenIndex.remove(resumeToken);
if (routeRegistry != null) {
routeRegistry.removeResumeToken(resumeToken);
}
AgentResumeToken token = new AgentResumeToken();
token.setValue(resumeToken);
AgentResumeRequest request = new AgentResumeRequest();
@@ -430,6 +482,15 @@ public class AgentRunRegistry {
return suspended.get();
}
/**
* 当前运行是否持久化聊天日志与运行态。
*
* @return true 表示正式聊天持久化运行
*/
public boolean persistChatlog() {
return persistChatlog;
}
/**
* 绑定运行订阅。
*
@@ -477,6 +538,18 @@ public class AgentRunRegistry {
}
}
/**
* 关闭底层运行时并释放资源。
*/
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。
*

View File

@@ -19,6 +19,10 @@ 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.distributed.AgentRuntimeCommandAction;
import tech.easyflow.agent.distributed.AgentRuntimeCommandProducer;
import tech.easyflow.agent.distributed.AgentRuntimeRoute;
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
import tech.easyflow.agent.runtime.event.AgentRunEventRecorder;
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
import tech.easyflow.agent.runtime.lock.AgentRunLock;
@@ -66,18 +70,22 @@ public class AgentRunService {
@Resource
private AgentService agentService;
@Resource
private AgentDefinitionCompiler agentDefinitionCompiler;
private AgentRuntimeCompiler agentRuntimeCompiler;
@Resource
private AgentRuntimeFactory agentRuntimeFactory;
@Resource
private AgentChatCapabilityService agentChatCapabilityService;
@Resource
private AgentSessionStore agentSessionStore;
@Resource
private EasyFlowAgentSessionStore easyFlowAgentSessionStore;
@Resource
private AgentSessionStore draftAgentSessionStore;
@Resource
private AgentRunRegistry agentRunRegistry;
@Resource
private AgentRuntimeRouteRegistry agentRuntimeRouteRegistry;
@Resource
private AgentRuntimeCommandProducer agentRuntimeCommandProducer;
@Resource
private AgentRunLock agentRunLock;
@Resource
private AgentHitlPendingService agentHitlPendingService;
@@ -136,7 +144,7 @@ public class AgentRunService {
applyFormalSessionTitle(chatContext, chatRequest.getPrompt(), existingSession);
// 执行对话
return run(agent, chatRequest.getPrompt(), requestId, traceId, sessionId.toString(),
ASSISTANT_CODE, chatContext, true);
ASSISTANT_CODE, chatContext, true, easyFlowAgentSessionStore);
}
/**
@@ -160,7 +168,7 @@ public class AgentRunService {
String traceId = UUID.randomUUID().toString();
ChatRuntimeContext chatContext = buildChatRuntimeContext(agent, chatSessionId, draftRequest.getPrompt(), account, DRAFT_ASSISTANT_CODE);
return run(agent, draftRequest.getPrompt(), requestId, traceId, runtimeSessionId,
DRAFT_ASSISTANT_CODE, chatContext, false);
DRAFT_ASSISTANT_CODE, chatContext, false, draftAgentSessionStore);
}
private SseEmitter run(Agent agent,
@@ -170,7 +178,8 @@ public class AgentRunService {
String runtimeSessionId,
String assistantCode,
ChatRuntimeContext chatContext,
boolean persistChatlog) {
boolean persistChatlog,
AgentSessionStore runtimeSessionStore) {
ChatSseEmitter chatSseEmitter = new ChatSseEmitter();
// 获取会话锁
AgentRunLock.Handle lockHandle = acquireRunLock(agent, runtimeSessionId);
@@ -186,7 +195,7 @@ public class AgentRunService {
chatRuntimeManager.recordUserMessage(chatContext, buildUserRuntimeMessage(chatContext, prompt));
}
threadPoolTaskExecutor.execute(() -> startRuntime(agent, prompt, requestId, traceId, runtimeSessionId,
assistantCode, chatContext, chatSseEmitter, persistChatlog, lockHandle));
assistantCode, chatContext, chatSseEmitter, persistChatlog, runtimeSessionStore, lockHandle));
submitted = true;
return chatSseEmitter.getEmitter();
} finally {
@@ -210,11 +219,12 @@ public class AgentRunService {
throw new BusinessException("仅允许清理 Agent 草稿试运行会话");
}
LoginAccount account = requireCurrentLoginAccount();
agentRunRegistry.cancelSession(sessionId, account.getId() == null ? null : account.getId().toString());
agentSessionStore.delete(sessionId);
if (agentHitlPendingService != null) {
agentHitlPendingService.deleteByRuntimeSessionId(sessionId);
}
clearDraftSessionInternal(sessionId, account.getId() == null ? null : account.getId().toString());
}
private void clearDraftSessionInternal(String sessionId, String userId) {
agentRunRegistry.cancelSession(sessionId, userId);
draftAgentSessionStore.delete(sessionId);
}
/**
@@ -225,9 +235,32 @@ public class AgentRunService {
*/
public void approve(String requestId, String resumeToken) {
LoginAccount account = requireCurrentLoginAccount();
String userId = account.getId() == null ? null : account.getId().toString();
approveRuntime(requestId, resumeToken, account.getId(), account.getId() == null ? null : account.getId().toString());
}
private void approveRuntime(String requestId, String resumeToken, BigInteger operatorId, String userId) {
if (!agentRunRegistry.containsResumeTarget(requestId, resumeToken)) {
dispatchRemoteRuntimeCommand(requestId, resumeToken, AgentRuntimeCommandAction.APPROVE, null, operatorId, userId);
return;
}
approveRuntimeLocal(requestId, resumeToken, operatorId, userId);
}
/**
* 在当前节点批准工具执行。
*
* @param requestId 请求 ID
* @param resumeToken 恢复令牌
* @param operatorId 操作人 ID
* @param userId 用户 ID
*/
public void approveRuntimeLocal(String requestId, String resumeToken, BigInteger operatorId, String userId) {
if (agentRunRegistry.isDraftResumeTarget(requestId, resumeToken)) {
agentRunRegistry.approve(requestId, resumeToken, userId);
return;
}
agentRunRegistry.approve(requestId, resumeToken, userId,
() -> agentHitlPendingService.approve(resumeToken, account.getId()));
() -> agentHitlPendingService.approve(resumeToken, operatorId));
}
/**
@@ -239,9 +272,73 @@ public class AgentRunService {
*/
public void reject(String requestId, String resumeToken, String reason) {
LoginAccount account = requireCurrentLoginAccount();
String userId = account.getId() == null ? null : account.getId().toString();
rejectRuntime(requestId, resumeToken, reason, account.getId(), account.getId() == null ? null : account.getId().toString());
}
private void rejectRuntime(String requestId, String resumeToken, String reason, BigInteger operatorId, String userId) {
if (!agentRunRegistry.containsResumeTarget(requestId, resumeToken)) {
dispatchRemoteRuntimeCommand(requestId, resumeToken, AgentRuntimeCommandAction.REJECT, reason, operatorId, userId);
return;
}
rejectRuntimeLocal(requestId, resumeToken, reason, operatorId, userId);
}
/**
* 在当前节点拒绝工具执行。
*
* @param requestId 请求 ID
* @param resumeToken 恢复令牌
* @param reason 拒绝原因
* @param operatorId 操作人 ID
* @param userId 用户 ID
*/
public void rejectRuntimeLocal(String requestId, String resumeToken, String reason, BigInteger operatorId, String userId) {
if (agentRunRegistry.isDraftResumeTarget(requestId, resumeToken)) {
agentRunRegistry.reject(requestId, resumeToken, userId, reason);
return;
}
agentRunRegistry.reject(requestId, resumeToken, userId, reason,
() -> agentHitlPendingService.reject(resumeToken, account.getId(), reason));
() -> agentHitlPendingService.reject(resumeToken, operatorId, reason));
}
private void dispatchRemoteRuntimeCommand(String requestId,
String resumeToken,
AgentRuntimeCommandAction action,
String reason,
BigInteger operatorId,
String userId) {
String resolvedRequestId = resolveRequestIdForRemoteDispatch(requestId, resumeToken);
AgentRuntimeRoute ownerRoute = agentRuntimeRouteRegistry.findOwnerRoute(resolvedRequestId);
String ownerNodeId = ownerRoute == null ? null : ownerRoute.getNodeId();
if (ownerNodeId == null || ownerNodeId.isBlank()) {
throw new BusinessException("Agent 运行节点不可用,请重新发起对话");
}
if (ownerNodeId.equals(agentRuntimeRouteRegistry.currentNodeId())) {
throw new BusinessException("Agent 运行节点不可用,请重新发起对话");
}
if (!agentRuntimeRouteRegistry.isNodeAlive(ownerNodeId)) {
throw new BusinessException("Agent 运行节点不可用,请重新发起对话");
}
String currentOwnerBootId = agentRuntimeRouteRegistry.currentNodeBootId(ownerNodeId);
if (ownerRoute.getBootId() == null || !ownerRoute.getBootId().equals(currentOwnerBootId)) {
throw new BusinessException("Agent 运行节点不可用,请重新发起对话");
}
if (action == AgentRuntimeCommandAction.APPROVE) {
agentRuntimeCommandProducer.sendApprove(ownerNodeId, resolvedRequestId, resumeToken, operatorId, userId);
return;
}
agentRuntimeCommandProducer.sendReject(ownerNodeId, resolvedRequestId, resumeToken, reason, operatorId, userId);
}
private String resolveRequestIdForRemoteDispatch(String requestId, String resumeToken) {
if (requestId != null && !requestId.isBlank()) {
return requestId;
}
String resolvedRequestId = agentRuntimeRouteRegistry.findRequestIdByResumeToken(resumeToken);
if (resolvedRequestId == null || resolvedRequestId.isBlank()) {
throw new BusinessException("Agent 运行节点不可用,请重新发起对话");
}
return resolvedRequestId;
}
private void startRuntime(Agent agent,
@@ -253,6 +350,7 @@ public class AgentRunService {
ChatRuntimeContext chatContext,
ChatSseEmitter chatSseEmitter,
boolean persistChatlog,
AgentSessionStore runtimeSessionStore,
AgentRunLock.Handle initialLockHandle) {
AtomicBoolean finished = new AtomicBoolean(false);
StringBuilder answer = new StringBuilder();
@@ -262,8 +360,10 @@ public class AgentRunService {
assistantAccumulator, finished, persistChatlog);
AgentRunLock.Handle lockHandle = initialLockHandle;
try {
bindAgentSession(agent, runtimeSessionId, chatContext);
AgentRuntimeBundle bundle = agentDefinitionCompiler.compile(agent);
if (persistChatlog) {
bindAgentSession(agent, runtimeSessionId, chatContext);
}
AgentRuntimeBundle bundle = agentRuntimeCompiler.compile(agent);
AgentRuntime runtime = agentRuntimeFactory.create();
// 会话初始化请求
AgentInitRequest request = new AgentInitRequest();
@@ -272,7 +372,7 @@ public class AgentRunService {
request.setRuntimeContext(buildAgentRuntimeContext(chatContext, traceId, runtimeSessionId));
request.setToolInvokers(bundle.getToolInvokers());
request.setKnowledgeRetrievers(bundle.getKnowledgeRetrievers());
request.setSessionStore(agentSessionStore);
request.setSessionStore(runtimeSessionStore);
request.getMetadata().put("assistantCode", assistantCode);
runtime.init(request);
// 注册会话运行时管理
@@ -346,20 +446,20 @@ public class AgentRunService {
return agentRunLock.acquire(agent == null ? null : agent.getId(), runtimeSessionId);
}
private void recordRuntimeEvent(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) {
if (agentRunEventRecorder != null) {
private void recordRuntimeEvent(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event, boolean persistChatlog) {
if (persistChatlog && agentRunEventRecorder != null) {
agentRunEventRecorder.record(requestId, chatContext, event);
}
}
private void recordApprovalRequired(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) {
if (agentHitlPendingService != null) {
private void recordApprovalRequired(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event, boolean persistChatlog) {
if (persistChatlog && agentHitlPendingService != null) {
agentHitlPendingService.recordApprovalRequired(requestId, chatContext, event);
}
}
private void cancelPending(String requestId, String reason) {
if (agentHitlPendingService != null) {
private void cancelPending(String requestId, String reason, boolean persistChatlog) {
if (persistChatlog && agentHitlPendingService != null) {
agentHitlPendingService.cancelByRequestId(requestId, reason);
}
}
@@ -397,7 +497,7 @@ public class AgentRunService {
}
}
agentRunRegistry.remove(requestId);
cancelPending(requestId, "客户端连接已断开Agent 运行已取消");
cancelPending(requestId, "客户端连接已断开Agent 运行已取消", persistChatlog);
if (!persistChatlog) {
return;
}
@@ -420,7 +520,7 @@ public class AgentRunService {
if (event == null || event.getEventType() == null) {
return;
}
recordRuntimeEvent(requestId, chatContext, event);
recordRuntimeEvent(requestId, chatContext, event, persistChatlog);
if (event.getEventType() == AgentRuntimeEventType.MESSAGE_DELTA) {
String text = stringPayload(event, "text");
if (text != null) {
@@ -448,21 +548,29 @@ public class AgentRunService {
if (event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) {
String resumeToken = stringPayload(event, "resumeToken");
agentRunRegistry.registerResumeToken(requestId, resumeToken);
recordApprovalRequired(requestId, chatContext, event);
recordApprovalRequired(requestId, chatContext, event, persistChatlog);
if (!sendEnvelope(chatSseEmitter, ChatDomain.TOOL, ChatType.FORM_REQUEST, buildToolHitlPayload(requestId, event))) {
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, finished, persistChatlog);
}
return;
}
if (isAsyncToolEvent(event.getEventType())) {
if (!sendEnvelope(chatSseEmitter, ChatDomain.TOOL, asyncToolChatType(event), buildAsyncToolEventPayload(event))) {
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, finished, persistChatlog);
}
return;
}
if (event.getEventType() == AgentRuntimeEventType.TOOL_CALL) {
LOG.info("Agent runtime tool call, requestId={}, toolCallId={}, payload={}, metadata={}",
requestId, event.getToolCallId(), event.getPayload(), event.getMetadata());
Map<String, Object> toolPayload = buildToolEventPayload(event);
assistantAccumulator.appendToolCall(
firstText(event.getToolCallId(), stringPayload(event, "toolCallId")),
firstText(stringPayload(event, "toolName"), stringPayload(event, "name")),
firstNonNull(event.getPayload().get("input"), event.getPayload().get("toolInput"))
firstText(stringValue(toolPayload, "toolCallId"), event.getToolCallId()),
firstText(stringValue(toolPayload, "toolName"), stringValue(toolPayload, "name")),
stringValue(toolPayload, "toolDisplayName"),
firstNonNull(toolPayload.get("input"), toolPayload.get("toolInput"))
);
if (!sendEnvelope(chatSseEmitter, ChatDomain.TOOL, ChatType.TOOL_CALL, buildToolEventPayload(event))) {
if (!sendEnvelope(chatSseEmitter, ChatDomain.TOOL, ChatType.TOOL_CALL, toolPayload)) {
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, finished, persistChatlog);
}
return;
@@ -470,13 +578,15 @@ public class AgentRunService {
if (event.getEventType() == AgentRuntimeEventType.TOOL_RESULT) {
LOG.info("Agent runtime tool result, requestId={}, toolCallId={}, payload={}, metadata={}",
requestId, event.getToolCallId(), event.getPayload(), event.getMetadata());
Map<String, Object> toolPayload = buildToolEventPayload(event);
assistantAccumulator.appendToolResult(
firstText(event.getToolCallId(), stringPayload(event, "toolCallId")),
firstText(stringPayload(event, "toolName"), stringPayload(event, "name")),
firstNonNull(firstNonNull(event.getPayload().get("output"), event.getPayload().get("result")),
event.getPayload().get("text"))
firstText(stringValue(toolPayload, "toolCallId"), event.getToolCallId()),
firstText(stringValue(toolPayload, "toolName"), stringValue(toolPayload, "name")),
stringValue(toolPayload, "toolDisplayName"),
firstNonNull(firstNonNull(toolPayload.get("output"), toolPayload.get("result")),
toolPayload.get("text"))
);
if (!sendEnvelope(chatSseEmitter, ChatDomain.TOOL, ChatType.TOOL_RESULT, buildToolEventPayload(event))) {
if (!sendEnvelope(chatSseEmitter, ChatDomain.TOOL, ChatType.TOOL_RESULT, toolPayload)) {
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, finished, persistChatlog);
}
return;
@@ -587,7 +697,7 @@ public class AgentRunService {
return;
}
agentRunRegistry.remove(requestId);
cancelPending(requestId, safeErrorMessage(error));
cancelPending(requestId, safeErrorMessage(error), persistChatlog);
Throwable safeError = error == null ? new BusinessException("Agent 运行失败") : error;
LOG.error("Agent run failed, requestId={}, message={}, exception={}", requestId,
safeError.getMessage(), safeError.toString(), safeError);
@@ -621,7 +731,7 @@ public class AgentRunService {
}
agentRunRegistry.remove(requestId);
String reason = errorMessage(event);
cancelPending(requestId, reason);
cancelPending(requestId, reason, persistChatlog);
LOG.info("Agent run cancelled, requestId={}, reason={}", requestId, reason);
if (persistChatlog) {
recordPartialAssistantIfPresent(chatContext, answer, assistantAccumulator, reason);
@@ -1079,9 +1189,81 @@ public class AgentRunService {
if (toolCallId != null && !toolCallId.isBlank()) {
payload.put("toolCallId", toolCallId);
}
if (Boolean.TRUE.equals(event.getMetadata().get("asyncTool"))) {
enrichAsyncToolPayload(payload, event.getMetadata(), toolCallId);
String taskId = stringValue(payload, "taskId");
if (taskId != null && !taskId.isBlank()) {
payload.put("toolCallId", taskId);
}
}
return payload;
}
private boolean isAsyncToolEvent(AgentRuntimeEventType type) {
return type == AgentRuntimeEventType.ASYNC_TOOL_SUBMITTED
|| type == AgentRuntimeEventType.ASYNC_TOOL_OBSERVED
|| type == AgentRuntimeEventType.ASYNC_TOOL_RESULT
|| type == AgentRuntimeEventType.ASYNC_TOOL_CANCELLED
|| type == AgentRuntimeEventType.ASYNC_TOOL_LISTED
|| type == AgentRuntimeEventType.ASYNC_TOOL_FAILED;
}
private ChatType asyncToolChatType(AgentRuntimeEvent event) {
String status = stringPayload(event, "status");
if ("SUCCEEDED".equalsIgnoreCase(status)
|| "FAILED".equalsIgnoreCase(status)
|| "CANCELLED".equalsIgnoreCase(status)
|| "TIMEOUT".equalsIgnoreCase(status)
|| event.getEventType() == AgentRuntimeEventType.ASYNC_TOOL_RESULT
|| event.getEventType() == AgentRuntimeEventType.ASYNC_TOOL_FAILED
|| event.getEventType() == AgentRuntimeEventType.ASYNC_TOOL_CANCELLED) {
return ChatType.TOOL_RESULT;
}
return ChatType.TOOL_CALL;
}
private Map<String, Object> buildAsyncToolEventPayload(AgentRuntimeEvent event) {
Map<String, Object> payload = new LinkedHashMap<>(event.getPayload() == null ? Map.of() : event.getPayload());
String taskId = stringValue(payload, "taskId");
String toolCallId = firstText(taskId, event.getToolCallId());
if (toolCallId != null && !toolCallId.isBlank()) {
payload.put("toolCallId", toolCallId);
}
enrichAsyncToolPayload(payload, event.getMetadata(), toolCallId);
return payload;
}
private void enrichAsyncToolPayload(Map<String, Object> payload, Map<String, Object> metadata, String fallbackId) {
Map<String, Object> safeMetadata = metadata == null ? Map.of() : metadata;
payload.put("asyncTool", true);
putIfPresent(payload, "asyncToolName", firstText(stringValue(payload, "asyncToolName"), stringValue(safeMetadata, "asyncToolName")));
putIfPresent(payload, "phase", firstText(stringValue(payload, "phase"), stringValue(safeMetadata, "asyncToolPhase")));
putIfPresent(payload, "taskId", firstText(stringValue(payload, "taskId"), stringValue(safeMetadata, "taskId")));
putIfPresent(payload, "status", firstText(stringValue(payload, "status"), stringValue(safeMetadata, "status")));
String displayName = firstText(stringValue(payload, "toolDisplayName"),
firstText(stringValue(safeMetadata, "toolDisplayName"), stringValue(payload, "asyncToolName")));
putIfPresent(payload, "toolDisplayName", displayName);
putIfPresent(payload, "toolName", displayName);
putIfPresent(payload, "name", displayName);
String statusKey = "async-tool:" + firstText(stringValue(payload, "taskId"), fallbackId);
payload.put("statusKey", statusKey);
payload.put("label", asyncToolLabel(stringValue(payload, "status"), stringValue(payload, "phase"), displayName));
}
private String asyncToolLabel(String status, String phase, String displayName) {
String name = displayName == null || displayName.isBlank() ? "异步工具" : displayName;
if ("SUCCEEDED".equalsIgnoreCase(status)) {
return name + "已完成";
}
if ("FAILED".equalsIgnoreCase(status) || "TIMEOUT".equalsIgnoreCase(status)) {
return name + "执行失败";
}
if ("PENDING".equalsIgnoreCase(status) || "submit".equalsIgnoreCase(phase)) {
return name + "已提交";
}
return name + "执行中";
}
/**
* 构建知识库检索状态载荷,确保前端可按稳定 key 合并同一轮状态行。
*

View File

@@ -10,6 +10,8 @@ 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;
@@ -27,8 +29,9 @@ 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.McpTool;
import tech.easyflow.ai.easyagents.tool.WorkflowTool;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.*;
@@ -40,16 +43,19 @@ 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 发布快照编译为 easy-agents-agent-runtime 可执行定义
* Agent 发布快照编译为可执行定义
*/
@Component
public class AgentDefinitionCompiler {
public class AgentRuntimeCompiler {
private static final Logger LOG = LoggerFactory.getLogger(AgentDefinitionCompiler.class);
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;
@@ -63,6 +69,8 @@ public class AgentDefinitionCompiler {
private DocumentCollectionService documentCollectionService;
@Resource
private ObjectMapper objectMapper;
@Resource
private AgentToolRuntimeCompiler agentToolRuntimeCompiler;
/**
* 编译 Agent 运行时定义和调用器
@@ -205,22 +213,10 @@ public class AgentDefinitionCompiler {
}
private void compileTools(Agent agent, AgentDefinition definition, AgentRuntimeBundle bundle) {
if (agent.getToolBindings() == null) {
return;
}
List<AgentToolSpec> specs = new ArrayList<>();
Map<String, com.easyagents.agent.runtime.tool.AgentToolInvoker> invokers = new LinkedHashMap<>();
for (AgentToolBinding binding : agent.getToolBindings()) {
if (!Boolean.TRUE.equals(binding.getEnabled())) {
continue;
}
Tool tool = buildTool(binding);
AgentToolSpec spec = toToolSpec(tool, binding);
specs.add(spec);
invokers.put(spec.getName(), (arguments, context) -> invokeTool(tool, arguments));
}
definition.setToolSpecs(specs);
bundle.setToolInvokers(invokers);
AgentToolRuntimeCompilation compilation = agentToolRuntimeCompiler.compile(agent);
definition.setToolSpecs(compilation.getToolSpecs());
definition.setMcpSpecs(compilation.getMcpSpecs());
bundle.setToolInvokers(compilation.getToolInvokers());
}
private Tool buildTool(AgentToolBinding binding) {
@@ -243,16 +239,74 @@ public class AgentDefinitionCompiler {
}
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 不存在");
}
McpTool tool = new McpTool();
tool.setMcpId(mcp.getId());
tool.setName(binding.getToolName());
tool.setDescription(mcp.getDescription());
tool.setParameters(new Parameter[0]);
return tool;
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) {
@@ -477,6 +531,138 @@ public class AgentDefinitionCompiler {
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);

View File

@@ -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();
}
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -5,6 +5,7 @@ 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;
@@ -32,6 +33,7 @@ public class AgentHitlPendingExpirationTask {
* 定期将超时 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);

View File

@@ -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(':', '_');
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -276,20 +276,22 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
summary.put("bindingId", binding.getId());
summary.put("toolType", binding.getToolType());
summary.put("targetId", binding.getTargetId());
summary.put("toolName", binding.getToolName());
summary.put("enabled", Boolean.TRUE.equals(binding.getEnabled()));
summary.put("hitlEnabled", Boolean.TRUE.equals(binding.getHitlEnabled()));
summary.put("hitlConfigJson", binding.getHitlConfigJson());
summary.put("sortNo", binding.getSortNo());
if ("WORKFLOW".equalsIgnoreCase(binding.getToolType())) {
summary.put("toolName", binding.getToolName());
Workflow workflow = workflowService.getById(binding.getTargetId());
summary.put("title", workflow == null ? null : workflow.getTitle());
} else if ("PLUGIN".equalsIgnoreCase(binding.getToolType())) {
summary.put("toolName", binding.getToolName());
PluginItem pluginItem = pluginItemService.getById(binding.getTargetId());
summary.put("title", pluginItem == null ? null : pluginItem.getName());
} else {
Mcp mcp = mcpService.getById(binding.getTargetId());
summary.put("title", mcp == null ? null : mcp.getTitle());
summary.put("tools", mcp == null || mcp.getTools() == null ? List.of() : mcp.getTools());
}
return summary;
}

View File

@@ -0,0 +1,159 @@
package tech.easyflow.agent.distributed;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.agent.config.AgentRuntimeProperties;
import tech.easyflow.agent.distributed.AgentRuntimeCommandAction;
import tech.easyflow.agent.distributed.AgentRuntimeCommandConsumer;
import tech.easyflow.agent.distributed.AgentRuntimeCommandMessage;
import tech.easyflow.agent.distributed.AgentRuntimeCommandResultRegistry;
import tech.easyflow.agent.runtime.AgentRunService;
import tech.easyflow.common.mq.config.MQProperties;
import tech.easyflow.common.mq.core.MQMessage;
import java.math.BigInteger;
import java.util.List;
/**
* {@link AgentRuntimeCommandConsumer} 回归测试。
*/
public class AgentRuntimeCommandConsumerTest {
/**
* 验证消费者只处理发给当前节点的命令。
*
* @throws Exception 消息序列化异常
*/
@Test
public void consumerShouldHandleOnlyCurrentNodeCommand() throws Exception {
AgentRuntimeProperties properties = new AgentRuntimeProperties();
properties.setInstanceId("node-a");
MQProperties mqProperties = new MQProperties();
mqProperties.getRedis().setChatPersistShardCount(4);
RecordingAgentRunService service = new RecordingAgentRunService();
RecordingCommandResultRegistry resultRegistry = new RecordingCommandResultRegistry();
AgentRuntimeCommandConsumer consumer =
new AgentRuntimeCommandConsumer(new ObjectMapper(), properties, mqProperties, service, resultRegistry);
consumer.handle(List.of(message(command("cmd-1", "node-b")), message(command("cmd-2", "node-a"))));
Assert.assertEquals(1, service.approveCount);
Assert.assertEquals("request-cmd-2", service.lastRequestId);
Assert.assertEquals(4, consumer.subscription().getShardCount());
Assert.assertFalse(consumer.subscription().isBatchEnabled());
Assert.assertEquals("cmd-2", resultRegistry.lastSuccessCommandId);
}
/**
* 验证 owner 本机执行失败时写入失败结果,避免 MQ 重试重复消费一次性 token。
*
* @throws Exception 消息序列化异常
*/
@Test
public void consumerShouldMarkFailureWhenLocalRuntimeFails() throws Exception {
AgentRuntimeProperties properties = new AgentRuntimeProperties();
properties.setInstanceId("node-a");
MQProperties mqProperties = new MQProperties();
FailingAgentRunService service = new FailingAgentRunService();
RecordingCommandResultRegistry resultRegistry = new RecordingCommandResultRegistry();
AgentRuntimeCommandConsumer consumer =
new AgentRuntimeCommandConsumer(new ObjectMapper(), properties, mqProperties, service, resultRegistry);
consumer.handle(List.of(message(command("cmd-1", "node-a"))));
Assert.assertEquals("cmd-1", resultRegistry.lastFailureCommandId);
Assert.assertEquals("runtime missing", resultRegistry.lastFailureMessage);
}
/**
* 验证成功结果写入失败不会再次执行或改写为失败结果。
*
* @throws Exception 消息序列化异常
*/
@Test
public void consumerShouldNotMarkFailureWhenSuccessResultWriteFails() throws Exception {
AgentRuntimeProperties properties = new AgentRuntimeProperties();
properties.setInstanceId("node-a");
MQProperties mqProperties = new MQProperties();
RecordingAgentRunService service = new RecordingAgentRunService();
FailingSuccessResultRegistry resultRegistry = new FailingSuccessResultRegistry();
AgentRuntimeCommandConsumer consumer =
new AgentRuntimeCommandConsumer(new ObjectMapper(), properties, mqProperties, service, resultRegistry);
consumer.handle(List.of(message(command("cmd-1", "node-a"))));
Assert.assertEquals(1, service.approveCount);
Assert.assertNull(resultRegistry.lastFailureCommandId);
}
private AgentRuntimeCommandMessage command(String commandId, String targetNodeId) {
AgentRuntimeCommandMessage command = new AgentRuntimeCommandMessage();
command.setCommandId(commandId);
command.setRequestId("request-" + commandId);
command.setResumeToken("token-" + commandId);
command.setAction(AgentRuntimeCommandAction.APPROVE);
command.setOperatorId(BigInteger.ONE);
command.setUserId("1");
command.setTargetNodeId(targetNodeId);
return command;
}
private MQMessage message(AgentRuntimeCommandMessage command) throws Exception {
MQMessage message = new MQMessage();
message.setMessageId(command.getCommandId());
message.setBody(new ObjectMapper().writeValueAsString(command));
return message;
}
private static final class RecordingAgentRunService extends AgentRunService {
private int approveCount;
private String lastRequestId;
@Override
public void approveRuntimeLocal(String requestId, String resumeToken, BigInteger operatorId, String userId) {
approveCount++;
lastRequestId = requestId;
}
}
private static class RecordingCommandResultRegistry extends AgentRuntimeCommandResultRegistry {
private String lastSuccessCommandId;
String lastFailureCommandId;
private String lastFailureMessage;
private RecordingCommandResultRegistry() {
super(null, null, null);
}
@Override
public void markSuccess(String commandId) {
lastSuccessCommandId = commandId;
}
@Override
public void markFailure(String commandId, String message) {
lastFailureCommandId = commandId;
lastFailureMessage = message;
}
}
private static final class FailingAgentRunService extends AgentRunService {
@Override
public void approveRuntimeLocal(String requestId, String resumeToken, BigInteger operatorId, String userId) {
throw new RuntimeException("runtime missing");
}
}
private static final class FailingSuccessResultRegistry extends RecordingCommandResultRegistry {
@Override
public void markSuccess(String commandId) {
super.markSuccess(commandId);
throw new RuntimeException("redis down");
}
}
}

View File

@@ -0,0 +1,91 @@
package tech.easyflow.agent.distributed;
import com.fasterxml.jackson.databind.ObjectMapper;
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 tech.easyflow.agent.config.AgentRuntimeProperties;
import tech.easyflow.agent.distributed.AgentRuntimeCommandResult;
import tech.easyflow.agent.distributed.AgentRuntimeCommandResultRegistry;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.time.Duration;
/**
* {@link AgentRuntimeCommandResultRegistry} 回归测试。
*/
public class AgentRuntimeCommandResultRegistryTest {
/**
* 验证成功结果可被等待方读取。
*/
@Test
public void waitForResultShouldReturnSuccessResult() {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> valueOperations = Mockito.mock(ValueOperations.class);
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
Mockito.when(valueOperations.get("easyflow:agent:runtime:command-result:cmd-1"))
.thenReturn("{\"success\":true,\"message\":\"OK\"}");
AgentRuntimeCommandResultRegistry registry = registry(redisTemplate);
AgentRuntimeCommandResult result = registry.waitForResult("cmd-1");
Assert.assertTrue(result.isSuccess());
Assert.assertEquals("OK", result.getMessage());
}
/**
* 验证写入失败结果时使用配置的 TTL。
*/
@Test
public void markFailureShouldWriteResultWithTtl() {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> valueOperations = Mockito.mock(ValueOperations.class);
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
AgentRuntimeProperties properties = properties();
AgentRuntimeCommandResultRegistry registry =
new AgentRuntimeCommandResultRegistry(redisTemplate, new ObjectMapper(), properties);
registry.markFailure("cmd-1", "failed");
Mockito.verify(valueOperations).set(
ArgumentMatchers.eq("easyflow:agent:runtime:command-result:cmd-1"),
ArgumentMatchers.contains("\"success\":false"),
ArgumentMatchers.eq(properties.getCommandResultTtl()));
}
/**
* 验证等待超时时抛出明确业务异常。
*/
@Test
public void waitForResultShouldThrowBusinessExceptionWhenTimeout() {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> valueOperations = Mockito.mock(ValueOperations.class);
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
Mockito.when(valueOperations.get(ArgumentMatchers.anyString())).thenReturn(null);
AgentRuntimeCommandResultRegistry registry = registry(redisTemplate);
BusinessException exception = Assert.assertThrows(
BusinessException.class,
() -> registry.waitForResult("cmd-1"));
Assert.assertEquals("Agent 运行节点响应超时,请稍后重试", exception.getMessage());
}
private AgentRuntimeCommandResultRegistry registry(StringRedisTemplate redisTemplate) {
return new AgentRuntimeCommandResultRegistry(redisTemplate, new ObjectMapper(), properties());
}
private AgentRuntimeProperties properties() {
AgentRuntimeProperties properties = new AgentRuntimeProperties();
properties.setCommandResultTimeout(Duration.ofMillis(10));
properties.setCommandResultTtl(Duration.ofMinutes(5));
return properties;
}
}

View File

@@ -0,0 +1,108 @@
package tech.easyflow.agent.distributed;
import com.fasterxml.jackson.databind.ObjectMapper;
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 tech.easyflow.agent.config.AgentRuntimeProperties;
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
import java.time.Duration;
/**
* {@link AgentRuntimeRouteRegistry} 回归测试。
*/
public class AgentRuntimeRouteRegistryTest {
/**
* 验证注册运行态和恢复令牌时写入 Redis 路由。
*/
@Test
public void registerShouldWriteRunAndTokenRoutes() {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> valueOperations = Mockito.mock(ValueOperations.class);
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
AgentRuntimeProperties properties = properties("node-a");
AgentRuntimeRouteRegistry registry = registry(redisTemplate, properties);
registry.registerRun("request-1");
registry.registerResumeToken("request-1", "token-1");
Mockito.verify(valueOperations).set(
ArgumentMatchers.eq("easyflow:agent:runtime:request:request-1"),
ArgumentMatchers.contains("\"nodeId\":\"node-a\""),
ArgumentMatchers.eq(Duration.ofHours(24)));
Mockito.verify(valueOperations).set(
"easyflow:agent:runtime:resume-token:token-1", "request-1", Duration.ofHours(24));
}
/**
* 验证运行结束时清理 Redis 路由。
*/
@Test
public void removeShouldDeleteRunAndTokenRoutes() {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
AgentRuntimeRouteRegistry registry = registry(redisTemplate, properties("node-a"));
registry.removeRun("request-1");
registry.removeResumeToken("token-1");
Mockito.verify(redisTemplate).delete("easyflow:agent:runtime:request:request-1");
Mockito.verify(redisTemplate).delete("easyflow:agent:runtime:resume-token:token-1");
}
/**
* 验证查询 owner 节点和 token 反查请求 ID。
*/
@Test
public void findShouldReadRoutes() {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> valueOperations = Mockito.mock(ValueOperations.class);
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
Mockito.when(valueOperations.get(ArgumentMatchers.eq("easyflow:agent:runtime:request:request-1")))
.thenReturn("{\"nodeId\":\"node-a\",\"bootId\":\"boot-a\"}");
Mockito.when(valueOperations.get(ArgumentMatchers.eq("easyflow:agent:runtime:resume-token:token-1")))
.thenReturn("request-1");
AgentRuntimeRouteRegistry registry = registry(redisTemplate, properties("node-a"));
Assert.assertEquals("node-a", registry.findOwnerNode("request-1"));
Assert.assertEquals("boot-a", registry.findOwnerRoute("request-1").getBootId());
Assert.assertEquals("request-1", registry.findRequestIdByResumeToken("token-1"));
}
/**
* 验证节点心跳写入和存活查询。
*/
@Test
public void heartbeatShouldWriteAndReadNodeAliveState() {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> valueOperations = Mockito.mock(ValueOperations.class);
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
AgentRuntimeProperties properties = properties("node-a");
Mockito.when(valueOperations.get("easyflow:agent:runtime:node:node-a")).thenReturn(properties.getBootId());
AgentRuntimeRouteRegistry registry = registry(redisTemplate, properties);
registry.heartbeat(Duration.ofSeconds(90));
Mockito.verify(valueOperations).set("easyflow:agent:runtime:node:node-a", properties.getBootId(), Duration.ofSeconds(90));
Assert.assertTrue(registry.isNodeAlive("node-a"));
Assert.assertEquals(properties.getBootId(), registry.currentNodeBootId("node-a"));
}
private AgentRuntimeProperties properties(String instanceId) {
AgentRuntimeProperties properties = new AgentRuntimeProperties();
properties.setInstanceId(instanceId);
properties.setRouteTtl(Duration.ofHours(24));
return properties;
}
private AgentRuntimeRouteRegistry registry(StringRedisTemplate redisTemplate, AgentRuntimeProperties properties) {
return new AgentRuntimeRouteRegistry(redisTemplate, properties, new ObjectMapper());
}
}

View File

@@ -0,0 +1,155 @@
package tech.easyflow.agent.runtime;
import com.easyagents.agent.runtime.AgentDefinition;
import com.easyagents.agent.runtime.mcp.McpSpec;
import com.easyagents.agent.runtime.mcp.McpTransportType;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.enums.AgentToolType;
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler;
import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.ai.service.McpService;
import tech.easyflow.ai.service.ModelService;
import java.lang.reflect.Field;
import java.lang.reflect.Proxy;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
/**
* Agent MCP 运行时定义编译测试。
*/
public class AgentDefinitionCompilerMcpTest {
/**
* 验证 Agent 绑定 MCP 后会编译为 runtime 原生 MCP 声明,并按整个 MCP 暴露工具。
*
* @throws Exception 反射注入依赖失败时抛出
*/
@Test
public void compileShouldBuildWholeMcpSpecWithDynamicPrefixAndApproval() throws Exception {
BigInteger modelId = BigInteger.valueOf(10L);
BigInteger mcpId = BigInteger.valueOf(20L);
Model model = model(modelId);
Mcp mcp = mcp(mcpId);
AgentRuntimeCompiler compiler = new AgentRuntimeCompiler();
AgentToolRuntimeCompiler toolCompiler = new AgentToolRuntimeCompiler();
setField(compiler, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper());
setField(compiler, "modelService", modelService(model));
setField(toolCompiler, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper());
setField(toolCompiler, "mcpService", mcpService(mcp));
setField(compiler, "agentToolRuntimeCompiler", toolCompiler);
Agent agent = agent(modelId, mcpId);
AgentRuntimeBundle bundle = compiler.compile(agent);
AgentDefinition definition = bundle.getDefinition();
Assert.assertTrue(definition.getToolSpecs().isEmpty());
Assert.assertTrue(bundle.getToolInvokers().isEmpty());
Assert.assertEquals(1, definition.getMcpSpecs().size());
McpSpec spec = definition.getMcpSpecs().get(0);
Assert.assertEquals("mcp_20", spec.getName());
Assert.assertEquals(McpTransportType.STDIO, spec.getTransportType());
Assert.assertEquals("npx", spec.getCommand());
Assert.assertEquals(List.of("-y", "@modelcontextprotocol/server-everything"), spec.getArgs());
Assert.assertTrue(spec.isApprovalRequired());
Assert.assertEquals("mcp_20_", spec.getToolNamePrefix());
Assert.assertTrue(spec.getToolAliases().isEmpty());
Assert.assertTrue(spec.getEnableTools().isEmpty());
Assert.assertEquals(AgentToolType.MCP.name(), spec.getMetadata().get("toolType"));
Assert.assertEquals(String.valueOf(mcpId), spec.getMetadata().get("mcpId"));
Assert.assertEquals("everything", spec.getMetadata().get("serverName"));
Assert.assertTrue(spec.getToolApprovalRequests().isEmpty());
Assert.assertEquals("确认调用 MCP 工具?", spec.getApprovalRequest().getApprovalPrompt());
}
private Agent agent(BigInteger modelId, BigInteger mcpId) {
AgentToolBinding binding = new AgentToolBinding();
binding.setToolType(AgentToolType.MCP.name());
binding.setTargetId(mcpId);
binding.setEnabled(true);
binding.setHitlEnabled(true);
binding.setHitlConfigJson(Map.of("prompt", "确认调用 MCP 工具?"));
Agent agent = new Agent();
agent.setId(BigInteger.valueOf(1L));
agent.setName("MCP Agent");
agent.setModelId(modelId);
agent.setToolBindings(List.of(binding));
return agent;
}
private Model model(BigInteger modelId) {
ModelProvider provider = new ModelProvider();
provider.setProviderType("openai");
provider.setProviderName("OpenAI");
Model model = new Model();
model.setId(modelId);
model.setModelProvider(provider);
model.setModelName("gpt-test");
model.setEndpoint("https://example.com");
model.setRequestPath("/v1/chat/completions");
model.setApiKey("test-key");
return model;
}
private Mcp mcp(BigInteger mcpId) {
Mcp mcp = new Mcp();
mcp.setId(mcpId);
mcp.setTitle("Everything");
mcp.setDescription("MCP Everything");
mcp.setApprovalRequired(true);
mcp.setStatus(true);
mcp.setConfigJson("""
{
"mcpServers": {
"everything": {
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-everything"]
}
}
}
""");
return mcp;
}
private ModelService modelService(Model model) {
return (ModelService) Proxy.newProxyInstance(
ModelService.class.getClassLoader(),
new Class<?>[]{ModelService.class},
(proxy, method, args) -> "getModelInstance".equals(method.getName()) ? model : defaultValue(method.getReturnType()));
}
private McpService mcpService(Mcp mcp) {
return (McpService) Proxy.newProxyInstance(
McpService.class.getClassLoader(),
new Class<?>[]{McpService.class},
(proxy, method, args) -> "getById".equals(method.getName()) ? mcp : defaultValue(method.getReturnType()));
}
private Object defaultValue(Class<?> type) {
if (type == boolean.class) {
return false;
}
if (type == int.class || type == long.class || type == short.class || type == byte.class) {
return 0;
}
if (type == double.class || type == float.class) {
return 0D;
}
return null;
}
private void setField(Object target, String fieldName, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
}

View File

@@ -1,15 +1,25 @@
package tech.easyflow.agent.runtime;
import com.easyagents.agent.runtime.AgentInitRequest;
import com.easyagents.agent.runtime.AgentRuntime;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
import com.easyagents.agent.runtime.message.AgentMessage;
import com.easyagents.agent.runtime.message.AgentMessageRole;
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.agent.entity.AgentHitlPending;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.distributed.AgentRuntimeCommandProducer;
import tech.easyflow.agent.distributed.AgentRuntimeRoute;
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
import tech.easyflow.agent.runtime.event.AgentRunEventRecorder;
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
import tech.easyflow.agent.runtime.lock.AgentRunLock;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.common.entity.LoginAccount;
@@ -402,14 +412,283 @@ public class AgentRunServiceDraftAndHitlTest {
Exception thrown = Assert.assertThrows(Exception.class, () -> invoke(service, "run",
new Class<?>[]{Agent.class, String.class, String.class, String.class, String.class,
String.class, ChatRuntimeContext.class, boolean.class},
agent, "你好", "request-lock", "trace-lock", "session-lock", "AGENT", context, true));
String.class, ChatRuntimeContext.class, boolean.class, AgentSessionStore.class},
agent, "你好", "request-lock", "trace-lock", "session-lock", "AGENT", context, true,
new InMemoryAgentSessionStore()));
Assert.assertTrue(rootCause(thrown) instanceof BusinessException);
Assert.assertEquals(0, chatRuntimeManager.prepareSessionCount);
Assert.assertEquals(0, chatRuntimeManager.recordUserMessageCount);
}
/**
* 验证草稿运行会使用独立 session store且不会绑定 MySQL session 元信息。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void startRuntimeShouldUseDraftSessionStoreWithoutBindingMysqlSession() throws Exception {
AgentRunService service = new AgentRunService();
RecordingAgentRuntimeCompiler compiler = new RecordingAgentRuntimeCompiler();
RecordingAgentRuntime runtime = new RecordingAgentRuntime();
RecordingAgentRuntimeFactory runtimeFactory = new RecordingAgentRuntimeFactory(runtime);
AgentSessionStore draftStore = new InMemoryAgentSessionStore();
setField(service, "agentRuntimeCompiler", compiler);
setField(service, "agentRuntimeFactory", runtimeFactory);
setField(service, "agentRunRegistry", new AgentRunRegistry());
Agent agent = new Agent();
agent.setId(BigInteger.valueOf(100));
invoke(service, "startRuntime",
new Class<?>[]{Agent.class, String.class, String.class, String.class, String.class, String.class,
ChatRuntimeContext.class, ChatSseEmitter.class, boolean.class, AgentSessionStore.class,
AgentRunLock.Handle.class},
agent, "你好", "request-draft", "trace-draft", "agent-draft-100", "AGENT_DRAFT",
chatContext(), new RecordingChatSseEmitter(), false, draftStore, null);
Assert.assertSame(draftStore, runtime.initRequest.getSessionStore());
}
/**
* 验证草稿事件不会写运行事件表,正式事件仍会记录。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void handleRuntimeEventShouldOnlyPersistEventsForFormalChat() throws Exception {
AgentRunService service = new AgentRunService();
setField(service, "agentRunRegistry", new AgentRunRegistry());
RecordingAgentRunEventRecorder recorder = new RecordingAgentRunEventRecorder();
setField(service, "agentRunEventRecorder", recorder);
AgentRuntimeEvent draftEvent = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_CALL);
draftEvent.getPayload().put("toolName", "search");
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
draftEvent, "request-draft", new RecordingChatSseEmitter(), new StringBuilder(),
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);
Assert.assertEquals(0, recorder.recordCount);
AgentRuntimeEvent formalEvent = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_CALL);
formalEvent.getPayload().put("toolName", "search");
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
formalEvent, "request-formal", new RecordingChatSseEmitter(), new StringBuilder(),
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), true);
Assert.assertEquals(1, recorder.recordCount);
}
/**
* 验证草稿工具审批只注册内存恢复令牌,不写 HITL pending 表。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void draftToolApprovalShouldNotPersistPending() throws Exception {
AgentRunService service = new AgentRunService();
AgentRunRegistry registry = new AgentRunRegistry();
RecordingAgentHitlPendingService pendingService = new RecordingAgentHitlPendingService();
setField(service, "agentRunRegistry", registry);
setField(service, "agentHitlPendingService", pendingService);
registry.register(runContext("request-draft", "agent-draft-tool", false));
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED);
event.getPayload().put("resumeToken", "token-draft");
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-draft", new RecordingChatSseEmitter(), new StringBuilder(),
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);
Assert.assertTrue(registry.containsResumeTarget("request-draft", "token-draft"));
Assert.assertEquals(0, pendingService.recordApprovalRequiredCount);
}
/**
* 验证草稿审批恢复不执行 pending 表消费,正式审批仍执行。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void approveShouldSkipPendingConsumeOnlyForDraftRun() throws Exception {
AgentRunService service = new AgentRunService();
AgentRunRegistry registry = new AgentRunRegistry();
RecordingAgentHitlPendingService pendingService = new RecordingAgentHitlPendingService();
setField(service, "agentRunRegistry", registry);
setField(service, "agentHitlPendingService", pendingService);
registry.register(runContext("request-draft-approve", "agent-draft-approve", false));
registry.registerResumeToken("request-draft-approve", "token-draft-approve");
invoke(service, "approveRuntime",
new Class<?>[]{String.class, String.class, BigInteger.class, String.class},
"request-draft-approve", "token-draft-approve", BigInteger.ONE, "1");
Assert.assertEquals(0, pendingService.approveCount);
registry.register(runContext("request-formal-approve", "session-formal-approve", true));
registry.registerResumeToken("request-formal-approve", "token-formal-approve");
invoke(service, "approveRuntime",
new Class<?>[]{String.class, String.class, BigInteger.class, String.class},
"request-formal-approve", "token-formal-approve", BigInteger.ONE, "1");
Assert.assertEquals(1, pendingService.approveCount);
}
/**
* 验证本机存在恢复目标时不投递远程命令。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void approveShouldNotDispatchRemoteWhenLocalRuntimeExists() throws Exception {
AgentRunService service = new AgentRunService();
AgentRunRegistry registry = new AgentRunRegistry();
RecordingAgentHitlPendingService pendingService = new RecordingAgentHitlPendingService();
RecordingRouteRegistry routeRegistry = new RecordingRouteRegistry("node-a");
RecordingCommandProducer commandProducer = new RecordingCommandProducer();
setField(service, "agentRunRegistry", registry);
setField(service, "agentHitlPendingService", pendingService);
setField(service, "agentRuntimeRouteRegistry", routeRegistry);
setField(service, "agentRuntimeCommandProducer", commandProducer);
registry.register(runContext("request-local-approve", "session-local-approve", true));
registry.registerResumeToken("request-local-approve", "token-local-approve");
invoke(service, "approveRuntime",
new Class<?>[]{String.class, String.class, BigInteger.class, String.class},
"request-local-approve", "token-local-approve", BigInteger.ONE, "1");
Assert.assertEquals(1, pendingService.approveCount);
Assert.assertEquals(0, commandProducer.approveCount);
}
/**
* 验证本机无运行态但 Redis owner 存在时投递远程命令。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void approveShouldDispatchRemoteWhenOwnerIsRemoteNode() throws Exception {
AgentRunService service = new AgentRunService();
RecordingRouteRegistry routeRegistry = new RecordingRouteRegistry("node-b");
routeRegistry.requestIdByToken = "request-remote-approve";
routeRegistry.ownerNode = "node-a";
routeRegistry.ownerBootId = "boot-a";
routeRegistry.currentOwnerBootId = "boot-a";
routeRegistry.nodeAlive = true;
RecordingCommandProducer commandProducer = new RecordingCommandProducer();
setField(service, "agentRunRegistry", new AgentRunRegistry());
setField(service, "agentRuntimeRouteRegistry", routeRegistry);
setField(service, "agentRuntimeCommandProducer", commandProducer);
invoke(service, "approveRuntime",
new Class<?>[]{String.class, String.class, BigInteger.class, String.class},
null, "token-remote-approve", BigInteger.ONE, "1");
Assert.assertEquals(1, commandProducer.approveCount);
Assert.assertEquals("node-a", commandProducer.lastTargetNodeId);
Assert.assertEquals("request-remote-approve", commandProducer.lastRequestId);
}
/**
* 验证 owner 缺失时明确失败。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void approveShouldFailWhenOwnerRouteMissing() throws Exception {
AgentRunService service = new AgentRunService();
RecordingRouteRegistry routeRegistry = new RecordingRouteRegistry("node-b");
routeRegistry.requestIdByToken = "request-missing-owner";
setField(service, "agentRunRegistry", new AgentRunRegistry());
setField(service, "agentRuntimeRouteRegistry", routeRegistry);
setField(service, "agentRuntimeCommandProducer", new RecordingCommandProducer());
try {
invoke(service, "approveRuntime",
new Class<?>[]{String.class, String.class, BigInteger.class, String.class},
null, "token-missing-owner", BigInteger.ONE, "1");
Assert.fail("expected BusinessException");
} catch (Exception e) {
Assert.assertTrue(rootCause(e) instanceof BusinessException);
}
}
/**
* 验证 owner 重启后启动代不匹配会明确失败。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void approveShouldFailWhenOwnerBootIdChanged() throws Exception {
AgentRunService service = new AgentRunService();
RecordingRouteRegistry routeRegistry = new RecordingRouteRegistry("node-b");
routeRegistry.requestIdByToken = "request-restarted-owner";
routeRegistry.ownerNode = "node-a";
routeRegistry.ownerBootId = "boot-old";
routeRegistry.currentOwnerBootId = "boot-new";
routeRegistry.nodeAlive = true;
setField(service, "agentRunRegistry", new AgentRunRegistry());
setField(service, "agentRuntimeRouteRegistry", routeRegistry);
setField(service, "agentRuntimeCommandProducer", new RecordingCommandProducer());
try {
invoke(service, "approveRuntime",
new Class<?>[]{String.class, String.class, BigInteger.class, String.class},
null, "token-restarted-owner", BigInteger.ONE, "1");
Assert.fail("expected BusinessException");
} catch (Exception e) {
Assert.assertTrue(rootCause(e) instanceof BusinessException);
}
}
/**
* 验证 owner 路由存在但节点心跳缺失时明确失败。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void approveShouldFailWhenOwnerNodeHeartbeatMissing() throws Exception {
AgentRunService service = new AgentRunService();
RecordingRouteRegistry routeRegistry = new RecordingRouteRegistry("node-b");
routeRegistry.requestIdByToken = "request-offline-owner";
routeRegistry.ownerNode = "node-a";
routeRegistry.nodeAlive = false;
setField(service, "agentRunRegistry", new AgentRunRegistry());
setField(service, "agentRuntimeRouteRegistry", routeRegistry);
setField(service, "agentRuntimeCommandProducer", new RecordingCommandProducer());
try {
invoke(service, "approveRuntime",
new Class<?>[]{String.class, String.class, BigInteger.class, String.class},
null, "token-offline-owner", BigInteger.ONE, "1");
Assert.fail("expected BusinessException");
} catch (Exception e) {
Assert.assertTrue(rootCause(e) instanceof BusinessException);
}
}
/**
* 验证清理草稿会话只清草稿 store不触碰 MySQL pending 清理。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void clearDraftSessionShouldOnlyDeleteDraftStore() throws Exception {
AgentRunService service = new AgentRunService();
RecordingAgentHitlPendingService pendingService = new RecordingAgentHitlPendingService();
RecordingAgentSessionStore draftStore = new RecordingAgentSessionStore();
setField(service, "agentRunRegistry", new AgentRunRegistry());
setField(service, "agentHitlPendingService", pendingService);
setField(service, "draftAgentSessionStore", draftStore);
invoke(service, "clearDraftSessionInternal",
new Class<?>[]{String.class, String.class}, "agent-draft-clear", "1");
Assert.assertEquals("agent-draft-clear", draftStore.deletedSessionKey);
Assert.assertEquals(0, pendingService.deleteByRuntimeSessionIdCount);
}
/**
* 验证正式聊天会在会话准备完成后向前端返回真实会话 ID。
*
@@ -530,6 +809,28 @@ public class AgentRunServiceDraftAndHitlTest {
ChatRuntimeContext.class, AtomicBoolean.class, boolean.class};
}
private AgentRunRegistry.AgentRunContext runContext(String requestId, String sessionId, boolean persistChatlog) {
return new AgentRunRegistry.AgentRunContext(
requestId,
sessionId,
new RecordingAgentRuntime(),
new RecordingChatSseEmitter(),
chatContext(),
new StringBuilder(),
new ChatAssistantAccumulator(),
new AtomicBoolean(false),
persistChatlog,
new AgentRunRegistry.RunOwner("agent-1", sessionId, "1"),
null,
event -> {
},
error -> {
},
() -> {
}
);
}
private ChatRuntimeContext chatContext() {
ChatRuntimeContext context = new ChatRuntimeContext();
context.setAssistantId(BigInteger.valueOf(100));
@@ -598,6 +899,214 @@ public class AgentRunServiceDraftAndHitlTest {
}
}
private static class RecordingAgentRuntime implements AgentRuntime {
private AgentInitRequest initRequest;
private int resumeCount;
@Override
public void init(AgentInitRequest request) {
initRequest = request;
}
@Override
public reactor.core.publisher.Flux<AgentRuntimeEvent> stream(AgentMessage userMessage) {
return reactor.core.publisher.Flux.empty();
}
@Override
public reactor.core.publisher.Flux<AgentRuntimeEvent> resume(com.easyagents.agent.runtime.AgentResumeRequest request) {
resumeCount++;
return reactor.core.publisher.Flux.empty();
}
}
private static class RecordingRouteRegistry extends AgentRuntimeRouteRegistry {
private final String currentNodeId;
private String ownerNode;
private String ownerBootId;
private String currentOwnerBootId;
private String requestIdByToken;
private boolean nodeAlive;
private RecordingRouteRegistry(String currentNodeId) {
super(null, null, null);
this.currentNodeId = currentNodeId;
}
@Override
public String findOwnerNode(String requestId) {
return ownerNode;
}
@Override
public AgentRuntimeRoute findOwnerRoute(String requestId) {
AgentRuntimeRoute route = new AgentRuntimeRoute();
route.setNodeId(ownerNode);
route.setBootId(ownerBootId);
return route;
}
@Override
public String findRequestIdByResumeToken(String resumeToken) {
return requestIdByToken;
}
@Override
public String currentNodeId() {
return currentNodeId;
}
@Override
public boolean isNodeAlive(String nodeId) {
return nodeAlive;
}
@Override
public String currentNodeBootId(String nodeId) {
return currentOwnerBootId;
}
}
private static class RecordingCommandProducer extends AgentRuntimeCommandProducer {
private int approveCount;
private String lastTargetNodeId;
private String lastRequestId;
@Override
public void sendApprove(String targetNodeId,
String requestId,
String resumeToken,
BigInteger operatorId,
String userId) {
approveCount++;
lastTargetNodeId = targetNodeId;
lastRequestId = requestId;
}
}
private static class RecordingAgentRuntimeFactory implements AgentRuntimeFactory {
private final AgentRuntime runtime;
private RecordingAgentRuntimeFactory(AgentRuntime runtime) {
this.runtime = runtime;
}
@Override
public AgentRuntime create() {
return runtime;
}
}
private static class RecordingAgentRuntimeCompiler extends AgentRuntimeCompiler {
@Override
public AgentRuntimeBundle compile(Agent agent) {
AgentRuntimeBundle bundle = new AgentRuntimeBundle();
bundle.setDefinition(new com.easyagents.agent.runtime.AgentDefinition());
return bundle;
}
}
private static class RecordingAgentRunEventRecorder implements AgentRunEventRecorder {
private int recordCount;
@Override
public void record(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) {
recordCount++;
}
}
private static class RecordingAgentHitlPendingService implements AgentHitlPendingService {
private int recordApprovalRequiredCount;
private int approveCount;
private int rejectCount;
private int cancelByRequestIdCount;
private int deleteByRuntimeSessionIdCount;
@Override
public void recordApprovalRequired(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) {
recordApprovalRequiredCount++;
}
@Override
public AgentHitlPending approve(String resumeToken, BigInteger operatorId) {
approveCount++;
return new AgentHitlPending();
}
@Override
public AgentHitlPending reject(String resumeToken, BigInteger operatorId, String reason) {
rejectCount++;
return new AgentHitlPending();
}
@Override
public void cancelByRequestId(String requestId, String reason) {
cancelByRequestIdCount++;
}
@Override
public void deleteByChatSessionId(BigInteger chatSessionId) {
// 测试桩无需处理。
}
@Override
public void deleteByRuntimeSessionId(String runtimeSessionId) {
deleteByRuntimeSessionIdCount++;
}
@Override
public List<AgentHitlPending> expirePending(int limit) {
return List.of();
}
}
private static class RecordingAgentSessionStore implements AgentSessionStore {
private String deletedSessionKey;
@Override
public void save(String sessionKey, String name, io.agentscope.core.state.State state) {
// 测试桩无需处理。
}
@Override
public void saveList(String sessionKey, String name, List<? extends io.agentscope.core.state.State> states) {
// 测试桩无需处理。
}
@Override
public <T extends io.agentscope.core.state.State> java.util.Optional<T> get(String sessionKey, String name, Class<T> type) {
return java.util.Optional.empty();
}
@Override
public <T extends io.agentscope.core.state.State> List<T> getList(String sessionKey, String name, Class<T> itemType) {
return List.of();
}
@Override
public boolean exists(String sessionKey) {
return false;
}
@Override
public void delete(String sessionKey) {
deletedSessionKey = sessionKey;
}
@Override
public java.util.Set<String> listSessionKeys() {
return java.util.Set.of();
}
}
/**
* 记录 chatlog 写入动作的测试桩。
*/

View File

@@ -0,0 +1,195 @@
package tech.easyflow.agent.runtime.asynctool;
import com.easyagents.agent.runtime.tool.AgentToolContext;
import com.easyagents.agent.runtime.tool.asynctool.AsyncToolCancelRequest;
import com.easyagents.agent.runtime.tool.asynctool.AsyncToolObserveRequest;
import com.easyagents.agent.runtime.tool.asynctool.AsyncToolResultRequest;
import com.easyagents.agent.runtime.tool.asynctool.AsyncToolSubmitResult;
import com.easyagents.agent.runtime.tool.asynctool.AsyncToolTaskStatus;
import com.easyagents.agent.runtime.tool.asynctool.AsyncToolTaskView;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import tech.easyflow.agent.runtime.tool.AgentToolExecutionResult;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.UnaryOperator;
/**
* EasyFlow 异步业务工具基类测试。
*/
public class AbstractAgentAsyncSubToolsTest {
/**
* 验证 submit、observe、result 与 list 的基础任务生命周期。
*
* @throws Exception 等待后台执行超时时抛出
*/
@Test
public void asyncSubToolsShouldSubmitObserveResultAndListCurrentSessionTasks() throws Exception {
ThreadPoolTaskExecutor executor = executor();
try {
InMemoryTaskStore store = new InMemoryTaskStore();
TestAsyncSubTools subTools = new TestAsyncSubTools(store, executor);
AgentToolContext context = context("session-a");
AsyncToolSubmitResult submitted = subTools.submit(Map.of("keyword", "hello"), context);
Assert.assertEquals(AsyncToolTaskStatus.PENDING, submitted.getStatus());
Assert.assertTrue(submitted.getTaskId().startsWith("async_"));
AsyncToolTaskView completed = waitTerminal(subTools, submitted.getTaskId(), context);
Assert.assertEquals(AsyncToolTaskStatus.SUCCEEDED, completed.getStatus());
Assert.assertEquals(Map.of("echo", "hello"), completed.getResult());
Assert.assertTrue(completed.getNextCursor() >= 2L);
AsyncToolResultRequest resultRequest = new AsyncToolResultRequest();
resultRequest.setTaskId(submitted.getTaskId());
resultRequest.setCursor(1L);
AsyncToolTaskView result = subTools.result(resultRequest, context);
Assert.assertEquals(AsyncToolTaskStatus.SUCCEEDED, result.getStatus());
Assert.assertEquals(Map.of("echo", "hello"), result.getResult());
Assert.assertFalse(result.getEvents().isEmpty());
Assert.assertEquals(1, subTools.list(null, context).getTasks().size());
Assert.assertTrue(subTools.list(null, context("session-b")).getTasks().isEmpty());
AsyncToolTaskView crossedSessionView = observe(subTools, submitted.getTaskId(), context("session-b"));
Assert.assertEquals(AsyncToolTaskStatus.FAILED, crossedSessionView.getStatus());
Assert.assertEquals("TASK_NOT_FOUND", crossedSessionView.getErrorType());
} finally {
executor.shutdown();
}
}
/**
* 验证首版取消语义返回明确失败结果。
*/
@Test
public void cancelShouldReturnUnsupportedFailure() {
TestAsyncSubTools subTools = new TestAsyncSubTools(new InMemoryTaskStore(), executor());
AsyncToolCancelRequest request = new AsyncToolCancelRequest();
request.setTaskId("task-1");
var result = subTools.cancel(request, context("session-a"));
Assert.assertEquals(AsyncToolTaskStatus.FAILED, result.getStatus());
Assert.assertEquals("不支持取消", result.getMessage());
}
private AsyncToolTaskView waitTerminal(TestAsyncSubTools subTools, String taskId, AgentToolContext context) throws Exception {
long deadline = System.currentTimeMillis() + 3000L;
AsyncToolTaskView view = observe(subTools, taskId, context);
while (!Boolean.TRUE.equals(view.getTerminal()) && System.currentTimeMillis() < deadline) {
Thread.sleep(20L);
view = observe(subTools, taskId, context);
}
Assert.assertTrue("异步任务应在测试超时前完成", Boolean.TRUE.equals(view.getTerminal()));
return view;
}
private AsyncToolTaskView observe(TestAsyncSubTools subTools, String taskId, AgentToolContext context) {
AsyncToolObserveRequest request = new AsyncToolObserveRequest();
request.setTaskId(taskId);
request.setCursor(0L);
return subTools.observe(request, context);
}
private AgentToolContext context(String sessionId) {
AgentToolContext context = new AgentToolContext();
context.setRequestId("request-1");
context.setTraceId("trace-1");
context.setSessionId(sessionId);
context.setAgentId("agent-1");
context.setToolCallId("tool-call-1");
return context;
}
private ThreadPoolTaskExecutor executor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(1);
executor.setQueueCapacity(4);
executor.setThreadNamePrefix("async-sub-tools-test-");
executor.initialize();
return executor;
}
private static final class TestAsyncSubTools extends AbstractAgentAsyncSubTools {
private TestAsyncSubTools(AgentAsyncToolTaskStore taskStore, ThreadPoolTaskExecutor taskExecutor) {
super(taskStore, taskExecutor);
}
@Override
protected String toolType() {
return "PLUGIN";
}
@Override
protected String toolName() {
return "test_tool";
}
@Override
protected String displayName() {
return "测试工具";
}
@Override
protected String businessId() {
return "business-1";
}
@Override
protected AgentToolExecutionResult executeBusiness(Map<String, Object> arguments) {
return new AgentToolExecutionResult(Map.of("echo", arguments.get("keyword")), "business-run-1");
}
}
private static final class InMemoryTaskStore implements AgentAsyncToolTaskStore {
private final Map<String, AgentAsyncToolTaskRecord> records = new ConcurrentHashMap<>();
@Override
public void create(AgentAsyncToolTaskRecord record) {
record.setSessionScopedKey(key(record.getSessionId(), record.getTaskId()));
records.put(record.getSessionScopedKey(), record);
}
@Override
public Optional<AgentAsyncToolTaskRecord> get(String sessionId, String taskId) {
return Optional.ofNullable(records.get(key(sessionId, taskId)));
}
@Override
public Optional<AgentAsyncToolTaskRecord> update(String sessionId,
String taskId,
UnaryOperator<AgentAsyncToolTaskRecord> updater) {
String key = key(sessionId, taskId);
AgentAsyncToolTaskRecord updated = records.computeIfPresent(key,
(ignored, existing) -> updater == null ? existing : updater.apply(existing));
return Optional.ofNullable(updated);
}
@Override
public List<AgentAsyncToolTaskRecord> list(String sessionId, AsyncToolTaskStatus status) {
List<AgentAsyncToolTaskRecord> result = new ArrayList<>();
for (AgentAsyncToolTaskRecord record : records.values()) {
if (sessionId.equals(record.getSessionId()) && (status == null || status == record.getStatus())) {
result.add(record);
}
}
result.sort(Comparator.comparing(AgentAsyncToolTaskRecord::getCreatedAt).reversed());
return result;
}
private String key(String sessionId, String taskId) {
return sessionId + ":" + taskId;
}
}
}

View File

@@ -0,0 +1,213 @@
package tech.easyflow.agent.runtime.asynctool;
import com.easyagents.agent.runtime.tool.AgentToolContext;
import com.easyagents.agent.runtime.tool.asynctool.AsyncToolObserveRequest;
import com.easyagents.agent.runtime.tool.asynctool.AsyncToolResultRequest;
import com.easyagents.agent.runtime.tool.asynctool.AsyncToolSubmitResult;
import com.easyagents.agent.runtime.tool.asynctool.AsyncToolTaskStatus;
import com.easyagents.agent.runtime.tool.asynctool.AsyncToolTaskView;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import tech.easyflow.agent.runtime.tool.AgentToolExecutionResult;
import tech.easyflow.agent.runtime.tool.PluginToolExecutor;
import tech.easyflow.agent.runtime.tool.WorkflowToolExecutor;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.UnaryOperator;
/**
* Workflow 与 Plugin 异步子工具测试。
*/
public class WorkflowPluginAsyncSubToolsTest {
/**
* 验证 Workflow 异步子工具会把业务执行结果保留到任务视图。
*
* @throws Exception 等待后台执行超时时抛出
*/
@Test
public void workflowAsyncSubToolsShouldKeepBusinessResultInTaskView() throws Exception {
ThreadPoolTaskExecutor executor = executor();
try {
Map<String, Object> businessResult = Map.of("workflowOutput", "ok");
WorkflowAsyncSubTools subTools = new WorkflowAsyncSubTools(workflow(),
"workflow_demo",
"测试工作流",
new StubWorkflowToolExecutor(businessResult),
new InMemoryTaskStore(),
executor);
AsyncToolTaskView view = submitAndResult(subTools);
Assert.assertEquals(AsyncToolTaskStatus.SUCCEEDED, view.getStatus());
Assert.assertEquals(businessResult, view.getResult());
Assert.assertEquals("workflow-run-1", view.getPayload().get("businessExecutionId"));
} finally {
executor.shutdown();
}
}
/**
* 验证 Plugin 异步子工具会把业务执行结果保留到任务视图。
*
* @throws Exception 等待后台执行超时时抛出
*/
@Test
public void pluginAsyncSubToolsShouldKeepBusinessResultInTaskView() throws Exception {
ThreadPoolTaskExecutor executor = executor();
try {
Map<String, Object> businessResult = Map.of("pluginOutput", List.of("a", "b"));
PluginAsyncSubTools subTools = new PluginAsyncSubTools(pluginItem(),
"plugin_demo",
"测试插件",
new StubPluginToolExecutor(businessResult),
new InMemoryTaskStore(),
executor);
AsyncToolTaskView view = submitAndResult(subTools);
Assert.assertEquals(AsyncToolTaskStatus.SUCCEEDED, view.getStatus());
Assert.assertEquals(businessResult, view.getResult());
} finally {
executor.shutdown();
}
}
private AsyncToolTaskView submitAndResult(AbstractAgentAsyncSubTools subTools) throws Exception {
AgentToolContext context = context();
AsyncToolSubmitResult submitted = subTools.submit(Map.of("keyword", "hello"), context);
waitTerminal(subTools, submitted.getTaskId(), context);
AsyncToolResultRequest request = new AsyncToolResultRequest();
request.setTaskId(submitted.getTaskId());
return subTools.result(request, context);
}
private void waitTerminal(AbstractAgentAsyncSubTools subTools, String taskId, AgentToolContext context) throws Exception {
long deadline = System.currentTimeMillis() + 3000L;
AsyncToolTaskView view = observe(subTools, taskId, context);
while (!Boolean.TRUE.equals(view.getTerminal()) && System.currentTimeMillis() < deadline) {
Thread.sleep(20L);
view = observe(subTools, taskId, context);
}
Assert.assertTrue("异步任务应在测试超时前完成", Boolean.TRUE.equals(view.getTerminal()));
}
private AsyncToolTaskView observe(AbstractAgentAsyncSubTools subTools, String taskId, AgentToolContext context) {
AsyncToolObserveRequest request = new AsyncToolObserveRequest();
request.setTaskId(taskId);
return subTools.observe(request, context);
}
private AgentToolContext context() {
AgentToolContext context = new AgentToolContext();
context.setRequestId("request-1");
context.setTraceId("trace-1");
context.setSessionId("session-1");
context.setAgentId("agent-1");
context.setToolCallId("tool-call-1");
return context;
}
private Workflow workflow() {
Workflow workflow = new Workflow();
workflow.setId(BigInteger.valueOf(101L));
workflow.setTitle("测试工作流");
return workflow;
}
private PluginItem pluginItem() {
PluginItem pluginItem = new PluginItem();
pluginItem.setId(BigInteger.valueOf(102L));
pluginItem.setName("测试插件");
return pluginItem;
}
private ThreadPoolTaskExecutor executor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(1);
executor.setQueueCapacity(4);
executor.setThreadNamePrefix("workflow-plugin-async-test-");
executor.initialize();
return executor;
}
private static final class StubWorkflowToolExecutor extends WorkflowToolExecutor {
private final Map<String, Object> businessResult;
private StubWorkflowToolExecutor(Map<String, Object> businessResult) {
super(null);
this.businessResult = businessResult;
}
@Override
public AgentToolExecutionResult execute(Workflow workflow, Map<String, Object> arguments) {
return new AgentToolExecutionResult(businessResult, "workflow-run-1");
}
}
private static final class StubPluginToolExecutor extends PluginToolExecutor {
private final Map<String, Object> businessResult;
private StubPluginToolExecutor(Map<String, Object> businessResult) {
this.businessResult = businessResult;
}
@Override
public AgentToolExecutionResult execute(PluginItem pluginItem, Map<String, Object> arguments) {
return new AgentToolExecutionResult(businessResult, null);
}
}
private static final class InMemoryTaskStore implements AgentAsyncToolTaskStore {
private final Map<String, AgentAsyncToolTaskRecord> records = new ConcurrentHashMap<>();
@Override
public void create(AgentAsyncToolTaskRecord record) {
record.setSessionScopedKey(key(record.getSessionId(), record.getTaskId()));
records.put(record.getSessionScopedKey(), record);
}
@Override
public Optional<AgentAsyncToolTaskRecord> get(String sessionId, String taskId) {
return Optional.ofNullable(records.get(key(sessionId, taskId)));
}
@Override
public Optional<AgentAsyncToolTaskRecord> update(String sessionId,
String taskId,
UnaryOperator<AgentAsyncToolTaskRecord> updater) {
AgentAsyncToolTaskRecord updated = records.computeIfPresent(key(sessionId, taskId),
(ignored, existing) -> updater == null ? existing : updater.apply(existing));
return Optional.ofNullable(updated);
}
@Override
public List<AgentAsyncToolTaskRecord> list(String sessionId, AsyncToolTaskStatus status) {
List<AgentAsyncToolTaskRecord> result = new ArrayList<>();
for (AgentAsyncToolTaskRecord record : records.values()) {
if (sessionId.equals(record.getSessionId()) && (status == null || status == record.getStatus())) {
result.add(record);
}
}
result.sort(Comparator.comparing(AgentAsyncToolTaskRecord::getCreatedAt).reversed());
return result;
}
private String key(String sessionId, String taskId) {
return sessionId + ":" + taskId;
}
}
}

View File

@@ -0,0 +1,239 @@
package tech.easyflow.agent.runtime.tool;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import com.easyagents.core.model.chat.tool.Parameter;
import com.easyagents.core.model.chat.tool.Tool;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.enums.AgentToolType;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.lang.reflect.Field;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Agent 工具运行时编译测试。
*/
public class AgentToolRuntimeCompilerTest {
/**
* 验证 Workflow 默认按同步工具编译。
*
* @throws Exception 反射注入依赖失败时抛出
*/
@Test
public void compileShouldUseSyncModeByDefault() throws Exception {
AgentToolRuntimeCompiler compiler = compiler();
AgentToolRuntimeCompilation compilation = compiler.compile(agent(workflowBinding(null, false, "flow-sync")));
Assert.assertEquals(List.of("flow-sync"), toolNames(compilation));
Assert.assertEquals(1, compilation.getToolInvokers().size());
Assert.assertFalse(compilation.getToolSpecs().get(0).isApprovalRequired());
}
/**
* 验证非法执行模式会回退为同步工具。
*
* @throws Exception 反射注入依赖失败时抛出
*/
@Test
public void compileShouldFallbackToSyncWhenExecutionModeInvalid() throws Exception {
AgentToolRuntimeCompiler compiler = compiler();
AgentToolRuntimeCompilation compilation = compiler.compile(agent(workflowBinding("BAD", false, "flow-sync")));
Assert.assertEquals(List.of("flow-sync"), toolNames(compilation));
Assert.assertEquals(1, compilation.getToolInvokers().size());
}
/**
* 验证 Workflow 异步模式会展开为五个固定子工具。
*
* @throws Exception 反射注入依赖失败时抛出
*/
@Test
public void compileShouldExpandWorkflowAsyncSubToolsAndNormalizeName() throws Exception {
AgentToolRuntimeCompiler compiler = compiler();
AgentToolRuntimeCompilation compilation = compiler.compile(agent(workflowBinding("ASYNC", true, "flow-alpha")));
Assert.assertEquals(List.of(
"flow_alpha_submit",
"flow_alpha_observe",
"flow_alpha_result",
"flow_alpha_cancel",
"flow_alpha_list"
), toolNames(compilation));
Assert.assertEquals(5, compilation.getToolInvokers().size());
Assert.assertEquals(List.of("keyword"), compilation.getToolSpecs().get(0).getParametersSchema().get("required"));
Assert.assertTrue(compilation.getToolSpecs().get(0).isApprovalRequired());
Assert.assertEquals("确认执行?", compilation.getToolSpecs().get(0).getApprovalRequest().getApprovalPrompt());
Assert.assertFalse(compilation.getToolSpecs().get(1).isApprovalRequired());
Assert.assertEquals("flow_alpha", compilation.getToolSpecs().get(0).getMetadata().get("asyncToolName"));
Assert.assertEquals("submit", compilation.getToolSpecs().get(0).getMetadata().get("asyncToolPhase"));
}
/**
* 验证 Plugin 异步模式同样展开为五个固定子工具。
*
* @throws Exception 反射注入依赖失败时抛出
*/
@Test
public void compileShouldExpandPluginAsyncSubTools() throws Exception {
AgentToolRuntimeCompiler compiler = compiler();
AgentToolRuntimeCompilation compilation = compiler.compile(agent(pluginBinding("ASYNC", "plugin-tool")));
Assert.assertEquals(List.of(
"plugin_tool_submit",
"plugin_tool_observe",
"plugin_tool_result",
"plugin_tool_cancel",
"plugin_tool_list"
), toolNames(compilation));
Assert.assertEquals(5, compilation.getToolInvokers().size());
for (AgentToolSpec spec : compilation.getToolSpecs()) {
Assert.assertEquals(Boolean.TRUE, spec.getMetadata().get("asyncTool"));
Assert.assertEquals("插件工具", spec.getMetadata().get("toolDisplayName"));
}
}
/**
* 验证异步工具名归一化后发生冲突时会在编译阶段失败。
*
* @throws Exception 反射注入依赖失败时抛出
*/
@Test
public void compileShouldRejectNormalizedAsyncToolNameCollision() throws Exception {
AgentToolRuntimeCompiler compiler = compiler();
AgentToolBinding first = workflowBinding("ASYNC", false, "flow-alpha");
AgentToolBinding second = workflowBinding("ASYNC", false, "flow_alpha");
second.setId(BigInteger.valueOf(13L));
second.setTargetId(BigInteger.valueOf(103L));
try {
compiler.compile(agent(List.of(first, second)));
Assert.fail("异步工具名冲突时应编译失败");
} catch (BusinessException e) {
Assert.assertTrue(e.getMessage().contains("flow_alpha_submit"));
}
}
private AgentToolRuntimeCompiler compiler() throws Exception {
AgentToolRuntimeCompiler compiler = new AgentToolRuntimeCompiler();
setField(compiler, "objectMapper", new ObjectMapper());
setField(compiler, "workflowToolExecutor", new StubWorkflowToolExecutor());
setField(compiler, "pluginToolExecutor", new StubPluginToolExecutor());
return compiler;
}
private Agent agent(AgentToolBinding binding) {
return agent(List.of(binding));
}
private Agent agent(List<AgentToolBinding> bindings) {
Agent agent = new Agent();
agent.setId(BigInteger.ONE);
agent.setToolBindings(bindings);
return agent;
}
private AgentToolBinding workflowBinding(String executionMode, boolean hitlEnabled, String toolName) {
AgentToolBinding binding = new AgentToolBinding();
binding.setId(BigInteger.valueOf(11L));
binding.setToolType(AgentToolType.WORKFLOW.name());
binding.setTargetId(BigInteger.valueOf(101L));
binding.setToolName(toolName);
binding.setEnabled(true);
binding.setHitlEnabled(hitlEnabled);
binding.setHitlConfigJson(Map.of("prompt", "确认执行?"));
binding.setOptionsJson(executionMode == null ? Map.of() : Map.of("executionMode", executionMode));
binding.setResourceSnapshot(Map.of(
"id", BigInteger.valueOf(101L),
"title", "客户检索工作流",
"description", "按关键词检索客户",
"englishName", "flow-alpha"
));
return binding;
}
private AgentToolBinding pluginBinding(String executionMode, String toolName) {
AgentToolBinding binding = new AgentToolBinding();
binding.setId(BigInteger.valueOf(12L));
binding.setToolType(AgentToolType.PLUGIN.name());
binding.setTargetId(BigInteger.valueOf(102L));
binding.setToolName(toolName);
binding.setEnabled(true);
binding.setOptionsJson(Map.of("executionMode", executionMode));
binding.setResourceSnapshot(Map.of(
"id", BigInteger.valueOf(102L),
"name", "插件工具",
"description", "调用插件",
"englishName", "plugin-tool"
));
return binding;
}
private List<String> toolNames(AgentToolRuntimeCompilation compilation) {
return compilation.getToolSpecs().stream().map(AgentToolSpec::getName).collect(Collectors.toList());
}
private void setField(Object target, String fieldName, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
private Tool testTool(String name, String description) {
Parameter parameter = new Parameter();
parameter.setName("keyword");
parameter.setDescription("关键词");
parameter.setType("string");
parameter.setRequired(true);
return Tool.builder()
.name(name)
.description(description)
.addParameter(parameter)
.function(arguments -> Map.of("ok", true))
.build();
}
private final class StubWorkflowToolExecutor extends WorkflowToolExecutor {
private StubWorkflowToolExecutor() {
super(null);
}
@Override
public Tool buildTool(Workflow workflow) {
return testTool(workflow.getEnglishName(), workflow.getDescription());
}
@Override
public AgentToolExecutionResult execute(Workflow workflow, Map<String, Object> arguments) {
return new AgentToolExecutionResult(Map.of("ok", true), "wf-run-1");
}
}
private final class StubPluginToolExecutor extends PluginToolExecutor {
@Override
public Tool buildTool(PluginItem pluginItem) {
return testTool(pluginItem.getEnglishName(), pluginItem.getDescription());
}
@Override
public AgentToolExecutionResult execute(PluginItem pluginItem, Map<String, Object> arguments) {
return new AgentToolExecutionResult(Map.of("ok", true), null);
}
}
}

View File

@@ -131,5 +131,58 @@
<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>
<profiles>
<profile>
<id>release-obfuscation</id>
<build>
<plugins>
<plugin>
<groupId>com.github.wvengen</groupId>
<artifactId>proguard-maven-plugin</artifactId>
<version>${proguard.maven.plugin.version}</version>
<dependencies>
<dependency>
<groupId>com.guardsquare</groupId>
<artifactId>proguard-base</artifactId>
<version>${proguard.version}</version>
</dependency>
</dependencies>
<executions>
<execution>
<id>release-obfuscation</id>
<phase>package</phase>
<goals>
<goal>proguard</goal>
</goals>
</execution>
</executions>
<configuration>
<proguardVersion>${proguard.version}</proguardVersion>
<proguardInclude>${maven.multiModuleProjectDirectory}/config/proguard/easyflow-module-ai.pro</proguardInclude>
<mappingFileName>proguard-map-${project.artifactId}.txt</mappingFileName>
<seedFileName>proguard-seed-${project.artifactId}.txt</seedFileName>
<includeDependency>true</includeDependency>
<includeDependencyInjar>false</includeDependencyInjar>
<attach>false</attach>
<attachMap>false</attachMap>
<appendClassifier>false</appendClassifier>
<addMavenDescriptor>false</addMavenDescriptor>
<addManifest>true</addManifest>
<putLibraryJarsInTempDir>true</putLibraryJarsInTempDir>
<generateTemporaryConfigurationFile>true</generateTemporaryConfigurationFile>
<bindToMavenLogging>true</bindToMavenLogging>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

View File

@@ -5,11 +5,13 @@ import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.ComponentScan;
import tech.easyflow.ai.documentimport.task.DocumentImportParseMonitorProperties;
import tech.easyflow.ai.documentimport.task.DocumentImportStatusBroadcastProperties;
@MapperScan("tech.easyflow.ai.mapper")
@ComponentScan("tech.easyflow.ai")
@EnableConfigurationProperties({
DocumentImportParseMonitorProperties.class,
DocumentImportStatusBroadcastProperties.class,
RagHealthProperties.class
})
@AutoConfiguration

View File

@@ -10,6 +10,7 @@ public class EasyFlowThreadPoolProperties {
private Pool sse = new Pool(4, 16, 2000, 30, true);
private Pool documentImport = new Pool(2, 4, 200, 60, true);
private Pool agentAsyncTool = new Pool(2, 8, 200, 60, true);
/**
* 获取 SSE 线程池配置。
@@ -47,6 +48,24 @@ public class EasyFlowThreadPoolProperties {
this.documentImport = documentImport;
}
/**
* 获取 Agent 异步工具后台执行线程池配置。
*
* @return Agent 异步工具线程池配置
*/
public Pool getAgentAsyncTool() {
return agentAsyncTool;
}
/**
* 设置 Agent 异步工具后台执行线程池配置。
*
* @param agentAsyncTool Agent 异步工具线程池配置
*/
public void setAgentAsyncTool(Pool agentAsyncTool) {
this.agentAsyncTool = agentAsyncTool;
}
/**
* 线程池配置项。
*/

View File

@@ -78,4 +78,30 @@ public class ThreadPoolConfig {
executor.initialize();
return executor;
}
/**
* 创建 Agent 异步工具后台执行线程池。
*
* @return Agent 异步工具执行线程池
*/
@Bean(name = "agentAsyncToolExecutor")
public ThreadPoolTaskExecutor agentAsyncToolExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
EasyFlowThreadPoolProperties.Pool pool = properties.getAgentAsyncTool();
executor.setCorePoolSize(pool.getCoreSize());
executor.setMaxPoolSize(pool.getMaxSize());
executor.setQueueCapacity(pool.getQueueCapacity());
executor.setKeepAliveSeconds(pool.getKeepAliveSeconds());
executor.setAllowCoreThreadTimeOut(pool.isAllowCoreThreadTimeout());
executor.setThreadNamePrefix("agent-async-tool-");
executor.setRejectedExecutionHandler((runnable, executorService) -> {
log.error("Agent异步工具线程池过载核心线程数{},最大线程数:{},队列任务数:{}",
executorService.getCorePoolSize(),
executorService.getMaximumPoolSize(),
executorService.getQueue().size());
throw new BusinessException("Agent 异步工具任务繁忙,请稍后重试");
});
executor.initialize();
return executor;
}
}

View File

@@ -2,6 +2,7 @@ package tech.easyflow.ai.documentimport.task;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import tech.easyflow.common.cache.DistributedScheduledLock;
/**
* 知识库文档解析任务收敛器。
@@ -27,6 +28,7 @@ public class DocumentImportParseMonitor {
fixedDelayString = "${easyflow.ai.document-import.parse-monitor.fixed-delay:10000}",
initialDelayString = "${easyflow.ai.document-import.parse-monitor.initial-delay:10000}"
)
@DistributedScheduledLock(key = "easyflow:schedule:document-import:parse-monitor", leaseSeconds = 300L)
public void reconcileRunningParseTasks() {
appService.monitorRunningParseTasks();
}

View File

@@ -0,0 +1,79 @@
package tech.easyflow.ai.documentimport.task;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
/**
* 文档导入状态 Redis 广播配置。
*/
@Configuration
public class DocumentImportStatusBroadcastConfig {
private static final Logger LOG = LoggerFactory.getLogger(DocumentImportStatusBroadcastConfig.class);
/**
* 创建文档导入状态广播监听容器。
*
* @param connectionFactory Redis 连接工厂
* @param streamService 文档导入状态流服务
* @param properties 文档导入监控配置
* @return Redis 消息监听容器
*/
@Bean
public RedisMessageListenerContainer documentImportStatusListenerContainer(
RedisConnectionFactory connectionFactory,
DocumentImportTaskStatusStreamService streamService,
DocumentImportStatusBroadcastProperties properties
) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(
new DocumentImportStatusMessageListener(streamService),
new ChannelTopic(properties.getStatusBroadcastChannel())
);
return container;
}
/**
* 文档导入状态广播监听器。
*/
private static final class DocumentImportStatusMessageListener implements MessageListener {
private final DocumentImportTaskStatusStreamService streamService;
/**
* 创建监听器。
*
* @param streamService 文档导入状态流服务
*/
private DocumentImportStatusMessageListener(DocumentImportTaskStatusStreamService streamService) {
this.streamService = streamService;
}
/**
* 处理 Redis 广播消息。
*
* @param message 消息
* @param pattern 订阅模式
*/
@Override
public void onMessage(Message message, byte[] pattern) {
String payload = new String(message.getBody(), StandardCharsets.UTF_8);
try {
streamService.publishLocal(new BigInteger(payload));
} catch (RuntimeException e) {
LOG.warn("处理文档导入状态广播失败: payload={}", payload, e);
}
}
}
}

View File

@@ -0,0 +1,34 @@
package tech.easyflow.ai.documentimport.task;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 文档导入状态广播配置。
*/
@ConfigurationProperties(prefix = "easyflow.ai.document-import")
public class DocumentImportStatusBroadcastProperties {
private String statusBroadcastChannel = "easyflow:document-import:status";
/**
* 获取文档导入状态广播通道。
*
* @return Redis 广播通道
*/
public String getStatusBroadcastChannel() {
return statusBroadcastChannel;
}
/**
* 设置文档导入状态广播通道。
*
* @param statusBroadcastChannel Redis 广播通道
*/
public void setStatusBroadcastChannel(String statusBroadcastChannel) {
if (statusBroadcastChannel == null || statusBroadcastChannel.trim().isEmpty()) {
this.statusBroadcastChannel = "easyflow:document-import:status";
return;
}
this.statusBroadcastChannel = statusBroadcastChannel.trim();
}
}

View File

@@ -1,6 +1,7 @@
package tech.easyflow.ai.documentimport.task;
import org.springframework.http.MediaType;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
@@ -43,6 +44,12 @@ public class DocumentImportTaskStatusStreamService {
@Resource(name = "sseThreadPool")
private ThreadPoolTaskExecutor sseThreadPool;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private DocumentImportStatusBroadcastProperties statusBroadcastProperties;
/**
* 订阅知识库文档任务状态流。
*
@@ -75,7 +82,7 @@ public class DocumentImportTaskStatusStreamService {
if (documentId == null) {
return;
}
Runnable publishAction = () -> publishNow(documentId);
Runnable publishAction = () -> publishStatusChange(documentId);
if (TransactionSynchronizationManager.isSynchronizationActive()
&& TransactionSynchronizationManager.isActualTransactionActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@@ -89,7 +96,22 @@ public class DocumentImportTaskStatusStreamService {
publishAction.run();
}
private void publishNow(BigInteger documentId) {
/**
* 处理 Redis 广播收到的文档状态变更。
*
* @param documentId 文档 ID
*/
public void publishLocal(BigInteger documentId) {
publishNow(documentId);
}
private void publishStatusChange(BigInteger documentId) {
// 先推送本机连接,降低单机部署和广播链路延迟。
publishNow(documentId);
stringRedisTemplate.convertAndSend(statusBroadcastProperties.getStatusBroadcastChannel(), documentId.toString());
}
void publishNow(BigInteger documentId) {
Document document = documentMapper.selectOneById(documentId);
if (document == null || document.getCollectionId() == null) {
return;

View File

@@ -11,8 +11,12 @@ import tech.easyflow.common.util.StringUtil;
import java.math.BigInteger;
import java.util.Map;
import java.util.Optional;
/**
* @deprecated 该类仅用于 Bot 旧版 function/tool 链路的 MCP 工具调用。
* 后续请迁移到 agent-runtime 的 MCP 工具适配链路。
*/
@Deprecated(since = "0.4", forRemoval = false)
public class McpTool extends BaseTool {
private BigInteger mcpId;

View File

@@ -37,6 +37,18 @@ public class McpBase extends DateEntity implements Serializable {
@Column(comment = "完整MCP配置JSON")
private String configJson;
/**
* MCP连接方式
*/
@Column(comment = "MCP连接方式")
private String transportType;
/**
* 是否启用工具调用审批
*/
@Column(comment = "是否启用工具调用审批")
private Boolean approvalRequired;
/**
* 部门ID
*/
@@ -111,6 +123,22 @@ public class McpBase extends DateEntity implements Serializable {
this.configJson = configJson;
}
public String getTransportType() {
return transportType;
}
public void setTransportType(String transportType) {
this.transportType = transportType;
}
public Boolean getApprovalRequired() {
return approvalRequired;
}
public void setApprovalRequired(Boolean approvalRequired) {
this.approvalRequired = approvalRequired;
}
public BigInteger getDeptId() {
return deptId;
}

View File

@@ -0,0 +1,66 @@
package tech.easyflow.ai.mcp;
import tech.easyflow.common.util.StringUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.Locale;
/**
* MCP 连接方式。
*/
public enum McpTransportType {
/**
* 标准输入输出进程通信。
*/
STDIO("stdio"),
/**
* HTTP SSE 通信。
*/
SSE("http-sse"),
/**
* Streamable HTTP 通信。
*/
HTTP("http-stream");
private final String value;
/**
* 创建 MCP 连接方式。
*
* @param value 配置值
*/
McpTransportType(String value) {
this.value = value;
}
/**
* 获取配置值。
*
* @return 配置值
*/
public String getValue() {
return value;
}
/**
* 解析连接方式。
*
* @param value 连接方式文本
* @return MCP 连接方式
*/
public static McpTransportType from(String value) {
if (StringUtil.noText(value)) {
return STDIO;
}
String normalized = value.trim().toLowerCase(Locale.ROOT);
return switch (normalized) {
case "stdio" -> STDIO;
case "sse", "http-sse" -> SSE;
case "http", "http-stream", "streamable-http" -> HTTP;
default -> throw new BusinessException("不支持的 MCP 连接方式: " + value);
};
}
}

View File

@@ -1,6 +1,7 @@
package tech.easyflow.ai.service;
import com.easyagents.core.model.chat.tool.Tool;
import com.easyagents.mcp.client.McpEnvironmentCheckResult;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.service.IService;
import tech.easyflow.ai.entity.BotMcp;
@@ -30,4 +31,6 @@ public interface McpService extends IService<Mcp> {
Mcp getMcpTools(String id);
Page<Mcp> pageTools(Page<Mcp> mcpPage);
McpEnvironmentCheckResult checkMcp(String configJson);
}

View File

@@ -3,6 +3,8 @@ package tech.easyflow.ai.service.impl;
import com.easyagents.core.model.chat.tool.Parameter;
import com.easyagents.core.model.chat.tool.Tool;
import com.easyagents.mcp.client.McpClientManager;
import com.easyagents.mcp.client.McpEnvironmentCheckResult;
import com.easyagents.mcp.client.McpEnvironmentChecker;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.mybatisflex.core.paginate.Page;
@@ -16,6 +18,7 @@ import tech.easyflow.ai.easyagents.tool.McpTool;
import tech.easyflow.ai.entity.BotMcp;
import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.ai.mapper.McpMapper;
import tech.easyflow.ai.mcp.McpTransportType;
import tech.easyflow.ai.service.McpService;
import tech.easyflow.ai.utils.CommonFiledUtil;
import tech.easyflow.common.constant.enums.EnumRes;
@@ -37,7 +40,8 @@ import java.util.*;
@Service
public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpService {
private final McpClientManager mcpClientManager = McpClientManager.getInstance();
protected Logger Log = LoggerFactory.getLogger(DocumentServiceImpl.class);
private final McpEnvironmentChecker mcpEnvironmentChecker = new McpEnvironmentChecker();
protected Logger Log = LoggerFactory.getLogger(McpServiceImpl.class);
@Override
public Result<?> saveMcp(Mcp entity) {
@@ -49,6 +53,8 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
if (!StringUtil.hasText(serverName)) {
return Result.fail("未找到mcp服务名称", serverName);
}
entity.setTransportType(getFirstMcpTransportType(entity.getConfigJson()));
entity.setApprovalRequired(Boolean.TRUE.equals(entity.getApprovalRequired()));
try {
mcpClientManager.registerFromJson(entity.getConfigJson());
} catch (Exception e) {
@@ -79,6 +85,8 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
if (!StringUtil.hasText(serverName)) {
return Result.fail("未找到mcp服务名称", serverName);
}
entity.setTransportType(getFirstMcpTransportType(entity.getConfigJson()));
entity.setApprovalRequired(Boolean.TRUE.equals(entity.getApprovalRequired()));
if (entity.getStatus()) {
try {
mcpClientManager.registerFromJson(entity.getConfigJson());
@@ -121,6 +129,7 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
records.forEach(mcp -> {
boolean clientOnline = mcpClientManager.isClientOnline(getFirstMcpServerName(mcp.getConfigJson()));
mcp.setClientOnline(clientOnline);
mcp.setTransportType(resolveMcpTransportType(mcp));
}
);
page.getData().setRecords(records);
@@ -130,6 +139,9 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
@Override
public Mcp getMcpTools(String id) {
Mcp mcp = this.getById(id);
if (mcp != null) {
mcp.setTransportType(resolveMcpTransportType(mcp));
}
if (mcp != null && mcp.getStatus()) {
McpSyncClient mcpClient = getMcpClient(mcp, mcpClientManager);
List<McpSchema.Tool> tools = null;
@@ -209,9 +221,27 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
return firstServerName.orElse(null);
}
public static String getFirstMcpTransportType(String mcpJson) {
JSONObject rootJson = JSON.parseObject(mcpJson);
JSONObject mcpServersJson = rootJson.getJSONObject("mcpServers");
if (mcpServersJson == null || mcpServersJson.isEmpty()) {
return McpTransportType.STDIO.getValue();
}
Optional<String> firstServerName = mcpServersJson.keySet().stream().findFirst();
if (firstServerName.isEmpty()) {
return McpTransportType.STDIO.getValue();
}
JSONObject serverJson = mcpServersJson.getJSONObject(firstServerName.get());
if (serverJson == null) {
return McpTransportType.STDIO.getValue();
}
return McpTransportType.from(serverJson.getString("transport")).getValue();
}
@Override
public Page<Mcp> pageTools(Page<Mcp> page) {
page.getRecords().forEach(mcp -> {
mcp.setTransportType(resolveMcpTransportType(mcp));
// mcp 未启用,不查询工具
if (!mcp.getStatus()) {
return;
@@ -235,6 +265,11 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
return page;
}
@Override
public McpEnvironmentCheckResult checkMcp(String configJson) {
return mcpEnvironmentChecker.check(configJson);
}
private Result<?> validateMcpConfig(Mcp entity) {
if (entity == null || !StringUtil.hasText(entity.getConfigJson())) {
Log.error("MCP 配置不能为空");
@@ -242,4 +277,14 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
}
return Result.ok();
}
private String resolveMcpTransportType(Mcp mcp) {
if (mcp == null) {
return McpTransportType.STDIO.getValue();
}
if (StringUtil.hasText(mcp.getTransportType())) {
return McpTransportType.from(mcp.getTransportType()).getValue();
}
return getFirstMcpTransportType(mcp.getConfigJson());
}
}

View File

@@ -0,0 +1,97 @@
package tech.easyflow.ai.documentimport.task;
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.scheduling.concurrent.ThreadPoolTaskExecutor;
import tech.easyflow.ai.entity.Document;
import tech.easyflow.ai.mapper.DocumentMapper;
import java.lang.reflect.Field;
import java.math.BigInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* {@link DocumentImportTaskStatusStreamService} 回归测试。
*/
public class DocumentImportTaskStatusStreamServiceTest {
/**
* 验证文档状态变更会向 Redis 广播文档 ID。
*
* @throws Exception 反射注入异常
*/
@Test
public void publishAfterCommitShouldBroadcastDocumentId() throws Exception {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
DocumentImportTaskStatusStreamService service = new DocumentImportTaskStatusStreamService();
setField(service, "documentMapper", mockDocumentMapper());
setField(service, "sseThreadPool", directExecutor());
setField(service, "stringRedisTemplate", redisTemplate);
setField(service, "statusBroadcastProperties", statusBroadcastProperties());
service.publishAfterCommit(BigInteger.valueOf(101));
Mockito.verify(redisTemplate).convertAndSend("easyflow:document-import:test-status", "101");
}
/**
* 验证收到 Redis 广播后会重新查询文档状态。
*
* @throws Exception 反射注入异常
*/
@Test
public void publishLocalShouldReloadDocumentStatus() throws Exception {
AtomicReference<BigInteger> selectedIdRef = new AtomicReference<BigInteger>();
DocumentImportTaskStatusStreamService service = new DocumentImportTaskStatusStreamService();
setField(service, "documentMapper", mockDocumentMapper(selectedIdRef));
setField(service, "sseThreadPool", directExecutor());
setField(service, "stringRedisTemplate", Mockito.mock(StringRedisTemplate.class));
setField(service, "statusBroadcastProperties", statusBroadcastProperties());
service.publishLocal(BigInteger.valueOf(202));
Assert.assertEquals(BigInteger.valueOf(202), selectedIdRef.get());
}
private DocumentImportStatusBroadcastProperties statusBroadcastProperties() {
DocumentImportStatusBroadcastProperties properties = new DocumentImportStatusBroadcastProperties();
properties.setStatusBroadcastChannel("easyflow:document-import:test-status");
return properties;
}
private DocumentMapper mockDocumentMapper() {
return mockDocumentMapper(new AtomicReference<BigInteger>());
}
private DocumentMapper mockDocumentMapper(AtomicReference<BigInteger> selectedIdRef) {
DocumentMapper mapper = Mockito.mock(DocumentMapper.class);
Mockito.when(mapper.selectOneById(ArgumentMatchers.any())).thenAnswer(invocation -> {
Object id = invocation.getArgument(0);
selectedIdRef.set((BigInteger) id);
Document document = new Document();
document.setId((BigInteger) id);
document.setCollectionId(BigInteger.valueOf(1));
return document;
});
return mapper;
}
private ThreadPoolTaskExecutor directExecutor() {
ThreadPoolTaskExecutor executor = Mockito.mock(ThreadPoolTaskExecutor.class);
Mockito.doAnswer(invocation -> {
Runnable runnable = invocation.getArgument(0);
runnable.run();
return null;
}).when(executor).execute(ArgumentMatchers.any(Runnable.class));
return executor;
}
private void setField(Object target, String fieldName, Object value) throws Exception {
Field field = DocumentImportTaskStatusStreamService.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
}

View File

@@ -0,0 +1,58 @@
package tech.easyflow.ai.mcp;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.service.impl.McpServiceImpl;
import tech.easyflow.common.web.exceptions.BusinessException;
/**
* {@link McpTransportType} 单元测试。
*/
public class McpTransportTypeTest {
/**
* 应兼容解析 MCP 配置中常见的连接方式文本。
*/
@Test
public void fromShouldParseSupportedTransportTypes() {
Assert.assertEquals(McpTransportType.STDIO, McpTransportType.from("stdio"));
Assert.assertEquals(McpTransportType.SSE, McpTransportType.from("sse"));
Assert.assertEquals(McpTransportType.SSE, McpTransportType.from("http-sse"));
Assert.assertEquals(McpTransportType.HTTP, McpTransportType.from("http"));
Assert.assertEquals(McpTransportType.HTTP, McpTransportType.from("http-stream"));
Assert.assertEquals(McpTransportType.HTTP, McpTransportType.from("streamable-http"));
Assert.assertEquals(McpTransportType.STDIO, McpTransportType.from(null));
Assert.assertEquals(McpTransportType.STDIO, McpTransportType.from(" "));
}
/**
* 应从 MCP 配置 JSON 中推断首个 server 的连接方式。
*/
@Test
public void getFirstMcpTransportTypeShouldInferFromConfigJson() {
Assert.assertEquals("stdio", McpServiceImpl.getFirstMcpTransportType("""
{"mcpServers":{"everything":{"command":"npx","args":["-y","@modelcontextprotocol/server-everything"]}}}
"""));
Assert.assertEquals("http-sse", McpServiceImpl.getFirstMcpTransportType("""
{"mcpServers":{"remote":{"transport":"http-sse","url":"http://127.0.0.1:3000/sse"}}}
"""));
Assert.assertEquals("http-stream", McpServiceImpl.getFirstMcpTransportType("""
{"mcpServers":{"remote":{"transport":"http-stream","url":"http://127.0.0.1:3000/mcp"}}}
"""));
}
/**
* 不支持的连接方式应直接失败,避免保存无法启动的 MCP 配置。
*/
@Test
public void getFirstMcpTransportTypeShouldRejectUnsupportedTransportType() {
try {
McpServiceImpl.getFirstMcpTransportType("""
{"mcpServers":{"remote":{"transport":"websocket","url":"ws://127.0.0.1:3000/mcp"}}}
""");
Assert.fail("expected BusinessException");
} catch (BusinessException exception) {
Assert.assertTrue(exception.getMessage().contains("不支持的 MCP 连接方式"));
}
}
}

View File

@@ -70,4 +70,25 @@ public class ChatAssistantAccumulatorTest {
Assert.assertEquals(1, secondToolCalls.size());
Assert.assertEquals("call-2", secondToolCalls.get(0).get("id"));
}
/**
* 工具展示名应进入展示链和 assistant toolCalls但不覆盖真实工具名。
*/
@Test
@SuppressWarnings("unchecked")
public void shouldKeepToolDisplayNameWithoutOverridingToolName() {
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
accumulator.appendToolCall("call-1", "mcp_123_search", "知识库 MCP - search", "{\"q\":\"java\"}");
accumulator.appendToolResult("call-1", "mcp_123_search", "知识库 MCP - search", "{\"ok\":true}");
Map<String, Object> payload = accumulator.buildPayload(null);
List<Map<String, Object>> chains = (List<Map<String, Object>>) payload.get("chains");
List<Map<String, Object>> messageChain = (List<Map<String, Object>>) payload.get("messageChain");
List<Map<String, Object>> toolCalls = (List<Map<String, Object>>) messageChain.get(0).get("toolCalls");
Assert.assertEquals("mcp_123_search", chains.get(0).get("name"));
Assert.assertEquals("知识库 MCP - search", chains.get(0).get("toolDisplayName"));
Assert.assertEquals("mcp_123_search", toolCalls.get(0).get("name"));
Assert.assertEquals("知识库 MCP - search", toolCalls.get(0).get("toolDisplayName"));
}
}

View File

@@ -23,4 +23,51 @@
<scope>test</scope>
</dependency>
</dependencies>
<profiles>
<profile>
<id>release-obfuscation</id>
<build>
<plugins>
<plugin>
<groupId>com.github.wvengen</groupId>
<artifactId>proguard-maven-plugin</artifactId>
<version>${proguard.maven.plugin.version}</version>
<dependencies>
<dependency>
<groupId>com.guardsquare</groupId>
<artifactId>proguard-base</artifactId>
<version>${proguard.version}</version>
</dependency>
</dependencies>
<executions>
<execution>
<id>release-obfuscation</id>
<phase>package</phase>
<goals>
<goal>proguard</goal>
</goals>
</execution>
</executions>
<configuration>
<proguardVersion>${proguard.version}</proguardVersion>
<proguardInclude>${maven.multiModuleProjectDirectory}/config/proguard/easyflow-module-autoconfig.pro</proguardInclude>
<mappingFileName>proguard-map-${project.artifactId}.txt</mappingFileName>
<seedFileName>proguard-seed-${project.artifactId}.txt</seedFileName>
<includeDependency>true</includeDependency>
<includeDependencyInjar>false</includeDependencyInjar>
<attach>false</attach>
<attachMap>false</attachMap>
<appendClassifier>false</appendClassifier>
<addMavenDescriptor>false</addMavenDescriptor>
<addManifest>true</addManifest>
<putLibraryJarsInTempDir>true</putLibraryJarsInTempDir>
<generateTemporaryConfigurationFile>true</generateTemporaryConfigurationFile>
<bindToMavenLogging>true</bindToMavenLogging>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

View File

@@ -4,19 +4,33 @@ import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import tech.easyflow.chatlog.config.ChatSyncProperties;
import tech.easyflow.chatlog.service.ChatSyncService;
import tech.easyflow.common.cache.DistributedScheduledLock;
/**
* 聊天记录同步定时任务。
*/
@Component
public class ChatSyncScheduler {
private final ChatSyncService chatSyncService;
private final ChatSyncProperties syncProperties;
/**
* 创建聊天记录同步定时任务。
*
* @param chatSyncService 聊天同步服务
* @param syncProperties 同步配置
*/
public ChatSyncScheduler(ChatSyncService chatSyncService, ChatSyncProperties syncProperties) {
this.chatSyncService = chatSyncService;
this.syncProperties = syncProperties;
}
/**
* 同步聊天会话摘要。
*/
@Scheduled(fixedDelayString = "${easyflow.chat.sync.fixed-delay:30000}", initialDelay = 10000L)
@DistributedScheduledLock(key = "easyflow:schedule:chat-sync:sessions", leaseSeconds = 300L)
public void syncSessions() {
if (!syncProperties.isEnabled()) {
return;
@@ -24,7 +38,11 @@ public class ChatSyncScheduler {
chatSyncService.syncSessions();
}
/**
* 同步聊天日志明细。
*/
@Scheduled(fixedDelayString = "${easyflow.chat.sync.fixed-delay:30000}", initialDelay = 15000L)
@DistributedScheduledLock(key = "easyflow:schedule:chat-sync:logs", leaseSeconds = 300L)
public void syncLogs() {
if (!syncProperties.isEnabled()) {
return;
@@ -32,7 +50,11 @@ public class ChatSyncScheduler {
chatSyncService.syncLogs();
}
/**
* 修复近期聊天日志同步缺口。
*/
@Scheduled(cron = "0 15 3 * * *")
@DistributedScheduledLock(key = "easyflow:schedule:chat-sync:repair-logs", leaseSeconds = 300L)
public void repairLogs() {
if (!syncProperties.isEnabled()) {
return;
@@ -40,7 +62,11 @@ public class ChatSyncScheduler {
chatSyncService.repairLogs();
}
/**
* 维护聊天日志 MySQL 分表。
*/
@Scheduled(cron = "0 0 2 * * *")
@DistributedScheduledLock(key = "easyflow:schedule:chat-sync:maintain-mysql-tables", leaseSeconds = 300L)
public void maintainMysqlTables() {
chatSyncService.maintainMysqlTables();
}

View File

@@ -46,4 +46,51 @@
<artifactId>easyflow-common-web</artifactId>
</dependency>
</dependencies>
<profiles>
<profile>
<id>release-obfuscation</id>
<build>
<plugins>
<plugin>
<groupId>com.github.wvengen</groupId>
<artifactId>proguard-maven-plugin</artifactId>
<version>${proguard.maven.plugin.version}</version>
<dependencies>
<dependency>
<groupId>com.guardsquare</groupId>
<artifactId>proguard-base</artifactId>
<version>${proguard.version}</version>
</dependency>
</dependencies>
<executions>
<execution>
<id>release-obfuscation</id>
<phase>package</phase>
<goals>
<goal>proguard</goal>
</goals>
</execution>
</executions>
<configuration>
<proguardVersion>${proguard.version}</proguardVersion>
<proguardInclude>${maven.multiModuleProjectDirectory}/config/proguard/easyflow-module-datacenter.pro</proguardInclude>
<mappingFileName>proguard-map-${project.artifactId}.txt</mappingFileName>
<seedFileName>proguard-seed-${project.artifactId}.txt</seedFileName>
<includeDependency>true</includeDependency>
<includeDependencyInjar>false</includeDependencyInjar>
<attach>false</attach>
<attachMap>false</attachMap>
<appendClassifier>false</appendClassifier>
<addMavenDescriptor>false</addMavenDescriptor>
<addManifest>true</addManifest>
<putLibraryJarsInTempDir>true</putLibraryJarsInTempDir>
<generateTemporaryConfigurationFile>true</generateTemporaryConfigurationFile>
<bindToMavenLogging>true</bindToMavenLogging>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

View File

@@ -39,6 +39,7 @@ easyflow:
redis:
database: 1
stream-prefix: easyflow:mq
consumer-instance-id: ${EASYFLOW_INSTANCE_ID:${HOSTNAME:${random.uuid}}}
chat-persist-shard-count: 4
consumer-batch-size: 200
consumer-block-timeout: 2000ms
@@ -74,11 +75,19 @@ easyflow:
validate-on-migrate: true
storage:
type: xFileStorage
agent:
runtime:
instance-id: ${EASYFLOW_INSTANCE_ID:${HOSTNAME:${random.uuid}}}
route-ttl: 24h
command-topic-prefix: easyflow:agent-runtime-command
command-result-timeout: 5s
command-result-ttl: 5m
ai:
rag:
health:
cache-ttl: 5s
document-import:
status-broadcast-channel: easyflow:document-import:status
parse-monitor:
fixed-delay: 10000
initial-delay: 10000

View File

@@ -106,14 +106,15 @@ easyflow:
redis:
database: 1
stream-prefix: easyflow:mq
consumer-instance-id: ${EASYFLOW_INSTANCE_ID:${HOSTNAME:${random.uuid}}}
chat-persist-shard-count: 4
consumer-batch-size: 200
consumer-block-timeout: 2000ms
pending-claim-idle: 60000ms
max-retry: 16
consumer-executor:
core-size: 4
max-size: 12
core-size: 16
max-size: 24
queue-capacity: 64
keep-alive-seconds: 60
pool:
@@ -148,6 +149,13 @@ easyflow:
access-key-secret: xxx
app-key: xxx
voice: siyue
agent:
runtime:
instance-id: ${EASYFLOW_INSTANCE_ID:${HOSTNAME:${random.uuid}}}
route-ttl: 24h
command-topic-prefix: easyflow:agent-runtime-command
command-result-timeout: 5s
command-result-ttl: 5m
login:
# 放行接口路径
excludes: /api/v1/auth/**, /static/**, /userCenter/auth/**, /userCenter/public/**
@@ -169,6 +177,7 @@ easyflow:
health:
cache-ttl: 5s
document-import:
status-broadcast-channel: easyflow:document-import:status
parse-monitor:
fixed-delay: 10000
initial-delay: 10000

View File

@@ -0,0 +1,3 @@
ALTER TABLE `tb_mcp`
ADD COLUMN `transport_type` varchar(32) NULL DEFAULT NULL COMMENT 'MCP连接方式' AFTER `config_json`,
ADD COLUMN `approval_required` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否启用工具调用审批' AFTER `transport_type`;

View File

@@ -3,6 +3,7 @@
"title": "Title",
"description": "Description",
"configJson": "ConfigJson",
"approvalRequired": "Approval Required",
"deptId": "DeptId",
"tenantId": "TenantId",
"created": "Created",
@@ -27,5 +28,23 @@
"labels": {
"clientOnline": "ClientOnline",
"clientOffline": "ClientOffline"
},
"jsonEditor": {
"format": "Format",
"invalid": "Invalid JSON"
},
"check": {
"action": "Check",
"overall": "Overall",
"resultTitle": "MCP Environment Check",
"toolCount": "Tools",
"message": {
"configRequired": "Please enter MCP config JSON first"
},
"status": {
"success": "Passed",
"warning": "Warning",
"failed": "Failed"
}
}
}

View File

@@ -3,6 +3,7 @@
"title": "名称",
"description": "描述",
"configJson": "MCP配置JSON",
"approvalRequired": "执行前审批",
"deptId": "部门ID",
"tenantId": "租户ID",
"created": "创建时间",
@@ -27,5 +28,23 @@
"labels": {
"clientOnline": "客户端在线",
"clientOffline": "客户端离线"
},
"jsonEditor": {
"format": "格式化",
"invalid": "JSON 格式错误"
},
"check": {
"action": "检测",
"overall": "整体状态",
"resultTitle": "MCP 环境检测",
"toolCount": "工具数",
"message": {
"configRequired": "请先填写 MCP 配置 JSON"
},
"status": {
"success": "通过",
"warning": "警告",
"failed": "失败"
}
}
}

View File

@@ -111,6 +111,49 @@ describe('agentTimelineAdapter', () => {
).toBe(true);
});
it('uses tool display name when restoring aliased MCP tools from history', () => {
const items = recordsToTimelineItems([
{
id: 'mcp-history',
senderRole: 'assistant',
contentText: '已完成',
roundId: 'round-mcp',
contentPayload: {
chains: [
{
id: 'tool-mcp-1',
name: 'mcp_123_search',
toolDisplayName: 'Context MCP - search',
status: 'TOOL_RESULT',
result: 'ok',
},
],
messageChain: [
{
role: 'assistant',
toolCalls: [
{
id: 'tool-mcp-1',
name: 'mcp_123_search',
toolDisplayName: 'Context MCP - search',
arguments: '{}',
},
],
},
{
role: 'tool',
toolCallId: 'tool-mcp-1',
content: 'ok',
},
],
},
},
]);
const tool = items.find((item) => item.type === 'tool');
expect(tool?.toolName).toBe('Context MCP - search');
});
it('hides knowledge retrieval cards when restoring agent chat history', () => {
const items = recordsToTimelineItems([
{

View File

@@ -5,6 +5,7 @@ import type {
ChatTimelineKnowledgeHit,
ChatTimelineMessageItem,
ChatTimelineToolApprovalPayload,
ChatTimelineToolStatus,
} from '@easyflow/common-ui';
import {ChatTimelineBuilder} from '@easyflow/common-ui';
@@ -30,6 +31,17 @@ function asArray(value: unknown): any[] {
return Array.isArray(value) ? value : [];
}
function asyncToolTimelineStatus(
payload: Record<string, any>,
): ChatTimelineToolStatus {
const status = asText(payload.status).toUpperCase();
if (status === 'SUCCEEDED') return 'success';
if (status === 'FAILED' || status === 'TIMEOUT' || status === 'CANCELLED') {
return 'error';
}
return 'running';
}
function asTimestamp(value: unknown) {
if (!value) {
return Date.now();
@@ -68,7 +80,9 @@ function shouldSkipToolProjection(value: unknown) {
function normalizeToolCallName(payload: Record<string, any>) {
const fn = asRecord(payload.function);
return normalizeToolName(payload.name ?? payload.toolName ?? fn.name);
return normalizeToolName(
payload.toolDisplayName ?? payload.name ?? payload.toolName ?? fn.name,
);
}
function normalizeToolCallInput(payload: Record<string, any>) {
@@ -211,7 +225,9 @@ function projectHistoryChain(
hasAssistantThinking = true;
continue;
}
const toolName = normalizeToolName(item.name ?? item.toolName);
const toolName = normalizeToolName(
item.toolDisplayName ?? item.name ?? item.toolName,
);
const toolCallId = normalizeToolCallId(item);
if (toolCallId && toolName) {
toolNameByCallId.set(toolCallId, toolName);
@@ -423,19 +439,29 @@ export function applyAgentSseEnvelope(
return;
}
if (domain === 'TOOL' && (type === 'TOOL_CALL' || type === 'TOOL_RESULT')) {
const asyncTool = payload.asyncTool === true;
const toolName = normalizeToolName(
payload.toolDisplayName ?? payload.toolName ?? payload.name,
);
ChatTimelineBuilder.upsertToolCall(items, {
input: payload.input ?? payload.toolInput,
output: payload.output ?? payload.result ?? payload.text,
status: type === 'TOOL_RESULT' ? 'success' : 'running',
output: asyncTool
? payload.summary ?? payload.label ?? payload.output ?? payload.result ?? payload.text
: payload.output ?? payload.result ?? payload.text,
status: asyncTool
? asyncToolTimelineStatus(payload)
: type === 'TOOL_RESULT'
? 'success'
: 'running',
statusKey: statusKeyForProjection(
payload,
metadata,
'knowledge-retrieval',
),
toolCallId: normalizeToolCallId(payload),
toolName: normalizeToolName(
payload.toolDisplayName ?? payload.toolName ?? payload.name,
),
toolCallId: asyncTool
? asText(payload.toolCallId ?? payload.taskId ?? payload.id)
: normalizeToolCallId(payload),
toolName,
});
return;
}

View File

@@ -1,14 +1,18 @@
<script setup lang="ts">
/* cspell:ignore tryit */
import type {AgentCapabilityKind, AgentOption, AgentValidationIssue,} from './types';
import type {
AgentCapabilityKind,
AgentOption,
AgentValidationIssue,
} from './types';
import {computed, onMounted, ref} from 'vue';
import {useRoute, useRouter} from 'vue-router';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {ElMessage, ElMessageBox} from 'element-plus';
import {tryit} from 'radash';
import { ElMessage, ElMessageBox } from 'element-plus';
import { tryit } from 'radash';
import {api} from '#/api/request';
import { api } from '#/api/request';
import {
canAiResourceOffline,
canAiResourcePublish,
@@ -30,7 +34,7 @@ import {
import AgentStudioCanvas from './components/agent-studio/AgentStudioCanvas.vue';
import AgentCommandBar from './components/AgentCommandBar.vue';
import AgentInspectorPanel from './components/AgentInspectorPanel.vue';
import {useAgentDesignerState} from './composables/useAgentDesignerState';
import { useAgentDesignerState } from './composables/useAgentDesignerState';
const route = useRoute();
const router = useRouter();
@@ -62,6 +66,7 @@ const models = ref<AgentOption[]>([]);
const knowledges = ref<AgentOption[]>([]);
const workflows = ref<AgentOption[]>([]);
const pluginTools = ref<AgentOption[]>([]);
const mcps = ref<AgentOption[]>([]);
const isNew = computed(() => String(route.params.id || '') === 'new');
const publishText = computed(() => {
@@ -132,6 +137,21 @@ async function loadAgent() {
}
}
async function refreshAgentLifecycleState() {
if (!state.agent.id) return;
const [, res] = await tryit(getAgentDetail)(String(state.agent.id));
if (res?.errorCode !== 0 || !res.data) {
return;
}
const agentState = { ...res.data };
delete agentState.knowledgeBindings;
delete agentState.toolBindings;
state.agent = {
...state.agent,
...agentState,
};
}
function hasNavTitle() {
const navTitle = Array.isArray(route.query.navTitle)
? route.query.navTitle[0]
@@ -172,7 +192,7 @@ function syncNavTitle(title: string, options: { force?: boolean } = {}) {
}
async function loadOptions() {
const [categoryRes, modelRes, knowledgeRes, workflowRes, pluginRes] =
const [categoryRes, modelRes, knowledgeRes, workflowRes, pluginRes, mcpRes] =
await Promise.all([
api.get('/api/v1/agentCategory/visibleList', {
params: { sortKey: 'sortNo', sortType: 'asc' },
@@ -185,6 +205,9 @@ async function loadOptions() {
api.get('/api/v1/plugin/pageByCategory', {
params: { pageNumber: 1, pageSize: 200, category: 0 },
}),
api.get('/api/v1/mcp/pageTools', {
params: { pageNumber: 1, pageSize: 200, status: 1 },
}),
]);
categories.value = (categoryRes.data || []).map((item: any) => ({
@@ -212,6 +235,7 @@ async function loadOptions() {
pluginTools.value = flattenPluginTools(
pluginRes.data?.records || pluginRes.data || [],
);
mcps.value = mapMcpOptions(mcpRes.data?.records || mcpRes.data || []);
}
function flattenPluginTools(list: any[]): AgentOption[] {
@@ -229,6 +253,22 @@ function flattenPluginTools(list: any[]): AgentOption[] {
return result;
}
function mapMcpOptions(list: any[]): AgentOption[] {
return list.map((mcp) => ({
label: mcp.title || mcp.name || 'MCP',
value: String(mcp.id),
raw: {
...mcp,
id: mcp.id,
mcpId: mcp.id,
mcpTitle: mcp.title || mcp.name,
title: mcp.title || mcp.name,
tools: Array.isArray(mcp.tools) ? mcp.tools : [],
approvalRequired: Boolean(mcp.approvalRequired),
},
}));
}
async function handleAdd(kind: AgentCapabilityKind) {
if (kind === 'knowledge') {
addKnowledgeNode();
@@ -331,7 +371,7 @@ async function handlePublish() {
const res = await submitAgentPublishApproval(String(state.agent.id));
if (res.errorCode === 0) {
ElMessage.success(res.message || '已提交');
await loadAgent();
await refreshAgentLifecycleState();
}
} finally {
publishLoading.value = false;
@@ -358,7 +398,7 @@ async function handleOffline() {
const res = await submitAgentOfflineApproval(String(state.agent.id));
if (res.errorCode === 0) {
ElMessage.success(res.message || '已提交');
await loadAgent();
await refreshAgentLifecycleState();
}
} finally {
offlineLoading.value = false;
@@ -380,7 +420,10 @@ function handleCloseTryout() {
<AgentStudioCanvas
:state="state"
:knowledge-options="knowledges"
:mcp-options="mcps"
:plugin-options="pluginTools"
:selected-node-id="state.selectedNodeId"
:workflow-options="workflows"
@select="handleSelectNode"
/>
<AgentInspectorPanel
@@ -390,6 +433,7 @@ function handleCloseTryout() {
:knowledges="knowledges"
:workflows="workflows"
:plugin-tools="pluginTools"
:mcps="mcps"
:issues="issues"
@change="markDirty"
@remove-capability="removeSelectedCapability"

View File

@@ -1,9 +1,17 @@
<script setup lang="ts">
import type {AgentCapabilityKind} from '../types';
import type { AgentCapabilityKind } from '../types';
import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import {Connection, Files, Loading, Plus, Share, VideoPlay,} from '@element-plus/icons-vue';
import {
Connection,
Files,
Link,
Loading,
Plus,
Share,
VideoPlay,
} from '@element-plus/icons-vue';
const props = defineProps<{
offlineDisabled?: boolean;
@@ -47,6 +55,12 @@ const capabilityItems = [
desc: '执行工具能力',
icon: Connection,
},
{
kind: 'mcp' as const,
title: 'MCP',
desc: '连接外部工具',
icon: Link,
},
];
function handleAdd(kind: AgentCapabilityKind) {

View File

@@ -1,10 +1,14 @@
<script setup lang="ts">
import type {AgentDraftState, AgentOption, AgentValidationIssue,} from '../types';
import type {
AgentDraftState,
AgentOption,
AgentValidationIssue,
} from '../types';
import {computed} from 'vue';
import { computed } from 'vue';
import {Close} from '@element-plus/icons-vue';
import {ElButton} from 'element-plus';
import { Close } from '@element-plus/icons-vue';
import { ElButton } from 'element-plus';
import AgentBaseForm from './AgentBaseForm.vue';
import AgentKnowledgeForm from './AgentKnowledgeForm.vue';
@@ -15,6 +19,7 @@ const props = defineProps<{
categories: AgentOption[];
issues: AgentValidationIssue[];
knowledges: AgentOption[];
mcps: AgentOption[];
models: AgentOption[];
pluginTools: AgentOption[];
state: AgentDraftState;
@@ -40,11 +45,18 @@ const selectedTool = computed(() => {
return props.state.toolBindings.find((item) => item.localId === localId);
});
const selectedToolKind = computed(() =>
String(selectedTool.value?.toolType || '').toUpperCase() === 'WORKFLOW'
? 'workflow'
: 'plugin',
);
const selectedToolKind = computed(() => {
const toolType = String(selectedTool.value?.toolType || '').toUpperCase();
if (toolType === 'WORKFLOW') return 'workflow';
if (toolType === 'MCP') return 'mcp';
return 'plugin';
});
const selectedToolOptions = computed(() => {
if (selectedToolKind.value === 'workflow') return props.workflows;
if (selectedToolKind.value === 'mcp') return props.mcps;
return props.pluginTools;
});
</script>
<template>
@@ -100,7 +112,7 @@ const selectedToolKind = computed(() =>
v-else-if="selectedTool"
:binding="selectedTool"
:kind="selectedToolKind"
:options="selectedToolKind === 'workflow' ? workflows : pluginTools"
:options="selectedToolOptions"
@change="emit('change')"
@remove="emit('removeCapability')"
/>

View File

@@ -1,12 +1,23 @@
<script setup lang="ts">
/* eslint-disable vue/no-mutating-props */
import type {AgentOption, AgentToolBinding} from '../types';
import type { AgentOption, AgentToolBinding } from '../types';
import {ElButton, ElForm, ElFormItem, ElInput, ElOption, ElSelect, ElSwitch,} from 'element-plus';
import { computed } from 'vue';
import {
ElButton,
ElEmpty,
ElForm,
ElFormItem,
ElInput,
ElOption,
ElSelect,
ElSwitch,
} from 'element-plus';
const props = defineProps<{
binding: AgentToolBinding;
kind: 'plugin' | 'workflow';
kind: 'mcp' | 'plugin' | 'workflow';
options: AgentOption[];
}>();
@@ -15,7 +26,7 @@ const emit = defineEmits<{
remove: [];
}>();
const SAFE_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
const SAFE_TOOL_NAME_PATTERN = /^[\w-]+$/;
function isSafeToolName(name?: string) {
return SAFE_TOOL_NAME_PATTERN.test(String(name || ''));
@@ -40,11 +51,60 @@ function shouldSyncToolName() {
return name.startsWith(prefix);
}
const targetValue = computed({
get() {
return props.binding.targetId ? String(props.binding.targetId) : '';
},
set(value: string) {
props.binding.targetId = value;
},
});
const resourceLabel = computed(() => {
if (props.kind === 'workflow') return '工作流';
if (props.kind === 'mcp') return 'MCP';
return '插件工具';
});
const selectedMcpTools = computed(() => {
if (props.kind !== 'mcp') {
return [];
}
const option = props.options.find(
(item) => String(item.value) === String(props.binding.targetId),
);
const tools = option?.raw?.tools || props.binding.resourceSummary?.tools;
return Array.isArray(tools) ? tools : [];
});
const selectedMcpToolCount = computed(() => selectedMcpTools.value.length);
const asyncExecutionEnabled = computed({
get() {
return String(props.binding.optionsJson?.executionMode || '').toUpperCase() === 'ASYNC';
},
set(value: boolean) {
props.binding.optionsJson = {
...(props.binding.optionsJson || {}),
executionMode: value ? 'ASYNC' : 'SYNC',
};
emit('change');
},
});
function handleTargetChange(value: string) {
const option = props.options.find(
(item) => String(item.value) === String(value),
);
props.binding.resourceSummary = option?.raw || {};
if (props.kind === 'mcp') {
props.binding.targetId = option?.raw?.mcpId
? String(option.raw.mcpId)
: value;
props.binding.toolName = '';
emit('change');
return;
}
if (shouldSyncToolName()) {
props.binding.toolName = resolveToolName(option);
}
@@ -54,9 +114,9 @@ function handleTargetChange(value: string) {
<template>
<ElForm label-position="top" class="agent-form">
<ElFormItem :label="kind === 'workflow' ? '工作流' : '插件工具'" required>
<ElFormItem :label="resourceLabel" required>
<ElSelect
v-model="binding.targetId"
v-model="targetValue"
filterable
placeholder="选择资源"
@change="handleTargetChange"
@@ -69,7 +129,28 @@ function handleTargetChange(value: string) {
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="工具名称" required>
<div v-if="kind === 'mcp'" class="agent-form__mcp-tools">
<div class="agent-form__mcp-tools-header">
<span>工具列表</span>
<span>{{ selectedMcpToolCount }} </span>
</div>
<div v-if="selectedMcpTools.length > 0" class="agent-form__mcp-tool-list">
<div
v-for="tool in selectedMcpTools"
:key="tool.name || tool.title"
class="agent-form__mcp-tool"
>
<div class="agent-form__mcp-tool-name">
{{ tool.name || tool.title || '未命名工具' }}
</div>
<div v-if="tool.description" class="agent-form__mcp-tool-desc">
{{ tool.description }}
</div>
</div>
</div>
<ElEmpty v-else description="暂无工具" :image-size="64" />
</div>
<ElFormItem v-if="kind !== 'mcp'" label="工具名称" required>
<ElInput
v-model="binding.toolName"
placeholder="仅支持英文、数字、下划线或中划线"
@@ -79,6 +160,9 @@ function handleTargetChange(value: string) {
<ElFormItem label="执行前确认">
<ElSwitch v-model="binding.hitlEnabled" @change="emit('change')" />
</ElFormItem>
<ElFormItem v-if="kind !== 'mcp'" label="异步执行">
<ElSwitch v-model="asyncExecutionEnabled" />
</ElFormItem>
<ElButton
class="agent-form__danger"
type="danger"
@@ -99,6 +183,59 @@ function handleTargetChange(value: string) {
width: 100%;
}
.agent-form__mcp-tools {
margin-bottom: 16px;
}
.agent-form__mcp-tools-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.agent-form__mcp-tools-header span:last-child {
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.agent-form__mcp-tool-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.agent-form__mcp-tool {
padding: 10px 12px;
background: var(--el-fill-color-lighter);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.agent-form__mcp-tool-name {
overflow: hidden;
font-size: 13px;
font-weight: 600;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-form__mcp-tool-desc {
display: -webkit-box;
margin-top: 4px;
overflow: hidden;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.agent-form__danger {
width: 100%;
margin-top: 8px;

View File

@@ -34,8 +34,11 @@ import '@tinyflow-ai/vue/dist/index.css';
const props = defineProps<{
knowledgeOptions?: AgentOption[];
mcpOptions?: AgentOption[];
pluginOptions?: AgentOption[];
selectedNodeId: string;
state: AgentDraftState;
workflowOptions?: AgentOption[];
}>();
const emit = defineEmits<{
@@ -52,6 +55,11 @@ const canvasModel = useAgentStudioModel(
layout,
() => canvasSize.value,
() => props.knowledgeOptions || [],
() => ({
mcp: props.mcpOptions || [],
plugin: props.pluginOptions || [],
workflow: props.workflowOptions || [],
}),
);
const liveNodes = ref<AgentStudioNodeView[]>([]);
const liveViewport = ref<AgentStudioViewport>({ x: 250, y: 100, zoom: 1 });

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import type {AgentStudioNodeData} from './types';
import type { AgentStudioNodeData } from './types';
import {computed} from 'vue';
import { computed } from 'vue';
import {Connection, Cpu, Files, Share} from '@element-plus/icons-vue';
import {ElIcon} from 'element-plus';
import { Connection, Cpu, Files, Link, Share } from '@element-plus/icons-vue';
import { ElIcon } from 'element-plus';
const props = defineProps<{
data: AgentStudioNodeData;
@@ -14,6 +14,7 @@ const iconComponent = computed(() => {
const icons = {
base: Cpu,
knowledge: Files,
mcp: Link,
plugin: Connection,
workflow: Share,
};
@@ -108,6 +109,7 @@ const iconComponent = computed(() => {
}
.agent-studio-node--knowledge,
.agent-studio-node--mcp,
.agent-studio-node--workflow,
.agent-studio-node--plugin {
min-height: 78px;

View File

@@ -1,6 +1,6 @@
import {describe, expect, it} from 'vitest';
import {resolveCapabilityNodePosition} from './useAgentStudioModel';
import {useAgentStudioModel, resolveCapabilityNodePosition} from './useAgentStudioModel';
describe('resolveCapabilityNodePosition', () => {
it('无视口信息时沿用默认左侧列位置', () => {
@@ -57,3 +57,47 @@ describe('resolveCapabilityNodePosition', () => {
).toEqual({ x: 128, y: 256 });
});
});
describe('useAgentStudioModel', () => {
it('MCP 绑定缺少资源快照时从选项中回显节点信息', () => {
const model = useAgentStudioModel(
{
agent: {
name: '测试智能体',
},
dirty: false,
knowledgeBindings: [],
panelMode: 'capability',
selectedNodeId: 'tool:mcp-1',
toolBindings: [
{
localId: 'mcp-1',
targetId: '1001',
toolType: 'MCP',
},
],
},
() => 'tool:mcp-1',
undefined,
undefined,
undefined,
() => ({
mcp: [
{
label: 'context7',
value: '1001',
raw: {
id: '1001',
title: 'context7',
tools: [{ name: 'resolve-library-id' }, { name: 'query-docs' }],
},
},
],
}),
);
const mcpNode = model.value.nodes.find((node) => node.id === 'tool:mcp-1');
expect(mcpNode?.data.title).toBe('context7 · 2 个工具');
expect(mcpNode?.data.detail).toBe('context7 · 2 个工具');
});
});

View File

@@ -12,7 +12,7 @@ import type {
AgentToolBinding,
} from '../../types';
import {computed} from 'vue';
import { computed } from 'vue';
const BASE_NODE_ID = 'agent-base';
const BASE_POSITION = { x: 430, y: 260 };
@@ -57,19 +57,66 @@ function buildKnowledgeTitle(
);
}
function buildToolTitle(binding: AgentToolBinding) {
function findMatchedToolOption(
binding: AgentToolBinding,
options: AgentOption[],
) {
const targetId = String(binding.targetId || '');
if (!targetId) return undefined;
return options.find((item) => {
const raw = item.raw || {};
return (
String(item.value) === targetId ||
String(raw.id || '') === targetId ||
String(raw.mcpId || '') === targetId
);
});
}
function buildToolTitle(binding: AgentToolBinding, options: AgentOption[] = []) {
const matchedOption = findMatchedToolOption(binding, options);
return firstText(
binding.resourceSummary?.title,
binding.resourceSummary?.name,
binding.resourceSummary?.label,
binding.resourceSummary?.displayName,
binding.resourceSummary?.mcpTitle,
binding.resourceSnapshot?.title,
binding.resourceSnapshot?.name,
binding.resourceSnapshot?.label,
binding.resourceSnapshot?.displayName,
binding.resourceSnapshot?.mcpTitle,
matchedOption?.label,
matchedOption?.raw?.title,
matchedOption?.raw?.name,
matchedOption?.raw?.label,
matchedOption?.raw?.displayName,
matchedOption?.raw?.mcpTitle,
binding.toolName,
);
}
function buildToolDetail(binding: AgentToolBinding, fallback: string) {
function buildToolDetail(
binding: AgentToolBinding,
fallback: string,
options: AgentOption[] = [],
) {
if (String(binding.toolType || '').toUpperCase() === 'MCP') {
const matchedOption = findMatchedToolOption(binding, options);
const resourceName = buildToolTitle(binding, options);
const tools =
binding.resourceSummary?.tools ||
binding.resourceSnapshot?.tools ||
matchedOption?.raw?.tools ||
[];
const toolCount = Array.isArray(tools) ? tools.length : 0;
if (resourceName && toolCount > 0) {
return `${resourceName} · ${toolCount} 个工具`;
}
return resourceName || fallback;
}
const toolName = firstText(binding.toolName);
const resourceName = buildToolTitle(binding);
const resourceName = buildToolTitle(binding, options);
if (toolName && resourceName && toolName !== resourceName) {
return `${resourceName} / ${toolName}`;
}
@@ -157,6 +204,11 @@ export function useAgentStudioModel(
layout?: AgentStudioLayoutSnapshot,
canvasSize?: () => AgentStudioCanvasSize | undefined,
knowledgeOptions?: () => AgentOption[],
toolOptions?: () => {
mcp?: AgentOption[];
plugin?: AgentOption[];
workflow?: AgentOption[];
},
) {
return computed(() => {
const positionOf = (nodeId: string, fallback: { x: number; y: number }) =>
@@ -214,10 +266,20 @@ export function useAgentStudioModel(
});
const toolNodes = state.toolBindings.map((binding, index) => {
const nodeId = `tool:${binding.localId}`;
const isWorkflow =
String(binding.toolType || '').toUpperCase() === 'WORKFLOW';
const fallback = isWorkflow ? '待选择工作流' : '待选择插件工具';
const detail = buildToolDetail(binding, fallback);
const toolType = String(binding.toolType || '').toUpperCase();
const isWorkflow = toolType === 'WORKFLOW';
const isMcp = toolType === 'MCP';
const matchedOptions = isWorkflow
? toolOptions?.().workflow || []
: isMcp
? toolOptions?.().mcp || []
: toolOptions?.().plugin || [];
const fallback = isWorkflow
? '待选择工作流'
: isMcp
? '待选择 MCP'
: '待选择插件工具';
const detail = buildToolDetail(binding, fallback, matchedOptions);
const position = resolveCapabilityNodePosition({
canvasSize: size,
fallbackIndex: state.knowledgeBindings.length + index,
@@ -233,14 +295,20 @@ export function useAgentStudioModel(
width: CAPABILITY_NODE_WIDTH,
height: CAPABILITY_NODE_HEIGHT,
data: {
badge: isWorkflow ? '工作流' : '插件',
badge: isWorkflow ? '工作流' : isMcp ? 'MCP' : '插件',
detail,
iconKey: isWorkflow ? 'workflow' : 'plugin',
iconKey: isWorkflow ? 'workflow' : isMcp ? 'mcp' : 'plugin',
id: nodeId,
kind: isWorkflow ? 'workflow' : 'plugin',
kind: isWorkflow ? 'workflow' : isMcp ? 'mcp' : 'plugin',
selected: selectedNodeId() === nodeId,
title:
detail === fallback ? (isWorkflow ? '工作流' : '插件') : detail,
detail === fallback
? isWorkflow
? '工作流'
: isMcp
? 'MCP'
: '插件'
: detail,
} satisfies AgentStudioNodeData,
};
});

View File

@@ -7,10 +7,10 @@ import type {
AgentValidationIssue,
} from '../types';
import {computed, reactive} from 'vue';
import { computed, reactive } from 'vue';
const BASE_NODE_ID = 'agent-base';
const SAFE_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
const SAFE_TOOL_NAME_PATTERN = /^[\w-]+$/;
function createLocalId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
@@ -25,6 +25,15 @@ function buildFallbackToolName(prefix: string, resource?: Record<string, any>) {
return `${prefix}_${id}`;
}
function toolKindFromType(
toolType?: string,
): Exclude<AgentCapabilityKind, 'knowledge'> {
const normalized = String(toolType || '').toUpperCase();
if (normalized === 'WORKFLOW') return 'workflow';
if (normalized === 'MCP') return 'mcp';
return 'plugin';
}
function resolveToolName(
kind: Exclude<AgentCapabilityKind, 'knowledge'>,
resource?: Record<string, any>,
@@ -36,19 +45,19 @@ function resolveToolName(
return String(resource?.name);
}
return buildFallbackToolName(
kind === 'workflow' ? 'workflow' : 'plugin',
kind === 'workflow' ? 'workflow' : kind === 'mcp' ? 'mcp' : 'plugin',
resource,
);
}
function normalizeBindingToolName(binding: AgentToolBinding) {
if (String(binding.toolType || '').toUpperCase() === 'MCP') {
return '';
}
if (isSafeToolName(binding.toolName)) {
return String(binding.toolName);
}
const kind =
String(binding.toolType || '').toUpperCase() === 'WORKFLOW'
? 'workflow'
: 'plugin';
const kind = toolKindFromType(binding.toolType);
const resource = {
...(binding.resourceSnapshot || {}),
...(binding.resourceSummary || {}),
@@ -136,6 +145,12 @@ function normalizeToolBinding(
binding: AgentToolBinding,
index: number,
): AgentToolBinding {
const optionsJson = {
...(binding.optionsJson || {}),
};
if (String(optionsJson.executionMode || '').toUpperCase() !== 'ASYNC') {
optionsJson.executionMode = 'SYNC';
}
return {
...binding,
enabled: binding.enabled !== false,
@@ -145,9 +160,9 @@ function normalizeToolBinding(
String(
binding.id ||
createLocalId(String(binding.toolType || 'tool').toLowerCase()),
),
),
toolName: normalizeBindingToolName(binding),
optionsJson: binding.optionsJson || {},
optionsJson,
sortNo: binding.sortNo ?? index + 1,
};
}
@@ -178,10 +193,7 @@ export function useAgentDesignerState() {
(item) => item.localId === localId,
);
return {
kind:
String(binding?.toolType || '').toUpperCase() === 'WORKFLOW'
? ('workflow' as AgentCapabilityKind)
: ('plugin' as AgentCapabilityKind),
kind: toolKindFromType(binding?.toolType) as AgentCapabilityKind,
binding,
};
}
@@ -237,12 +249,17 @@ export function useAgentDesignerState() {
kind: Exclude<AgentCapabilityKind, 'knowledge'>,
resource?: Record<string, any>,
) {
const toolType = kind === 'workflow' ? 'WORKFLOW' : 'PLUGIN';
const toolType =
kind === 'workflow' ? 'WORKFLOW' : kind === 'mcp' ? 'MCP' : 'PLUGIN';
const binding = normalizeToolBinding(
{
toolType,
targetId: resource?.id ? String(resource.id) : '',
toolName: resolveToolName(kind, resource),
targetId: resource?.mcpId
? String(resource.mcpId)
: resource?.id
? String(resource.id)
: '',
toolName: kind === 'mcp' ? '' : resolveToolName(kind, resource),
resourceSummary: resource || {},
},
state.toolBindings.length,
@@ -299,6 +316,9 @@ export function useAgentDesignerState() {
if (!binding.targetId) {
issues.push({ nodeId, field: 'targetId', message: '请选择能力资源' });
}
if (String(binding.toolType || '').toUpperCase() === 'MCP') {
return;
}
if (!String(binding.toolName || '').trim()) {
issues.push({ nodeId, field: 'toolName', message: '请填写工具名称' });
} else if (!isSafeToolName(binding.toolName)) {
@@ -340,13 +360,17 @@ export function useAgentDesignerState() {
}
function buildToolPayload(agentId?: number | string) {
return state.toolBindings.map((binding, index) => ({
...binding,
agentId,
enabled: binding.enabled !== false,
hitlEnabled: Boolean(binding.hitlEnabled),
sortNo: index + 1,
}));
return state.toolBindings.map((binding, index) => {
const isMcp = String(binding.toolType || '').toUpperCase() === 'MCP';
return {
...binding,
agentId,
enabled: binding.enabled !== false,
hitlEnabled: Boolean(binding.hitlEnabled),
toolName: isMcp ? '' : binding.toolName,
sortNo: index + 1,
};
});
}
reset();

View File

@@ -2,6 +2,7 @@ import type {
ChatTimelineItem,
ChatTimelineKnowledgeHit,
ChatTimelineMessageItem,
ChatTimelineToolStatus,
} from '@easyflow/common-ui';
import {ChatTimelineBuilder} from '@easyflow/common-ui';
@@ -503,18 +504,25 @@ function projectEventToTimeline(
const displayToolName = asText(
payload.toolDisplayName ?? rawToolName ?? '工具',
);
const asyncTool = payload.asyncTool === true;
ChatTimelineBuilder.upsertToolCall(items, {
input: payload.input ?? payload.toolInput,
output: payload.output ?? payload.result ?? payload.text,
status: type === 'TOOL_RESULT' ? 'success' : 'running',
output: asyncTool
? payload.summary ?? payload.label ?? payload.output ?? payload.result ?? payload.text
: payload.output ?? payload.result ?? payload.text,
status: asyncTool
? asyncToolTimelineStatus(payload)
: type === 'TOOL_RESULT'
? 'success'
: 'running',
statusKey: statusKeyForProjection(
payload,
roundId,
variantIndex,
'knowledge-retrieval',
),
toolCallId: asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id),
toolName: isHiddenToolName(rawToolName) ? rawToolName : displayToolName,
toolCallId: asText(payload.toolCallId ?? payload.taskId ?? payload.tool_call_id ?? payload.id),
toolName: asyncTool ? displayToolName : isHiddenToolName(rawToolName) ? rawToolName : displayToolName,
});
return;
}
@@ -560,6 +568,15 @@ function projectEventToTimeline(
}
}
function asyncToolTimelineStatus(payload: Record<string, unknown>): ChatTimelineToolStatus {
const status = asText(payload.status).toUpperCase();
if (status === 'SUCCEEDED') return 'success';
if (status === 'FAILED' || status === 'TIMEOUT' || status === 'CANCELLED') {
return 'error';
}
return 'running';
}
function sortedRounds(rounds: Map<string, AgentTryoutRawRound>) {
return [...rounds.values()].sort(
(first, second) =>

View File

@@ -1,7 +1,7 @@
/* cspell:ignore hitl */
export type AgentPanelMode = 'base' | 'capability' | 'tryout';
export type AgentCapabilityKind = 'knowledge' | 'plugin' | 'workflow';
export type AgentCapabilityKind = 'knowledge' | 'plugin' | 'workflow' | 'mcp';
export interface AgentInfo {
id?: number | string;
@@ -33,7 +33,7 @@ export interface AgentInfo {
export interface AgentToolBinding {
id?: number | string;
agentId?: number | string;
toolType: 'PLUGIN' | 'WORKFLOW' | string;
toolType: 'MCP' | 'PLUGIN' | 'WORKFLOW' | string;
targetId?: number | string;
toolName?: string;
enabled?: boolean;

View File

@@ -5,7 +5,9 @@ import { markRaw, ref } from 'vue';
import { Delete, MoreFilled, Plus, Refresh } from '@element-plus/icons-vue';
import {
ElAlert,
ElButton,
ElDialog,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
@@ -14,6 +16,7 @@ import {
ElSwitch,
ElTable,
ElTableColumn,
ElTag,
ElTooltip,
} from 'element-plus';
@@ -28,6 +31,26 @@ import McpModal from './McpModal.vue';
const formRef = ref<FormInstance>();
const pageDataRef = ref();
const saveDialog = ref();
interface McpCheckItem {
name: string;
status: 'FAILED' | 'SUCCESS' | 'WARNING';
message: string;
detail?: string;
}
interface McpServerCheckResult {
serverName: string;
transport: string;
status: 'FAILED' | 'SUCCESS' | 'WARNING';
toolCount: number;
checks: McpCheckItem[];
}
interface McpEnvironmentCheckResult {
overallStatus: 'FAILED' | 'SUCCESS' | 'WARNING';
servers: McpServerCheckResult[];
}
function reset(formEl: FormInstance | undefined) {
formEl?.resetFields();
pageDataRef.value.setQuery({});
@@ -103,11 +126,110 @@ const handleHeaderButtonClick = (button: any) => {
};
const loadingMap = ref<Record<number | string, boolean>>({});
const refreshLoadingMap = ref<Record<number | string, boolean>>({});
const checkLoadingMap = ref<Record<number | string, boolean>>({});
const checkDialogVisible = ref(false);
const checkResult = ref<McpEnvironmentCheckResult>();
const checkTagType = (status?: string) => {
if (status === 'SUCCESS') {
return 'success';
}
if (status === 'WARNING') {
return 'warning';
}
return 'danger';
};
const checkStatusLabel = (status?: string) => {
if (status === 'SUCCESS') {
return $t('mcp.check.status.success');
}
if (status === 'WARNING') {
return $t('mcp.check.status.warning');
}
return $t('mcp.check.status.failed');
};
const handleCheck = (row: any) => {
checkLoadingMap.value[row.id] = true;
api
.post('/api/v1/mcp/check', { configJson: row.configJson })
.then((res) => {
if (res.errorCode === 0) {
checkResult.value = res.data;
checkDialogVisible.value = true;
}
})
.finally(() => {
checkLoadingMap.value[row.id] = false;
});
};
</script>
<template>
<div class="flex h-full flex-col gap-6 p-6">
<McpModal ref="saveDialog" @reload="reset" />
<ElDialog
v-model="checkDialogVisible"
:title="$t('mcp.check.resultTitle')"
width="720px"
>
<div v-if="checkResult" class="mcp-check-result">
<ElAlert
:closable="false"
:type="
checkResult.overallStatus === 'SUCCESS'
? 'success'
: checkResult.overallStatus === 'WARNING'
? 'warning'
: 'error'
"
:title="`${$t('mcp.check.overall')}: ${checkStatusLabel(
checkResult.overallStatus,
)}`"
show-icon
/>
<div
v-for="server in checkResult.servers"
:key="server.serverName"
class="mcp-check-server"
>
<div class="mcp-check-server__head">
<div>
<div class="mcp-check-server__title">
{{ server.serverName }}
</div>
<div class="mcp-check-server__meta">
{{ server.transport || '-' }} · {{ $t('mcp.check.toolCount') }}
{{ server.toolCount || 0 }}
</div>
</div>
<ElTag :type="checkTagType(server.status)" size="small">
{{ checkStatusLabel(server.status) }}
</ElTag>
</div>
<div class="mcp-check-list">
<div
v-for="(item, index) in server.checks"
:key="`${item.name}-${index}`"
class="mcp-check-item"
>
<ElTag :type="checkTagType(item.status)" size="small">
{{ checkStatusLabel(item.status) }}
</ElTag>
<div class="mcp-check-item__content">
<div class="mcp-check-item__message">
{{ item.message }}
</div>
<div v-if="item.detail" class="mcp-check-item__detail">
{{ item.detail }}
</div>
</div>
</div>
</div>
</div>
</div>
</ElDialog>
<ListPageShell>
<template #filters>
<HeaderSearch
@@ -185,6 +307,17 @@ const refreshLoadingMap = ref<Record<number | string, boolean>>({});
<ElButton link :icon="MoreFilled" />
<template #dropdown>
<ElDropdownMenu>
<div v-access:code="'/api/v1/mcp/check'">
<ElDropdownItem @click="handleCheck(row)">
<ElButton
type="primary"
link
:loading="checkLoadingMap[row.id]"
>
{{ $t('mcp.check.action') }}
</ElButton>
</ElDropdownItem>
</div>
<div v-access:code="'/api/v1/mcp/remove'">
<ElDropdownItem @click="remove(row)">
<ElButton type="danger" :icon="Delete" link>
@@ -205,4 +338,59 @@ const refreshLoadingMap = ref<Record<number | string, boolean>>({});
</div>
</template>
<style scoped></style>
<style scoped>
.mcp-check-result {
display: flex;
flex-direction: column;
gap: 12px;
}
.mcp-check-server {
padding: 12px;
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
}
.mcp-check-server__head {
display: flex;
gap: 16px;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 12px;
}
.mcp-check-server__title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.mcp-check-server__meta,
.mcp-check-item__detail {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.mcp-check-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.mcp-check-item {
display: flex;
gap: 8px;
align-items: flex-start;
}
.mcp-check-item__content {
min-width: 0;
}
.mcp-check-item__message {
font-size: 12px;
line-height: 20px;
color: var(--el-text-color-primary);
}
</style>

View File

@@ -1,11 +1,16 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { onMounted, ref } from 'vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { EasyFlowPanelModal } from '@easyflow/common-ui';
import { MagicStick } from '@element-plus/icons-vue';
import hljs from 'highlight.js/lib/core';
import jsonLanguage from 'highlight.js/lib/languages/json';
import {
ElAlert,
ElButton,
ElForm,
ElFormItem,
ElInput,
@@ -15,11 +20,14 @@ import {
ElTableColumn,
ElTabPane,
ElTabs,
ElTag,
} from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
hljs.registerLanguage('json', jsonLanguage);
interface PropValue {
type?: string;
description?: string;
@@ -42,9 +50,30 @@ interface McpEntity {
configJson: string;
deptId: string;
status: boolean;
approvalRequired: boolean;
tools: McpTool[];
}
interface McpCheckItem {
name: string;
status: 'FAILED' | 'SUCCESS' | 'WARNING';
message: string;
detail?: string;
}
interface McpServerCheckResult {
serverName: string;
transport: string;
status: 'FAILED' | 'SUCCESS' | 'WARNING';
toolCount: number;
checks: McpCheckItem[];
}
interface McpEnvironmentCheckResult {
overallStatus: 'FAILED' | 'SUCCESS' | 'WARNING';
servers: McpServerCheckResult[];
}
const emit = defineEmits(['reload']);
onMounted(() => {});
@@ -56,6 +85,11 @@ const saveForm = ref<FormInstance>();
const dialogVisible = ref(false);
const isAdd = ref(true);
const btnLoading = ref(false);
const checkLoading = ref(false);
const checkResult = ref<McpEnvironmentCheckResult>();
const jsonEditorTextarea = ref<HTMLTextAreaElement>();
const jsonEditorHighlight = ref<HTMLElement>();
const jsonError = ref('');
const defaultEntity: McpEntity = {
title: '',
@@ -63,6 +97,7 @@ const defaultEntity: McpEntity = {
configJson: '',
deptId: '',
status: false,
approvalRequired: false,
tools: [],
};
const entity = ref<McpEntity>({ ...defaultEntity });
@@ -84,13 +119,30 @@ const rules = ref({
],
});
const highlightedConfigJson = computed(() => {
const value = entity.value.configJson || '';
return hljs.highlight(value || ' ', { language: 'json' }).value;
});
watch(
() => entity.value.configJson,
() => {
validateConfigJson(false);
},
);
function openDialog(row: Partial<McpEntity> = {}) {
isAdd.value = !row.id;
entity.value = { ...defaultEntity, ...row };
checkResult.value = undefined;
if (!isAdd.value) {
getMcpTools(row);
}
dialogVisible.value = true;
nextTick(() => {
syncJsonEditorScroll();
validateConfigJson(false);
});
}
function getMcpTools(row: Partial<McpEntity>) {
@@ -101,6 +153,9 @@ function getMcpTools(row: Partial<McpEntity>) {
});
}
function save() {
if (!validateConfigJson(true)) {
return;
}
saveForm.value?.validate((valid) => {
if (valid) {
btnLoading.value = true;
@@ -128,12 +183,96 @@ function save() {
});
}
function checkMcpConfig() {
if (!entity.value.configJson) {
ElMessage.warning($t('mcp.check.message.configRequired'));
return;
}
if (!validateConfigJson(true)) {
return;
}
checkLoading.value = true;
api
.post('api/v1/mcp/check', { configJson: entity.value.configJson })
.then((res) => {
if (res.errorCode === 0) {
checkResult.value = res.data;
}
})
.finally(() => {
checkLoading.value = false;
});
}
function closeDialog() {
saveForm.value?.resetFields();
isAdd.value = true;
entity.value = { ...defaultEntity };
checkResult.value = undefined;
jsonError.value = '';
dialogVisible.value = false;
}
function formatConfigJson() {
if (!validateConfigJson(true)) {
return;
}
entity.value.configJson = JSON.stringify(
JSON.parse(entity.value.configJson),
null,
2,
);
nextTick(() => {
syncJsonEditorScroll();
});
}
function validateConfigJson(showMessage: boolean) {
if (!entity.value.configJson) {
jsonError.value = '';
return true;
}
try {
JSON.parse(entity.value.configJson);
jsonError.value = '';
return true;
} catch (error) {
jsonError.value =
error instanceof Error ? error.message : $t('mcp.jsonEditor.invalid');
if (showMessage) {
ElMessage.warning($t('mcp.jsonEditor.invalid'));
}
return false;
}
}
function syncJsonEditorScroll() {
if (!jsonEditorTextarea.value || !jsonEditorHighlight.value) {
return;
}
jsonEditorHighlight.value.scrollTop = jsonEditorTextarea.value.scrollTop;
jsonEditorHighlight.value.scrollLeft = jsonEditorTextarea.value.scrollLeft;
}
const checkTagType = (status?: string) => {
if (status === 'SUCCESS') {
return 'success';
}
if (status === 'WARNING') {
return 'warning';
}
return 'danger';
};
const checkStatusLabel = (status?: string) => {
if (status === 'SUCCESS') {
return $t('mcp.check.status.success');
}
if (status === 'WARNING') {
return $t('mcp.check.status.warning');
}
return $t('mcp.check.status.failed');
};
const jsonPlaceholder = ref(`{
"mcpServers": {
"12306-mcp": {
@@ -176,13 +315,113 @@ const activeName = ref('config');
<ElInput v-model.trim="entity.description" />
</ElFormItem>
<ElFormItem prop="configJson" :label="$t('mcp.configJson')">
<ElInput
type="textarea"
:rows="15"
v-model.trim="entity.configJson"
:placeholder="$t('mcp.example') + jsonPlaceholder"
/>
<div
class="mcp-json-editor"
:class="{ 'mcp-json-editor--error': jsonError }"
>
<div class="mcp-json-editor__toolbar">
<ElButton
:aria-label="$t('mcp.jsonEditor.format')"
:icon="MagicStick"
circle
size="small"
text
:title="$t('mcp.jsonEditor.format')"
@click="formatConfigJson"
/>
</div>
<pre
ref="jsonEditorHighlight"
class="mcp-json-editor__highlight"
aria-hidden="true"
><code v-html="highlightedConfigJson"></code></pre>
<textarea
ref="jsonEditorTextarea"
v-model="entity.configJson"
class="mcp-json-editor__textarea"
spellcheck="false"
:placeholder="$t('mcp.example') + jsonPlaceholder"
:aria-invalid="Boolean(jsonError)"
:aria-label="$t('mcp.configJson')"
@scroll="syncJsonEditorScroll"
></textarea>
</div>
<div v-if="jsonError" class="mcp-json-editor__error">
{{ jsonError }}
</div>
</ElFormItem>
<div class="mcp-check-actions">
<ElButton
type="primary"
plain
:loading="checkLoading"
:disabled="checkLoading"
@click="checkMcpConfig"
>
{{ $t('mcp.check.action') }}
</ElButton>
</div>
<ElFormItem
prop="approvalRequired"
:label="$t('mcp.approvalRequired')"
>
<ElSwitch v-model="entity.approvalRequired" />
</ElFormItem>
<div v-if="checkResult" class="mcp-check-result">
<ElAlert
:closable="false"
:type="
checkResult.overallStatus === 'SUCCESS'
? 'success'
: checkResult.overallStatus === 'WARNING'
? 'warning'
: 'error'
"
:title="`${$t('mcp.check.overall')}: ${checkStatusLabel(
checkResult.overallStatus,
)}`"
show-icon
/>
<div
v-for="server in checkResult.servers"
:key="server.serverName"
class="mcp-check-server"
>
<div class="mcp-check-server__head">
<div>
<div class="mcp-check-server__title">
{{ server.serverName }}
</div>
<div class="mcp-check-server__meta">
{{ server.transport || '-' }} ·
{{ $t('mcp.check.toolCount') }} {{ server.toolCount || 0 }}
</div>
</div>
<ElTag :type="checkTagType(server.status)" size="small">
{{ checkStatusLabel(server.status) }}
</ElTag>
</div>
<div class="mcp-check-list">
<div
v-for="(item, index) in server.checks"
:key="`${item.name}-${index}`"
class="mcp-check-item"
>
<ElTag :type="checkTagType(item.status)" size="small">
{{ checkStatusLabel(item.status) }}
</ElTag>
<div class="mcp-check-item__content">
<div class="mcp-check-item__message">
{{ item.message }}
</div>
<div v-if="item.detail" class="mcp-check-item__detail">
{{ item.detail }}
</div>
</div>
</div>
</div>
</div>
</div>
<ElFormItem prop="status" :label="$t('mcp.status')">
<ElSwitch v-model="entity.status" />
</ElFormItem>
@@ -319,14 +558,195 @@ const activeName = ref('config');
.params-left-title-container {
display: flex;
flex: 1;
flex-direction: row;
gap: 8px;
align-items: center;
padding: 8px;
background-color: #fafafa;
border: 1px solid #e6e9ee;
min-width: 120px;
}
.mcp-json-editor {
position: relative;
width: 100%;
min-height: 320px;
overflow: hidden;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 13px;
line-height: 20px;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color);
border-radius: 8px;
transition:
border-color var(--el-transition-duration),
box-shadow var(--el-transition-duration);
}
.mcp-json-editor:focus-within {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px var(--el-color-primary-light-7);
}
.mcp-json-editor--error {
border-color: var(--el-color-danger);
}
.mcp-json-editor--error:focus-within {
border-color: var(--el-color-danger);
box-shadow: 0 0 0 1px var(--el-color-danger-light-7);
}
.mcp-json-editor__toolbar {
position: absolute;
top: 6px;
right: 6px;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
}
.mcp-json-editor__toolbar :deep(.el-button) {
width: 24px;
height: 24px;
color: var(--el-text-color-secondary);
}
.mcp-json-editor__toolbar :deep(.el-button:hover),
.mcp-json-editor__toolbar :deep(.el-button:focus-visible) {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.mcp-json-editor__highlight,
.mcp-json-editor__textarea {
position: absolute;
inset: 0;
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 14px 48px 12px 12px;
margin: 0;
overflow: auto;
font: inherit;
line-height: inherit;
tab-size: 2;
white-space: pre;
border: 0;
}
.mcp-json-editor__highlight {
z-index: 1;
color: var(--el-text-color-primary);
pointer-events: none;
background: transparent;
}
.mcp-json-editor__textarea {
z-index: 2;
color: transparent;
caret-color: var(--el-text-color-primary);
resize: none;
outline: none;
background: transparent;
}
.mcp-json-editor__textarea::placeholder {
color: var(--el-text-color-placeholder);
}
.mcp-json-editor__textarea::selection {
color: transparent;
background: var(--el-color-primary-light-8);
}
.mcp-json-editor__error {
margin-top: 8px;
font-size: 12px;
line-height: 18px;
color: var(--el-color-danger);
}
.mcp-json-editor :deep(.hljs-attr) {
color: var(--el-color-primary);
}
.mcp-json-editor :deep(.hljs-string) {
color: var(--el-color-success);
}
.mcp-json-editor :deep(.hljs-number),
.mcp-json-editor :deep(.hljs-literal) {
color: var(--el-color-warning);
}
.mcp-json-editor :deep(.hljs-punctuation) {
color: var(--el-text-color-secondary);
}
.mcp-check-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
}
.mcp-check-result {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.mcp-check-server {
padding: 12px;
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
}
.mcp-check-server__head {
display: flex;
gap: 16px;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 12px;
}
.mcp-check-server__title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.mcp-check-server__meta,
.mcp-check-item__detail {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.mcp-check-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.mcp-check-item {
display: flex;
gap: 8px;
align-items: flex-start;
}
.mcp-check-item__content {
min-width: 0;
}
.mcp-check-item__message {
font-size: 12px;
line-height: 20px;
color: var(--el-text-color-primary);
}
.required-mark {

View File

@@ -8,7 +8,7 @@
"scripts": {
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 turbo build",
"build:analyze": "turbo build:analyze",
"build:docker": "./scripts/deploy/build-local-docker-image.sh",
"build:docker": "docker buildx build --platform linux/amd64 -f scripts/deploy/Dockerfile -t easyflow-frontend:0.1 --load .",
"build:app": "pnpm run build --filter=@easyflow/app",
"changeset": "pnpm exec changeset",
"check": "pnpm run check:circular && pnpm run check:dep && pnpm run check:type && pnpm check:cspell",

View File

@@ -1,38 +1,29 @@
FROM node:22-slim AS builder
# 前端构建脚本
FROM --platform=$BUILDPLATFORM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/node:24-slim AS builder
# --max-old-space-size
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV NODE_OPTIONS=--max-old-space-size=8192
ENV TZ=Asia/Shanghai
RUN npm i -g corepack
RUN npm install -g pnpm@10.17.1
WORKDIR /app
# copy package.json and pnpm-lock.yaml to workspace
COPY . /app
# 安装依赖
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN --mount=type=cache,id=easyflow-admin-pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build
RUN echo "Builder Success 🎉"
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/nginx:stable-alpine AS production
FROM nginx:stable-alpine AS production
RUN rm -f /etc/nginx/conf.d/default.conf
# 配置 nginx
RUN echo "types { application/javascript js mjs; }" > /etc/nginx/conf.d/mjs.conf \
&& rm -rf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/app/dist/ /usr/share/nginx/html/flow/
COPY scripts/deploy/nginx.conf /etc/nginx/nginx.conf
# 复制构建产物
COPY --from=builder /app/app/dist /usr/share/nginx/html
RUN chown -R nginx:nginx /usr/share/nginx/html
# 复制 nginx 配置
COPY --from=builder /app/scripts/deploy/nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
EXPOSE 8080
# 启动 nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,55 +0,0 @@
#!/bin/bash
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
LOG_FILE=${SCRIPT_DIR}/build-local-docker-image.log
ERROR=""
IMAGE_NAME="easyflow-admin-local"
function stop_and_remove_container() {
# Stop and remove the existing container
docker stop ${IMAGE_NAME} >/dev/null 2>&1
docker rm ${IMAGE_NAME} >/dev/null 2>&1
}
function remove_image() {
# Remove the existing image
docker rmi easyflow-admin-pro >/dev/null 2>&1
}
function install_dependencies() {
# Install all dependencies
cd ${SCRIPT_DIR}
pnpm install || ERROR="install_dependencies failed"
}
function build_image() {
# build docker
docker build ../../ -f Dockerfile -t ${IMAGE_NAME} || ERROR="build_image failed"
}
function log_message() {
if [[ ${ERROR} != "" ]];
then
>&2 echo "build failed, Please check build-local-docker-image.log for more details"
>&2 echo "ERROR: ${ERROR}"
exit 1
else
echo "docker image with tag '${IMAGE_NAME}' built sussessfully. Use below sample command to run the container"
echo ""
echo "docker run -d -p 8010:8080 --name ${IMAGE_NAME} ${IMAGE_NAME}"
fi
}
echo "Info: Stopping and removing existing container and image" | tee ${LOG_FILE}
stop_and_remove_container
remove_image
echo "Info: Installing dependencies" | tee -a ${LOG_FILE}
install_dependencies 1>> ${LOG_FILE} 2>> ${LOG_FILE}
if [[ ${ERROR} == "" ]]; then
echo "Info: Building docker image" | tee -a ${LOG_FILE}
build_image 1>> ${LOG_FILE} 2>> ${LOG_FILE}
fi
log_message | tee -a ${LOG_FILE}

View File

@@ -1,123 +1,98 @@
#user nobody;
worker_processes 1;
#error_log logs/error.log;
#error_log logs/error.log notice;
#error_log logs/error.log info;
#pid logs/nginx.pid;
worker_processes auto;
events {
worker_connections 1024;
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
# tcp_nopush on;
sendfile on;
keepalive_timeout 65;
client_max_body_size 500m;
#keepalive_timeout 0;
# keepalive_timeout 65;
# gzip on;
# gzip_buffers 32 16k;
# gzip_comp_level 6;
# gzip_min_length 1k;
# gzip_static on;
# gzip_types text/plain
# text/css
# application/javascript
# application/json
# application/x-javascript
# text/xml
# application/xml
# application/xml+rss
# text/javascript; #设置压缩的文件类型
# gzip_vary on;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# 如需多机负载均衡,在此增加多个后端地址
upstream easyflow_api {
server easyflow-api:8080;
keepalive 64;
}
server {
listen 8080;
server_name localhost;
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
index index.html;
# Enable CORS
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# WebSocket语音链路
location /api/v1/bot/ws/audio {
proxy_pass http://easyflow_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
resolver 127.0.0.11 valid=30s ipv6=off;
# SSE/HTTP API
location /api/ {
proxy_pass http://easyflow_api;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
proxy_cache off;
}
server {
listen 80;
server_name _;
location /userCenter/ {
proxy_pass http://easyflow_api;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
proxy_cache off;
}
location = / {
return 302 /flow/;
}
error_page 500 502 503 504 /50x.html;
location = /flow {
return 301 /flow/;
}
location = /50x.html {
root /usr/share/nginx/html;
location = /flow/api {
return 301 /flow/api/;
}
location = /flow/storage {
return 301 /flow/storage/;
}
location = /flow/api/v1/bot/ws/audio {
set $easyflow_backend http://backend:8111;
rewrite ^/flow/(.*)$ /$1 break;
proxy_pass $easyflow_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Prefix /flow;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_redirect off;
}
location ^~ /flow/api/ {
set $easyflow_backend http://backend:8111;
rewrite ^/flow/api/(.*)$ /api/$1 break;
proxy_pass $easyflow_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Prefix /flow;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
proxy_cache off;
proxy_redirect off;
}
location ^~ /flow/storage/ {
set $easyflow_minio http://minio-shared:9000;
rewrite ^/flow/storage/(.*)$ /easyflow/$1 break;
proxy_pass $easyflow_minio;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
}
location ^~ /flow/ {
root /usr/share/nginx/html;
try_files $uri $uri/ /flow/index.html;
}
}
}
}

View File

@@ -15,14 +15,14 @@
</modules>
<properties>
<java.version>17</java.version>
<java.versionm>17</java.versionm>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven-flatten.version>1.3.0</maven-flatten.version>
<revision>0.0.1</revision>
<revision>1.0.0</revision>
<mybatis-flex.version>1.11.6</mybatis-flex.version>
<easy-agents.version>0.0.1</easy-agents.version>
<easy-agents.version>1.0.0</easy-agents.version>
<okhttp.version>4.9.3</okhttp.version>
<kotlin.version>1.8.22</kotlin.version>
<spring-boot.version>3.5.9</spring-boot.version>
@@ -48,6 +48,8 @@
<minio.version>8.5.2</minio.version>
<jackson.version>2.19.4</jackson.version>
<netty.version>4.1.130.Final</netty.version>
<proguard.version>7.9.1</proguard.version>
<proguard.maven.plugin.version>2.7.0</proguard.maven.plugin.version>
</properties>
<dependencyManagement>
<dependencies>