chore: 项目环境升级为 JDK25,Spring 4.1,项目重构为多模块

This commit is contained in:
2026-09-08 15:02:18 +08:00
parent d392436620
commit 81a0d81f11
168 changed files with 2785 additions and 1375 deletions

View File

@@ -1,4 +1,4 @@
target
**/target
.idea
*.iml
*.log

View File

@@ -1,18 +1,26 @@
# syntax=docker/dockerfile:1
# Maven 构建阶段使用与项目一致的 JDK 21,并利用 BuildKit 缓存减少重复下载依赖的时间。
FROM maven:3.9.13-eclipse-temurin-21 AS builder
# Maven 构建阶段使用与项目一致的 JDK 25,并利用 BuildKit 缓存减少重复下载依赖的时间。
FROM maven:3.9.13-eclipse-temurin-25@sha256:ade3c87e3cdfbe04932afa16b31814cbf60b0122d21d78a76530684a1eeb7cc2 AS builder
WORKDIR /workspace
COPY pom.xml ./
COPY src ./src
COPY manuagent-common/pom.xml manuagent-common/pom.xml
COPY manuagent-admin/pom.xml manuagent-admin/pom.xml
COPY manuagent-agent/pom.xml manuagent-agent/pom.xml
COPY manuagent-web/pom.xml manuagent-web/pom.xml
COPY src/main/resources/db ./src/main/resources/db
COPY manuagent-common/src ./manuagent-common/src
COPY manuagent-admin/src ./manuagent-admin/src
COPY manuagent-agent/src ./manuagent-agent/src
COPY manuagent-web/src ./manuagent-web/src
# 直接打包只解析项目真正需要的依赖;独立 go-offline 会额外下载大量未参与构建的报告插件。
RUN --mount=type=cache,target=/root/.m2 mvn -B -DskipTests package
# AgentScope DockerSandbox 通过 docker 命令创建 Runtime直接复用官方镜像中的 CLI 二进制。
FROM docker:29-cli AS docker-cli
FROM eclipse-temurin:21-jre-jammy
FROM eclipse-temurin:25.0.2_10-jre-jammy
# curl 用于容器健康检查gosu 用于完成目录和 Docker Socket 权限初始化后降权运行 Java。
RUN apt-get update \
@@ -27,7 +35,7 @@ RUN groupadd --gid 10001 manuagent \
&& chown -R manuagent:manuagent /opt/manuagent /srv/manuagent
WORKDIR /opt/manuagent
COPY --from=builder /workspace/target/*.jar app.jar
COPY --from=builder /workspace/manuagent-web/target/manuagent-web.jar app.jar
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod 0755 /usr/local/bin/docker-entrypoint.sh

View File

@@ -1,7 +1,7 @@
#!/bin/sh
set -eu
# Agent Runtime 的目录通过宿主机 Docker daemon 再次挂载,因此后端必须能写入共享数据根目录。
# 沙箱的目录通过宿主机 Docker daemon 再次挂载,因此后端必须能写入共享数据根目录。
data_root="${APP_DATA_ROOT:-/srv/manuagent/data}"
mkdir -p "$data_root"
chown manuagent:manuagent "$data_root"
@@ -10,7 +10,7 @@ chown manuagent:manuagent "$data_root"
# 启动时读取真实组 ID 并把低权限应用用户加入对应组,避免以 root 身份运行 Spring Boot。
docker_socket="/var/run/docker.sock"
if [ ! -S "$docker_socket" ]; then
echo "错误:未挂载 $docker_socket,后端无法创建 Agent Runtime 容器。" >&2
echo "错误:未挂载 $docker_socket,后端无法创建沙箱容器。" >&2
exit 1
fi

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>tech.easyflow</groupId>
<artifactId>manuagent-parent</artifactId>
<version>0.1.0</version>
</parent>
<artifactId>manuagent-admin</artifactId>
<dependencies>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>manuagent-common</artifactId>
</dependency>
<dependency>
<groupId>com.mybatis-flex</groupId>
<artifactId>mybatis-flex-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.entity;
package tech.easyflow.manuagent.admin.auth;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
@@ -7,7 +7,7 @@ import com.mybatisflex.annotation.Column;
import com.mybatisflex.core.keygen.KeyGenerators;
import java.time.OffsetDateTime;
import java.util.UUID;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler;
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/**
* 映射 {@code app.app_user} 表的管理员用户实体

View File

@@ -1,7 +1,7 @@
package tech.easyflow.manuagent.mapper;
package tech.easyflow.manuagent.admin.auth;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.manuagent.entity.AppUserEntity;
import tech.easyflow.manuagent.admin.auth.AppUserEntity;
/**
* 提供 {@code app.app_user} 表的 MyBatis-Flex 基础数据访问能力
@@ -9,5 +9,6 @@ import tech.easyflow.manuagent.entity.AppUserEntity;
* <p>用户表只有简单单表操作因此直接使用 {@link BaseMapper} Lambda QueryWrapper
* 不额外维护 Mapper XML</p>
*/
@org.apache.ibatis.annotations.Mapper
public interface AppUserMapper extends BaseMapper<AppUserEntity> {
}

View File

@@ -1,10 +1,7 @@
package tech.easyflow.manuagent.auth;
package tech.easyflow.manuagent.admin.auth;
import com.mybatisflex.core.query.QueryWrapper;
import java.util.UUID;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
@@ -13,20 +10,19 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties;
import tech.easyflow.manuagent.entity.AppUserEntity;
import tech.easyflow.manuagent.mapper.AppUserMapper;
import tech.easyflow.manuagent.admin.config.AdminProperties;
import tech.easyflow.manuagent.admin.auth.AppUserEntity;
import tech.easyflow.manuagent.admin.auth.AppUserMapper;
/**
* 管理单管理员账户和当前用户标识
*/
@Service
@Order(1)
public class UserService implements UserDetailsService, ApplicationRunner {
public class UserService implements UserDetailsService {
private final AppUserMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final AppProperties properties;
private final AdminProperties properties;
/**
* 创建用户服务
@@ -35,7 +31,7 @@ public class UserService implements UserDetailsService, ApplicationRunner {
* @param passwordEncoder 密码编码器
* @param properties 应用配置
*/
public UserService(AppUserMapper userMapper, PasswordEncoder passwordEncoder, AppProperties properties) {
public UserService(AppUserMapper userMapper, PasswordEncoder passwordEncoder, AdminProperties properties) {
this.userMapper = userMapper;
this.passwordEncoder = passwordEncoder;
this.properties = properties;
@@ -44,10 +40,8 @@ public class UserService implements UserDetailsService, ApplicationRunner {
/**
* 初始化本地管理员账户
*
* @param args 启动参数
*/
@Override
public void run(ApplicationArguments args) {
public void initializeAdministrator() {
long count = userMapper.selectCountByQuery(QueryWrapper.create());
if (count > 0) {
return;

View File

@@ -0,0 +1,8 @@
package tech.easyflow.manuagent.admin.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** 首次启动时的管理员账号配置。 */
@ConfigurationProperties(prefix = "app")
public record AdminProperties(String adminUsername, String adminPassword) {
}

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.auth;
package tech.easyflow.manuagent.admin.auth;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -21,10 +21,10 @@ import org.mockito.ArgumentCaptor;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties;
import tech.easyflow.manuagent.entity.AppUserEntity;
import tech.easyflow.manuagent.mapper.AppUserMapper;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler;
import tech.easyflow.manuagent.admin.config.AdminProperties;
import tech.easyflow.manuagent.admin.auth.AppUserEntity;
import tech.easyflow.manuagent.admin.auth.AppUserMapper;
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/**
* 验证 {@link UserService} 在迁移到 MyBatis-Flex 后保持原有管理员初始化与认证语义
@@ -33,7 +33,7 @@ class UserServiceTest {
private AppUserMapper mapper;
private PasswordEncoder passwordEncoder;
private AppProperties properties;
private AdminProperties properties;
private UserService service;
/**
@@ -52,7 +52,7 @@ class UserServiceTest {
mapper = mock(AppUserMapper.class);
passwordEncoder = mock(PasswordEncoder.class);
properties = mock(AppProperties.class);
properties = mock(AdminProperties.class);
service = new UserService(mapper, passwordEncoder, properties);
}
@@ -66,7 +66,7 @@ class UserServiceTest {
when(properties.adminPassword()).thenReturn("plain-password");
when(passwordEncoder.encode("plain-password")).thenReturn("encoded-password");
service.run(null);
service.initializeAdministrator();
ArgumentCaptor<AppUserEntity> captor = ArgumentCaptor.forClass(AppUserEntity.class);
verify(mapper).insertSelective(captor.capture());
@@ -84,7 +84,7 @@ class UserServiceTest {
void shouldNotCreateAdministratorWhenAnyUserExists() {
when(mapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(1L);
service.run(null);
service.initializeAdministrator();
verify(mapper, never()).insertSelective(any(AppUserEntity.class));
}

View File

@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>tech.easyflow</groupId>
<artifactId>manuagent-parent</artifactId>
<version>0.1.0</version>
</parent>
<artifactId>manuagent-agent</artifactId>
<dependencies>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>manuagent-common</artifactId>
</dependency>
<dependency>
<groupId>com.mybatis-flex</groupId>
<artifactId>mybatis-flex-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
</dependency>
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-harness</artifactId>
</dependency>
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-extensions-model-openai</artifactId>
</dependency>
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-extensions-agui</artifactId>
</dependency>
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-extensions-skill-postgresql-repository</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.entity;
package tech.easyflow.manuagent.agent.artifact;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
@@ -7,8 +7,8 @@ import com.mybatisflex.annotation.Table;
import com.mybatisflex.core.keygen.KeyGenerators;
import java.time.OffsetDateTime;
import java.util.UUID;
import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler;
import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler;
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/**
* 映射 {@code app.artifact} 表的最终产物实体

View File

@@ -1,12 +1,13 @@
package tech.easyflow.manuagent.mapper;
package tech.easyflow.manuagent.agent.artifact;
import com.mybatisflex.core.BaseMapper;
import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.ArtifactEntity;
import tech.easyflow.manuagent.agent.artifact.ArtifactEntity;
/**
* 提供产物基础查询以及 PostgreSQL 原子 upsert 能力
*/
@org.apache.ibatis.annotations.Mapper
public interface ArtifactMapper extends BaseMapper<ArtifactEntity> {
/**

View File

@@ -1,11 +1,11 @@
package tech.easyflow.manuagent.artifact;
package tech.easyflow.manuagent.agent.artifact;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.entity.ArtifactEntity;
import tech.easyflow.manuagent.mapper.ArtifactMapper;
import tech.easyflow.manuagent.project.ProjectFileService;
import com.fasterxml.jackson.databind.JsonNode;
import tech.easyflow.manuagent.agent.artifact.ArtifactEntity;
import tech.easyflow.manuagent.agent.artifact.ArtifactMapper;
import tech.easyflow.manuagent.agent.project.ProjectFileService;
import tools.jackson.databind.JsonNode;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
@@ -78,9 +78,9 @@ public class ArtifactService {
}
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_COPY_FAILED", "申报书发布失败");
}
com.fasterxml.jackson.databind.node.ObjectNode enriched = metadata.isObject()
? ((com.fasterxml.jackson.databind.node.ObjectNode) metadata).deepCopy()
: com.fasterxml.jackson.databind.node.JsonNodeFactory.instance.objectNode();
tools.jackson.databind.node.ObjectNode enriched = metadata.isObject()
? ((tools.jackson.databind.node.ObjectNode) metadata).deepCopy()
: tools.jackson.databind.node.JsonNodeFactory.instance.objectNode();
enriched.put("docxValidated", true);
enriched.put("docxEntries", validation.entryCount());
enriched.put("commentCount", validation.commentCount());

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.artifact;
package tech.easyflow.manuagent.agent.artifact;
import tech.easyflow.manuagent.common.ApiException;
import java.io.IOException;

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.config;
package tech.easyflow.manuagent.agent.config;
import java.nio.file.Path;
import java.time.Duration;
@@ -10,19 +10,15 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* @param dataRoot 项目材料工作区和产物根目录
* @param dashscopeKeyFile 百炼 Key 文件
* @param masterKey 模型密钥加密主密钥
* @param adminUsername 本地管理员用户名
* @param adminPassword 本地管理员初始密码
* @param sandboxImage Agent Docker 运行镜像
* @param sandboxNetwork Agent Docker 网络
* @param runTimeout 单次 Agent 运行超时
*/
@ConfigurationProperties(prefix = "app")
public record AppProperties(
public record AgentProperties(
Path dataRoot,
Path dashscopeKeyFile,
String masterKey,
String adminUsername,
String adminPassword,
String sandboxImage,
String sandboxNetwork,
Duration runTimeout) {

View File

@@ -1,7 +1,7 @@
package tech.easyflow.manuagent.model;
package tech.easyflow.manuagent.agent.model;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties;
import tech.easyflow.manuagent.agent.config.AgentProperties;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
@@ -31,7 +31,7 @@ public class KeyCipher {
*
* @param properties 应用配置
*/
public KeyCipher(AppProperties properties) {
public KeyCipher(AgentProperties properties) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(properties.masterKey().getBytes(StandardCharsets.UTF_8));

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.entity;
package tech.easyflow.manuagent.agent.model;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
@@ -6,7 +6,7 @@ import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
import java.time.OffsetDateTime;
import java.util.UUID;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler;
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/**
* 映射 {@code app.model_assignment} 表的 Agent 角色模型分配实体

View File

@@ -1,12 +1,13 @@
package tech.easyflow.manuagent.mapper;
package tech.easyflow.manuagent.agent.model;
import com.mybatisflex.core.BaseMapper;
import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.ModelAssignmentEntity;
import tech.easyflow.manuagent.agent.model.ModelAssignmentEntity;
/**
* 提供 Agent 角色模型分配及 PostgreSQL 原子 upsert
*/
@org.apache.ibatis.annotations.Mapper
public interface ModelAssignmentMapper extends BaseMapper<ModelAssignmentEntity> {
/** 按角色插入或更新模型分配。 */

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.entity;
package tech.easyflow.manuagent.agent.model;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
@@ -8,8 +8,8 @@ import com.mybatisflex.core.keygen.KeyGenerators;
import java.time.OffsetDateTime;
import java.util.UUID;
import org.apache.ibatis.type.JdbcType;
import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler;
import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler;
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/**
* 映射 {@code app.model_config} 表的模型配置与加密密钥实体

View File

@@ -1,8 +1,8 @@
package tech.easyflow.manuagent.mapper;
package tech.easyflow.manuagent.agent.model;
import com.mybatisflex.core.BaseMapper;
import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.ModelConfigEntity;
import tech.easyflow.manuagent.agent.model.ModelConfigEntity;
/**
* 提供模型配置的 MyBatis-Flex CRUD 能力
@@ -10,6 +10,7 @@ import tech.easyflow.manuagent.entity.ModelConfigEntity;
* <p>普通查询和条件更新继续复用 {@link BaseMapper}包含 PostgreSQL JSONB 参数的新增更新
* 使用 XML 显式声明 TypeHandler避免写入行为依赖 MyBatis-Flex 全局表元数据的初始化顺序</p>
*/
@org.apache.ibatis.annotations.Mapper
public interface ModelConfigMapper extends BaseMapper<ModelConfigEntity> {
/**

View File

@@ -1,21 +1,19 @@
package tech.easyflow.manuagent.model;
package tech.easyflow.manuagent.agent.model;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.entity.ModelAssignmentEntity;
import tech.easyflow.manuagent.entity.ModelConfigEntity;
import tech.easyflow.manuagent.entity.AgentRunEntity;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import tech.easyflow.manuagent.agent.model.ModelAssignmentEntity;
import tech.easyflow.manuagent.agent.model.ModelConfigEntity;
import tech.easyflow.manuagent.agent.runtime.AgentRunEntity;
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
import tech.easyflow.manuagent.agent.model.ModelAssignmentMapper;
import tech.easyflow.manuagent.agent.model.ModelConfigMapper;
import tools.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.Principal;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.List;
@@ -38,7 +36,6 @@ public class ModelService {
private final ModelConfigMapper modelMapper;
private final ModelAssignmentMapper assignmentMapper;
private final AgentRunMapper runMapper;
private final UserService userService;
private final KeyCipher keyCipher;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
@@ -49,7 +46,6 @@ public class ModelService {
* @param modelMapper 模型配置 Mapper
* @param assignmentMapper 角色模型分配 Mapper
* @param runMapper Agent Run Mapper
* @param userService 用户服务
* @param keyCipher 密钥加密器
* @param objectMapper JSON 映射器
*/
@@ -58,14 +54,12 @@ public class ModelService {
ModelConfigMapper modelMapper,
ModelAssignmentMapper assignmentMapper,
AgentRunMapper runMapper,
UserService userService,
KeyCipher keyCipher,
ObjectMapper objectMapper) {
this(
modelMapper,
assignmentMapper,
runMapper,
userService,
keyCipher,
objectMapper,
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build());
@@ -77,7 +71,6 @@ public class ModelService {
* @param modelMapper 模型配置 Mapper
* @param assignmentMapper 角色模型分配 Mapper
* @param runMapper Agent Run Mapper
* @param userService 用户服务
* @param keyCipher 密钥加密器
* @param objectMapper JSON 映射器
* @param httpClient 模型连接使用的 HTTP 客户端
@@ -86,14 +79,12 @@ public class ModelService {
ModelConfigMapper modelMapper,
ModelAssignmentMapper assignmentMapper,
AgentRunMapper runMapper,
UserService userService,
KeyCipher keyCipher,
ObjectMapper objectMapper,
HttpClient httpClient) {
this.modelMapper = modelMapper;
this.assignmentMapper = assignmentMapper;
this.runMapper = runMapper;
this.userService = userService;
this.keyCipher = keyCipher;
this.objectMapper = objectMapper;
this.httpClient = httpClient;
@@ -118,12 +109,12 @@ public class ModelService {
*
* @param id 可选模型 ID
* @param input 模型输入
* @param principal 当前用户
* @param userId 当前操作者 ID
* @return 保存后的模型
*/
@Transactional
public ModelView save(UUID id, ModelInput input, Principal principal) {
UUID userId = userService.requireUserId(principal.getName());
public ModelView save(UUID id, ModelInput input, UUID userId) {
contextWindow(input.capabilities());
if (id == null) {
if (input.apiKey() == null || input.apiKey().isBlank()) {
@@ -165,15 +156,15 @@ public class ModelService {
* 将模型设置为所有角色默认模型
*
* @param id 模型 ID
* @param principal 当前用户
* @param userId 当前操作者 ID
*/
@Transactional
public void setDefault(UUID id, Principal principal) {
public void setDefault(UUID id, UUID userId) {
ModelView target = require(id);
if (!target.enabled()) {
throw new ApiException(HttpStatus.CONFLICT, "MODEL_DISABLED", "停用模型不能设为默认模型");
}
UUID userId = userService.requireUserId(principal.getName());
modelMapper.clearDefault();
if (modelMapper.setDefault(id) != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
@@ -387,7 +378,7 @@ public class ModelService {
private Map<String, Object> parseCapabilities(String json) {
try {
return objectMapper.readValue(json, Map.class);
} catch (IOException exception) {
} catch (tools.jackson.core.JacksonException exception) {
throw new ApiException(
HttpStatus.INTERNAL_SERVER_ERROR,
"MODEL_CAPABILITIES_INVALID",
@@ -528,7 +519,7 @@ public class ModelService {
private String json(Object value) {
try {
return objectMapper.writeValueAsString(value == null ? Map.of() : value);
} catch (IOException exception) {
} catch (tools.jackson.core.JacksonException exception) {
throw new ApiException(HttpStatus.BAD_REQUEST, "INVALID_MODEL_CONFIG", "模型配置无法序列化");
}
}

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.entity;
package tech.easyflow.manuagent.agent.project;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
@@ -7,7 +7,7 @@ import com.mybatisflex.annotation.Table;
import com.mybatisflex.core.keygen.KeyGenerators;
import java.time.OffsetDateTime;
import java.util.UUID;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler;
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/**
* 映射 {@code app.project} 表的企业申报项目实体

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.entity;
package tech.easyflow.manuagent.agent.project;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
@@ -7,7 +7,7 @@ import com.mybatisflex.annotation.Table;
import com.mybatisflex.core.keygen.KeyGenerators;
import java.time.OffsetDateTime;
import java.util.UUID;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler;
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/**
* 映射 {@code app.project_file} 表的项目材料实体

View File

@@ -1,10 +1,11 @@
package tech.easyflow.manuagent.mapper;
package tech.easyflow.manuagent.agent.project;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.manuagent.entity.ProjectFileEntity;
import tech.easyflow.manuagent.agent.project.ProjectFileEntity;
/**
* 提供 {@code app.project_file} 表的单表持久化能力
*/
@org.apache.ibatis.annotations.Mapper
public interface ProjectFileMapper extends BaseMapper<ProjectFileEntity> {
}

View File

@@ -1,11 +1,10 @@
package tech.easyflow.manuagent.project;
package tech.easyflow.manuagent.agent.project;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties;
import tech.easyflow.manuagent.entity.ProjectFileEntity;
import tech.easyflow.manuagent.mapper.ProjectFileMapper;
import tech.easyflow.manuagent.agent.config.AgentProperties;
import tech.easyflow.manuagent.agent.project.ProjectFileEntity;
import tech.easyflow.manuagent.agent.project.ProjectFileMapper;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
@@ -15,7 +14,6 @@ import java.nio.file.Path;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.Principal;
import java.time.OffsetDateTime;
import java.util.Comparator;
import java.util.HexFormat;
@@ -42,7 +40,6 @@ public class ProjectFileService {
"png", "jpg", "jpeg", "webp", "vsdx", "dwg");
private final ProjectFileMapper fileMapper;
private final UserService userService;
private final ProjectService projectService;
private final Path dataRoot;
private final Tika tika = new Tika();
@@ -51,17 +48,14 @@ public class ProjectFileService {
* 创建材料服务
*
* @param fileMapper 项目材料 Mapper
* @param userService 用户服务
* @param projectService 项目服务
* @param properties 应用配置
*/
public ProjectFileService(
ProjectFileMapper fileMapper,
UserService userService,
ProjectService projectService,
AppProperties properties) {
AgentProperties properties) {
this.fileMapper = fileMapper;
this.userService = userService;
this.projectService = projectService;
this.dataRoot = properties.dataRoot().toAbsolutePath().normalize();
}
@@ -110,11 +104,11 @@ public class ProjectFileService {
* @param projectId 项目 ID
* @param file 上传文件
* @param relativePath 浏览器提供的文件夹内相对路径单文件上传时可为空
* @param principal 当前用户
* @param userId 当前操作者 ID
* @return 文件元数据
*/
@Transactional
public FileView upload(UUID projectId, MultipartFile file, String relativePath, Principal principal) {
public FileView upload(UUID projectId, MultipartFile file, String relativePath, UUID userId) {
projectService.require(projectId);
String originalName = safeName(file.getOriginalFilename());
String extension = extension(originalName);
@@ -143,7 +137,7 @@ public class ProjectFileService {
}
Files.move(temporary, target);
moved = true;
UUID userId = userService.requireUserId(principal.getName());
// 数据库只保存受控路径和摘要selective insert 继续使用状态时间字段的数据库默认值
ProjectFileEntity entity = new ProjectFileEntity();
entity.setId(fileId);

View File

@@ -1,13 +1,14 @@
package tech.easyflow.manuagent.mapper;
package tech.easyflow.manuagent.agent.project;
import com.mybatisflex.core.BaseMapper;
import java.util.UUID;
import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.ProjectEntity;
import tech.easyflow.manuagent.agent.project.ProjectEntity;
/**
* 提供项目基础 CRUD阶段更新和项目级联清理所需的显式 SQL 接口
*/
@org.apache.ibatis.annotations.Mapper
public interface ProjectMapper extends BaseMapper<ProjectEntity> {
/** 判断项目是否仍有运行中的 Agent。 */

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.entity;
package tech.easyflow.manuagent.agent.project;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
@@ -8,8 +8,8 @@ import com.mybatisflex.core.keygen.KeyGenerators;
import java.time.OffsetDateTime;
import java.util.UUID;
import org.apache.ibatis.type.JdbcType;
import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler;
import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler;
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/**
* 映射 {@code app.project_plan} 表的不可变规划版本实体

View File

@@ -1,13 +1,14 @@
package tech.easyflow.manuagent.mapper;
package tech.easyflow.manuagent.agent.project;
import com.mybatisflex.core.BaseMapper;
import java.util.UUID;
import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.ProjectPlanEntity;
import tech.easyflow.manuagent.agent.project.ProjectPlanEntity;
/**
* 提供规划版本查询以及带条件的草稿写入确认能力
*/
@org.apache.ibatis.annotations.Mapper
public interface ProjectPlanMapper extends BaseMapper<ProjectPlanEntity> {
/** 插入项目下一版草稿并返回完整记录。 */

View File

@@ -1,16 +1,14 @@
package tech.easyflow.manuagent.project;
package tech.easyflow.manuagent.agent.project;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.entity.ProjectEntity;
import tech.easyflow.manuagent.entity.ProjectPlanEntity;
import tech.easyflow.manuagent.mapper.ProjectMapper;
import tech.easyflow.manuagent.mapper.ProjectPlanMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.security.Principal;
import tech.easyflow.manuagent.agent.project.ProjectEntity;
import tech.easyflow.manuagent.agent.project.ProjectPlanEntity;
import tech.easyflow.manuagent.agent.project.ProjectMapper;
import tech.easyflow.manuagent.agent.project.ProjectPlanMapper;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
@@ -26,7 +24,6 @@ public class ProjectService {
private final ProjectMapper projectMapper;
private final ProjectPlanMapper planMapper;
private final UserService userService;
private final ObjectMapper objectMapper;
/**
@@ -34,17 +31,14 @@ public class ProjectService {
*
* @param projectMapper 项目 Mapper
* @param planMapper 规划版本 Mapper
* @param userService 用户服务
* @param objectMapper JSON 映射器
*/
public ProjectService(
ProjectMapper projectMapper,
ProjectPlanMapper planMapper,
UserService userService,
ObjectMapper objectMapper) {
this.projectMapper = projectMapper;
this.planMapper = planMapper;
this.userService = userService;
this.objectMapper = objectMapper;
}
@@ -53,14 +47,14 @@ public class ProjectService {
*
* @param companyName 企业名称
* @param applicationLevel 申报等级
* @param principal 当前用户
* @param userId 当前操作者 ID
* @return 新项目
*/
@Transactional
public ProjectView create(String companyName, String applicationLevel, Principal principal) {
public ProjectView create(String companyName, String applicationLevel, UUID userId) {
String level = normalizeLevel(applicationLevel);
UUID id = UUID.randomUUID();
UUID userId = userService.requireUserId(principal.getName());
String threadId = "project-" + id;
ProjectEntity entity = new ProjectEntity();
entity.setId(id);
@@ -176,12 +170,12 @@ public class ProjectService {
* @param projectId 项目 ID
* @param planId 草稿规划 ID
* @param plan 用户确认后的完整规划
* @param principal 当前用户
* @param userId 当前操作者 ID
* @return 已确认规划
*/
@Transactional
public PlanView confirmPlan(UUID projectId, UUID planId, JsonNode plan, Principal principal) {
UUID userId = userService.requireUserId(principal.getName());
public PlanView confirmPlan(UUID projectId, UUID planId, JsonNode plan, UUID userId) {
ProjectPlanEntity confirmed = planMapper.confirmDraft(projectId, planId, plan.toString(), userId);
if (confirmed == null) {
throw new ApiException(HttpStatus.CONFLICT, "PLAN_ALREADY_CONFIRMED", "规划已确认或版本不存在");
@@ -201,7 +195,7 @@ public class ProjectService {
objectMapper.readTree(entity.getPlanJson()),
entity.getConfirmedAt(),
entity.getCreatedAt());
} catch (JsonProcessingException exception) {
} catch (JacksonException exception) {
// 迁移前 ResultSet 映射会将损坏的存量 JSON 作为未预期数据库读取异常处理不新增业务错误码
throw new IllegalStateException("规划 JSON 无法解析", exception);
}

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.entity;
package tech.easyflow.manuagent.agent.runtime;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
@@ -7,8 +7,8 @@ import com.mybatisflex.annotation.Table;
import java.time.OffsetDateTime;
import java.util.UUID;
import org.apache.ibatis.type.JdbcType;
import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler;
import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler;
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/**
* 映射 {@code app.agent_event} 表的持久化 Agent 事件

View File

@@ -1,13 +1,14 @@
package tech.easyflow.manuagent.mapper;
package tech.easyflow.manuagent.agent.runtime;
import com.mybatisflex.core.BaseMapper;
import java.util.UUID;
import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.AgentEventEntity;
import tech.easyflow.manuagent.agent.runtime.AgentEventEntity;
/**
* 提供 Agent 事件写入游标回放及运行恢复所需的持久化能力
*/
@org.apache.ibatis.annotations.Mapper
public interface AgentEventMapper extends BaseMapper<AgentEventEntity> {
/**

View File

@@ -1,8 +1,8 @@
package tech.easyflow.manuagent.agent;
package tech.easyflow.manuagent.agent.runtime;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
import com.mybatisflex.core.query.QueryWrapper;
import java.time.OffsetDateTime;
import java.util.List;
@@ -17,8 +17,8 @@ import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
import reactor.core.scheduler.Schedulers;
import tech.easyflow.manuagent.entity.AgentEventEntity;
import tech.easyflow.manuagent.mapper.AgentEventMapper;
import tech.easyflow.manuagent.agent.runtime.AgentEventEntity;
import tech.easyflow.manuagent.agent.runtime.AgentEventMapper;
/**
* 持久化并查询项目级 AG-UI 事件
@@ -175,7 +175,7 @@ public class AgentEventService {
entity.getEventType(),
objectMapper.readTree(entity.getPayloadJson()),
entity.getCreatedAt());
} catch (com.fasterxml.jackson.core.JsonProcessingException exception) {
} catch (tools.jackson.core.JacksonException exception) {
throw new IllegalStateException("Agent 事件 JSON 无法解析", exception);
}
}

View File

@@ -1,18 +1,22 @@
package tech.easyflow.manuagent.agent;
package tech.easyflow.manuagent.agent.runtime;
import tech.easyflow.manuagent.project.ProjectFileService;
import tech.easyflow.manuagent.project.ProjectService;
import tech.easyflow.manuagent.skill.SkillService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import tech.easyflow.manuagent.agent.project.ProjectFileService;
import tech.easyflow.manuagent.agent.project.ProjectService;
import tech.easyflow.manuagent.agent.skill.SkillService;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
import io.agentscope.core.agui.event.AguiEvent;
import io.agentscope.core.agui.model.AguiMessage;
import io.agentscope.core.agui.model.RunAgentInput;
import io.agentscope.core.model.ModelHttpException;
import io.agentscope.core.model.transport.HttpTransportException;
import java.time.Duration;
import java.time.Instant;
import tech.easyflow.manuagent.agent.config.AgentProperties;
import tech.easyflow.manuagent.common.ApiException;
import org.springframework.http.HttpStatus;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -36,6 +40,7 @@ public class AgentExecutionService {
private final AgentEventService eventService;
private final ProjectFileService fileService;
private final SkillService skillService;
private final Duration runTimeout;
/**
* 创建 Agent 执行服务
@@ -45,18 +50,24 @@ public class AgentExecutionService {
* @param eventService 事件服务
* @param fileService 工作区服务
* @param skillService Skill 服务
* @param properties Run 总时限配置
*/
public AgentExecutionService(
ObjectMapper objectMapper,
AgentFactory agentFactory,
AgentEventService eventService,
ProjectFileService fileService,
SkillService skillService) {
SkillService skillService,
AgentProperties properties) {
this.objectMapper = objectMapper;
this.agentFactory = agentFactory;
this.eventService = eventService;
this.fileService = fileService;
this.skillService = skillService;
this.runTimeout = properties.runTimeout();
if (runTimeout == null || runTimeout.isZero() || runTimeout.isNegative()) {
throw new IllegalArgumentException("app.run-timeout 必须大于 0");
}
}
/**
@@ -79,6 +90,7 @@ public class AgentExecutionService {
EventAccumulator accumulator = new EventAccumulator(project.id(), run.id());
for (int reconnects = 0; ; reconnects++) {
ensureRunning.run();
remainingTime(run);
String attemptPrompt = reconnects == 0 ? prompt : """
模型连接刚刚中断请恢复同一线程的会话状态读取 MEMORY.md 和工作区已有成果
检查未完成的输出后从中断处继续复用已经完成的工具结果不要重复已完成操作
@@ -90,13 +102,15 @@ public class AgentExecutionService {
.build();
try (AgentFactory.AgentHandle handle = agentFactory.create(
project.id(), run.modelConfigId(), skillService.enabledNames())) {
Mono<Void> deadline = Mono.delay(remainingTime(run)).then(Mono.error(timeoutFailure()));
handle.adapter().run(input)
.takeUntilOther(stopSignal)
.takeUntilOther(Mono.firstWithSignal(stopSignal, deadline))
.bufferTimeout(64, Duration.ofMillis(120))
.doOnNext(accumulator::accept)
.doOnNext(batch -> accumulator.accept(batch, handle.runtime()))
.blockLast();
accumulator.flush();
ensureRunning.run();
remainingTime(run);
return;
} catch (RuntimeException exception) {
accumulator.flush();
@@ -109,7 +123,7 @@ public class AgentExecutionService {
int attempt = reconnects + 1;
eventService.append(project.id(), run.id(), "MODEL_RETRY", Map.of(
"attempt", attempt, "maxAttempts", MAX_MODEL_RECONNECTS));
pauseBeforeReconnect(attempt, interrupted);
pauseBeforeReconnect(attempt, interrupted, remainingTime(run));
}
}
}
@@ -137,9 +151,19 @@ public class AgentExecutionService {
return false;
}
private void pauseBeforeReconnect(int attempt, BooleanSupplier interrupted) {
Duration remainingTime(AgentRunService.RunView run) {
Duration remaining = Duration.between(Instant.now(), run.startedAt().toInstant().plus(runTimeout));
if (remaining.isNegative() || remaining.isZero()) throw timeoutFailure();
return remaining;
}
private ApiException timeoutFailure() {
return new ApiException(HttpStatus.REQUEST_TIMEOUT, "AGENT_RUN_TIMEOUT", "任务已超过运行时限,现有成果已保留,可稍后继续");
}
private void pauseBeforeReconnect(int attempt, BooleanSupplier interrupted, Duration remaining) {
try {
Thread.sleep(Math.min(8_000L, 500L << (attempt - 1)));
Thread.sleep(Math.min(remaining.toMillis(), Math.min(8_000L, 500L << (attempt - 1))));
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
if (interrupted.getAsBoolean()) {
@@ -173,18 +197,24 @@ public class AgentExecutionService {
this.runId = runId;
}
private void accept(List<AguiEvent> batch) {
private void accept(List<AguiEvent> batch, AgentRuntimeMiddleware runtime) {
for (AguiEvent event : batch) {
accept(event);
accept(event, runtime);
}
if (System.nanoTime() - lastFlushNanos >= Duration.ofMillis(400).toNanos()) {
flush();
}
}
private void accept(AguiEvent event) {
private void accept(AguiEvent event, AgentRuntimeMiddleware runtime) {
String type = event.getType().name();
if (type.equals("RUN_STARTED") || type.equals("RUN_FINISHED") || type.equals("RUN_ERROR")) {
// Adapter 将基础设施异常转成事件必须终止执行不能当作缺少 JSON 再次调用 Agent
if (event instanceof AguiEvent.RunError error) {
String message = sanitize(objectMapper.getNodeFactory().textNode(error.message()),
fileService.projectRoot(projectId).toString()).asText();
throw new IllegalStateException("Agent 运行错误:" + message, runtime == null ? null : runtime.modelFailure());
}
if (type.equals("RUN_STARTED") || type.equals("RUN_FINISHED")) {
return;
}
ObjectNode payload = (ObjectNode) sanitize(

View File

@@ -1,9 +1,9 @@
package tech.easyflow.manuagent.agent;
package tech.easyflow.manuagent.agent.runtime;
import tech.easyflow.manuagent.config.AppProperties;
import tech.easyflow.manuagent.model.ModelService;
import tech.easyflow.manuagent.project.ProjectFileService;
import com.fasterxml.jackson.databind.ObjectMapper;
import tech.easyflow.manuagent.agent.config.AgentProperties;
import tech.easyflow.manuagent.agent.model.ModelService;
import tech.easyflow.manuagent.agent.project.ProjectFileService;
import tools.jackson.databind.ObjectMapper;
import io.agentscope.core.agui.adapter.AguiAdapterConfig;
import io.agentscope.core.agui.adapter.AguiAgentAdapter;
import io.agentscope.core.skill.AgentSkill;
@@ -11,6 +11,8 @@ import io.agentscope.core.skill.repository.AgentSkillRepository;
import io.agentscope.core.skill.repository.AgentSkillRepositoryInfo;
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
import io.agentscope.core.tool.Toolkit;
import io.agentscope.core.state.JsonFileAgentStateStore;
import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem;
import io.agentscope.extensions.model.openai.OpenAIChatModel;
import io.agentscope.harness.agent.IsolationScope;
import io.agentscope.harness.agent.HarnessAgent;
@@ -45,7 +47,7 @@ public class AgentFactory {
private final ModelService modelService;
private final PostgresSkillRepository skillRepository;
private final ProjectFileService fileService;
private final AppProperties properties;
private final AgentProperties properties;
private final ObjectMapper objectMapper;
private final String systemPrompt;
private final String compactionPrompt;
@@ -63,7 +65,7 @@ public class AgentFactory {
ModelService modelService,
PostgresSkillRepository skillRepository,
ProjectFileService fileService,
AppProperties properties,
AgentProperties properties,
ObjectMapper objectMapper) {
this.modelService = modelService;
this.skillRepository = skillRepository;
@@ -72,6 +74,7 @@ public class AgentFactory {
this.objectMapper = objectMapper;
this.systemPrompt = readPrompt("prompts/smart-factory-agent-system.md", "Agent 全局提示词");
this.compactionPrompt = readPrompt("prompts/smart-factory-compaction.md", "上下文压缩提示词");
AgentStateFiles.migrateExisting(properties.dataRoot());
}
/**
@@ -96,7 +99,7 @@ public class AgentFactory {
Map<String, String> environment = new HashMap<>();
environment.put("DASHSCOPE_API_KEY", readOptionalKey(properties.dashscopeKeyFile()));
environment.put("PATH", "/opt/java/openjdk/bin:/usr/local/bin:/usr/bin:/bin");
environment.put("NODE_PATH", "/opt/agent-runtime/node_modules");
environment.put("NODE_PATH", "/opt/sandbox/node_modules");
environment.put("SKILL_SESSION_ID", "project-" + projectId);
WorkspaceSpec workspace = new WorkspaceSpec();
@@ -115,6 +118,7 @@ public class AgentFactory {
throw new IllegalStateException("无法创建 Agent 沙箱快照目录", exception);
}
DockerFilesystemSpec filesystem = new DockerFilesystemSpec()
.client(new RecoverableDockerClient(properties.sandboxImage()))
.image(properties.sandboxImage())
.workspaceRoot("/workspace")
.environment(environment)
@@ -133,13 +137,17 @@ public class AgentFactory {
CompactionConfig compaction = compactionFor(model.contextWindow(), compactionPrompt);
Toolkit toolkit = new Toolkit();
DocumentViewTool documentView = new DocumentViewTool(objectMapper);
DocumentViewTool documentView = new DocumentViewTool(objectMapper, fileService, projectId);
toolkit.registerTool(documentView);
AgentRuntimeMiddleware runtime = new AgentRuntimeMiddleware();
Path stateRoot = AgentStateFiles.prepare(properties.dataRoot(), AgentStateFiles.legacyRoot(), projectId);
HarnessAgent agent = HarnessAgent.builder()
.name("smart-factory-agent")
.description("智能工厂申报书规划、编写与评审 Agent")
.sysPrompt(systemPrompt)
.model(chatModel)
.stateStore(new JsonFileAgentStateStore(stateRoot))
.middleware(runtime)
.toolkit(toolkit)
.workspace(projectRoot)
.filesystem(filesystem)
@@ -148,6 +156,8 @@ public class AgentFactory {
.toolResultEviction(ToolResultEvictionConfig.defaults())
.maxContextTokens(model.contextWindow())
.maxIters(96)
// 中断的工具调用以失败结果补齐由模型检查已有成果后继续不绕过权限确认
.enablePendingToolRecovery(true)
.enableAgentTracingLog(false)
.disableSubagents()
.build();
@@ -155,15 +165,16 @@ public class AgentFactory {
agent.getWorkspaceManager().getFilesystem(),
WorkspacePathNormalizer.of("/workspace"),
objectMapper));
agent.getToolkit().registerTool(new SandboxTools(
(AbstractSandboxFilesystem) agent.getWorkspaceManager().getFilesystem(), objectMapper));
documentView.bind(agent);
AguiAdapterConfig config = AguiAdapterConfig.builder()
.enableReasoning(true)
.emitToolCallArgs(true)
.emitStateEvents(false)
.runTimeout(properties.runTimeout())
.defaultAgentId("smart-factory-agent")
.build();
return new AgentHandle(new AguiAgentAdapter(agent, config), agent);
return new AgentHandle(new AguiAgentAdapter(agent, config), agent, runtime);
}
/**
@@ -240,8 +251,9 @@ public class AgentFactory {
*
* @param adapter AG-UI 适配器
* @param agent Harness Agent
* @param runtime 模型调用约束及原始异常
*/
public record AgentHandle(AguiAgentAdapter adapter, HarnessAgent agent) implements AutoCloseable {
public record AgentHandle(AguiAgentAdapter adapter, HarnessAgent agent, AgentRuntimeMiddleware runtime) implements AutoCloseable {
/**
* 结束 Agent 并释放 Docker 沙箱资源

View File

@@ -1,10 +1,10 @@
package tech.easyflow.manuagent.agent;
package tech.easyflow.manuagent.agent.runtime;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.project.ProjectFileService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import tech.easyflow.manuagent.agent.project.ProjectFileService;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.entity;
package tech.easyflow.manuagent.agent.runtime;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
@@ -8,8 +8,8 @@ import com.mybatisflex.core.keygen.KeyGenerators;
import java.time.OffsetDateTime;
import java.util.UUID;
import org.apache.ibatis.type.JdbcType;
import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler;
import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler;
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/**
* 映射 {@code app.agent_run} 表的 Agent 运行状态实体

View File

@@ -1,13 +1,14 @@
package tech.easyflow.manuagent.mapper;
package tech.easyflow.manuagent.agent.runtime;
import com.mybatisflex.core.BaseMapper;
import java.util.UUID;
import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.AgentRunEntity;
import tech.easyflow.manuagent.agent.runtime.AgentRunEntity;
/**
* 提供 Agent Run 查询以及带前置状态条件的原子状态迁移
*/
@org.apache.ibatis.annotations.Mapper
public interface AgentRunMapper extends BaseMapper<AgentRunEntity> {
/** 将等待输入的 Run 标记为已完成。 */

View File

@@ -1,22 +1,24 @@
package tech.easyflow.manuagent.agent;
package tech.easyflow.manuagent.agent.runtime;
import tech.easyflow.manuagent.artifact.ArtifactService;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.agent.artifact.ArtifactService;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.project.ProjectFileService;
import tech.easyflow.manuagent.project.ProjectService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import tech.easyflow.manuagent.agent.project.ProjectFileService;
import tech.easyflow.manuagent.agent.project.ProjectService;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
import java.io.IOException;
import java.security.Principal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.beans.factory.annotation.Value;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
@@ -26,7 +28,7 @@ import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import reactor.core.publisher.Sinks;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
/**
* 驱动材料检验规划 Ask 和自动编写 Run
@@ -43,10 +45,11 @@ public class AgentRunService {
private final AgentEventService eventService;
private final ProjectService projectService;
private final ProjectFileService fileService;
private final UserService userService;
private final ArtifactService artifactService;
private final ExecutorService executor;
private final TransactionTemplate transactions;
static final int MAX_OUTPUT_REPAIRS = 3;
private final Semaphore runSlots;
private final ConcurrentMap<UUID, RunControl> activeRuns = new ConcurrentHashMap<>();
/**
@@ -60,10 +63,10 @@ public class AgentRunService {
* @param eventService 事件服务
* @param projectService 项目服务
* @param fileService 材料与工作区服务
* @param userService 用户服务
* @param artifactService 产物服务
* @param applicationExecutor 虚拟线程执行器
* @param transactions 编程式事务模板
* @param maxConcurrentRuns 单进程同时运行的任务上限
*/
public AgentRunService(
AgentRunMapper runMapper,
@@ -74,10 +77,10 @@ public class AgentRunService {
AgentEventService eventService,
ProjectService projectService,
ProjectFileService fileService,
UserService userService,
ArtifactService artifactService,
ExecutorService applicationExecutor,
TransactionTemplate transactions) {
TransactionTemplate transactions,
@Value("${app.max-concurrent-runs:2}") int maxConcurrentRuns) {
this.runMapper = runMapper;
this.objectMapper = objectMapper;
this.executionService = executionService;
@@ -86,26 +89,27 @@ public class AgentRunService {
this.eventService = eventService;
this.projectService = projectService;
this.fileService = fileService;
this.userService = userService;
this.artifactService = artifactService;
this.executor = applicationExecutor;
this.transactions = transactions;
if (maxConcurrentRuns < 1) throw new IllegalArgumentException("app.max-concurrent-runs 必须大于 0");
this.runSlots = new Semaphore(maxConcurrentRuns);
}
/**
* 后台启动材料检验
*
* @param projectId 项目 ID
* @param principal 当前用户
* @param userId 当前操作者 ID
* @return Run
*/
@Transactional
public RunView startMaterialCheck(UUID projectId, Principal principal) {
public RunView startMaterialCheck(UUID projectId, UUID userId) {
ProjectService.ProjectView project = projectService.require(projectId);
userService.requireUserId(principal.getName());
RunView previous = latest(projectId);
RunView run = runStore.create(projectId, "INITIAL", null);
projectService.updateStatus(projectId, "MATERIAL_CHECK");
afterCommit(run.id(), () -> executeMaterialRun(project, run, false));
afterCommit(run.id(), () -> executeMaterialRun(project, run, false), previous == null ? null : previous.id());
return run;
}
@@ -114,13 +118,13 @@ public class AgentRunService {
*
* @param projectId 项目 ID
* @param response 用户对材料缺口的处理结果
* @param principal 当前用户
* @param userId 当前操作者 ID
* @return 新规划 Run
*/
@Transactional
public RunView confirmMaterials(UUID projectId, JsonNode response, Principal principal) {
public RunView confirmMaterials(UUID projectId, JsonNode response, UUID userId) {
ProjectService.ProjectView project = projectService.require(projectId);
UUID userId = userService.requireUserId(principal.getName());
RunView waiting = runStore.requireWaiting(projectId, "material_check");
if (!response.isObject() || !response.path("decisions").isArray()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "MATERIAL_RESPONSE_INVALID", "请确认每项材料缺口");
@@ -129,7 +133,7 @@ public class AgentRunService {
runStore.completeWaiting(waiting.id());
RunView run = runStore.create(projectId, "RESUME", waiting.id());
projectService.updateStatus(projectId, "PLANNING");
afterCommit(run.id(), () -> executePlanningRun(project, run, userId, response, false));
afterCommit(run.id(), () -> executePlanningRun(project, run, userId, response, false), waiting.id());
return run;
}
@@ -139,7 +143,7 @@ public class AgentRunService {
* @param projectId 项目 ID
* @param planId 规划 ID
* @param confirmedPlan 用户确认后的规划
* @param principal 当前用户
* @param userId 当前操作者 ID
* @return 已确认规划及编写 Run
*/
@Transactional
@@ -147,7 +151,7 @@ public class AgentRunService {
UUID projectId,
UUID planId,
JsonNode confirmedPlan,
Principal principal) {
UUID userId) {
ProjectService.ProjectView project = projectService.require(projectId);
ProjectService.PlanView current = projectService.currentPlan(projectId);
if (current != null && current.id().equals(planId) && "CONFIRMED".equals(current.status())) {
@@ -158,11 +162,11 @@ public class AgentRunService {
}
outputService.validateConfirmedPlan(confirmedPlan);
RunView waiting = runStore.requireWaiting(projectId, "planning");
ProjectService.PlanView plan = projectService.confirmPlan(projectId, planId, confirmedPlan, principal);
ProjectService.PlanView plan = projectService.confirmPlan(projectId, planId, confirmedPlan, userId);
eventService.append(projectId, waiting.id(), "ASK_RESPONDED", Map.of("planId", planId));
runStore.completeWaiting(waiting.id());
RunView run = runStore.create(projectId, "RESUME", waiting.id());
afterCommit(run.id(), () -> executeWritingRun(project, plan, run, false));
afterCommit(run.id(), () -> executeWritingRun(project, plan, run, false), waiting.id());
return new ConfirmPlanResult(plan, run);
}
@@ -188,7 +192,7 @@ public class AgentRunService {
}
RunView run = runStore.create(projectId, "RESUME", latest == null ? null : latest.id());
projectService.updateStatus(projectId, "WRITING");
afterCommit(run.id(), () -> executeWritingRun(project, plan, run, false));
afterCommit(run.id(), () -> executeWritingRun(project, plan, run, false), latest == null ? null : latest.id());
return run;
}
@@ -196,13 +200,13 @@ public class AgentRunService {
* 立即停止当前 Run并保留项目阶段和工作区成果
*
* @param projectId 项目 ID
* @param principal 当前用户
* @param userId 当前操作者 ID
* @return 已中断的 Run
*/
@Transactional
public RunView stop(UUID projectId, Principal principal) {
public RunView stop(UUID projectId, UUID userId) {
projectService.require(projectId);
userService.requireUserId(principal.getName());
RunView run = latest(projectId);
if (run == null || !"RUNNING".equals(run.status())) {
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务");
@@ -223,12 +227,12 @@ public class AgentRunService {
* 从已中断 Run 的原阶段继续复用同一线程状态和工作区成果
*
* @param projectId 项目 ID
* @param principal 当前用户
* @param userId 当前操作者 ID
* @return 新的恢复 Run
*/
@Transactional
public RunView resume(UUID projectId, Principal principal) {
return resume(projectId, null, principal);
public RunView resume(UUID projectId, UUID userId) {
return resume(projectId, null, userId);
}
/**
@@ -236,13 +240,13 @@ public class AgentRunService {
*
* @param projectId 项目 ID
* @param modelConfigId 替代模型为空时使用当前默认模型
* @param principal 当前用户
* @param userId 当前操作者 ID
* @return 新的恢复 Run
*/
@Transactional
public RunView resume(UUID projectId, UUID modelConfigId, Principal principal) {
public RunView resume(UUID projectId, UUID modelConfigId, UUID userId) {
ProjectService.ProjectView project = projectService.require(projectId);
UUID userId = userService.requireUserId(principal.getName());
RunView interrupted = latest(projectId);
if (interrupted == null || !"INTERRUPTED".equals(interrupted.status())) {
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_INTERRUPTED", "当前没有可继续的任务");
@@ -250,7 +254,7 @@ public class AgentRunService {
String phase = runStore.interruptedPhase(interrupted, project);
RunView run = runStore.create(projectId, "RESUME", interrupted.id(), modelConfigId);
projectService.updateStatus(projectId, phase);
scheduleResume(project, run, userId, phase);
scheduleResume(project, run, userId, phase, interrupted.id());
return run;
}
@@ -263,13 +267,13 @@ public class AgentRunService {
*
* @param projectId 项目 ID
* @param modelConfigId 替代模型 ID
* @param principal 当前用户
* @param userId 当前操作者 ID
* @return 绑定替代模型的新恢复 Run
*/
@Transactional
public RunView switchModel(UUID projectId, UUID modelConfigId, Principal principal) {
public RunView switchModel(UUID projectId, UUID modelConfigId, UUID userId) {
ProjectService.ProjectView project = projectService.require(projectId);
UUID userId = userService.requireUserId(principal.getName());
RunView current = latest(projectId);
if (current == null || !"RUNNING".equals(current.status())) {
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务");
@@ -291,7 +295,7 @@ public class AgentRunService {
control.cancel();
}
});
scheduleResume(project, replacement, userId, phase);
scheduleResume(project, replacement, userId, phase, current.id());
return replacement;
}
@@ -302,21 +306,22 @@ public class AgentRunService {
ProjectService.ProjectView project,
RunView run,
UUID userId,
String phase) {
String phase,
UUID previousRunId) {
switch (phase) {
case "MATERIAL_CHECK" -> afterCommit(
run.id(), () -> executeMaterialRun(project, run, true));
run.id(), () -> executeMaterialRun(project, run, true), previousRunId);
case "PLANNING" -> {
JsonNode materialResponse = runStore.latestMaterialResponse(project.id());
afterCommit(run.id(), () -> executePlanningRun(
project, run, userId, materialResponse, true));
project, run, userId, materialResponse, true), previousRunId);
}
case "WRITING" -> {
ProjectService.PlanView plan = projectService.currentPlan(project.id());
if (plan == null || !"CONFIRMED".equals(plan.status())) {
throw new ApiException(HttpStatus.CONFLICT, "PLAN_NOT_CONFIRMED", "无法恢复:建设规划尚未确认");
}
afterCommit(run.id(), () -> executeWritingRun(project, plan, run, true));
afterCommit(run.id(), () -> executeWritingRun(project, plan, run, true), previousRunId);
}
default -> throw new ApiException(
HttpStatus.CONFLICT, "RUN_PHASE_UNKNOWN", "无法识别中断前的执行阶段");
@@ -383,8 +388,12 @@ public class AgentRunService {
while (true) {
try {
return outputService.readMaterialCheck(project.id());
} catch (ApiException | IOException exception) {
} catch (ApiException | IOException | tools.jackson.core.JacksonException exception) {
runStore.ensureRunning(run.id());
if (repairRound >= MAX_OUTPUT_REPAIRS) {
throw new ApiException(HttpStatus.UNPROCESSABLE_ENTITY, "AGENT_OUTPUT_INVALID",
"结构化结果连续修复失败,现有成果已保留,请检查材料或模型后继续");
}
repairRound++;
log.warn("Agent 未生成有效材料检验结果继续同一线程修复runId={}round={}",
run.id(), repairRound, exception);
@@ -457,8 +466,12 @@ public class AgentRunService {
while (true) {
try {
return outputService.readProposedPlan(project.id());
} catch (ApiException | IOException exception) {
} catch (ApiException | IOException | tools.jackson.core.JacksonException exception) {
runStore.ensureRunning(run.id());
if (repairRound >= MAX_OUTPUT_REPAIRS) {
throw new ApiException(HttpStatus.UNPROCESSABLE_ENTITY, "AGENT_OUTPUT_INVALID",
"结构化结果连续修复失败,现有成果已保留,请检查材料或模型后继续");
}
repairRound++;
log.warn("Agent 未生成有效建设规划继续同一线程修复runId={}round={}",
run.id(), repairRound, exception);
@@ -543,29 +556,57 @@ public class AgentRunService {
* @param task 后台任务
*/
private void afterCommit(UUID runId, Runnable task) {
RunControl control = new RunControl();
activeRuns.put(runId, control);
afterCommit(runId, task, null);
}
private void afterCommit(UUID runId, Runnable task, UUID previousRunId) {
// ponytail: 单进程并发上限部署多个后端副本时改用数据库租约统一占位
RunControl control;
synchronized (activeRuns) {
RunControl previous = previousRunId == null ? null : activeRuns.get(previousRunId);
if (previous == null && !runSlots.tryAcquire()) {
throw new ApiException(HttpStatus.TOO_MANY_REQUESTS, "AGENT_CAPACITY_REACHED", "当前运行任务已达上限,请稍后重试");
}
control = new RunControl(previous);
activeRuns.put(runId, control);
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
executor.submit(() -> {
try {
task.run();
} finally {
activeRuns.remove(runId, control);
}
});
try {
executor.submit(() -> {
try {
// 同一项目接续复用名额但须等旧 Agent 关闭并保存快照后再读写工作区
control.previousFinished.join();
task.run();
} finally {
release(runId, control);
}
});
} catch (RuntimeException exception) {
release(runId, control);
fail(runStore.require(runId), exception);
}
}
@Override
public void afterCompletion(int status) {
if (status != TransactionSynchronization.STATUS_COMMITTED) {
activeRuns.remove(runId, control);
release(runId, control);
}
}
});
}
private void release(UUID runId, RunControl control) {
synchronized (activeRuns) {
if (!activeRuns.remove(runId, control)) return;
if (control.slotUsers.decrementAndGet() == 0) runSlots.release();
}
// 回滚或提交任务失败也不能让后继任务越过仍在释放资源的祖先任务
control.previousFinished.thenRun(() -> control.finished.complete(null));
}
/**
* 在当前事务成功提交后执行短操作
*
@@ -647,7 +688,7 @@ public class AgentRunService {
int updated = runMapper.failRunning(run.id(), message);
if (updated == 1) {
eventService.append(run.projectId(), run.id(), "RUN_ERROR", Map.of(
"code", "AGENT_RUN_FAILED", "message", message));
"code", exception instanceof ApiException api ? api.code() : "AGENT_RUN_FAILED", "message", message));
projectService.updateStatus(run.projectId(), "FAILED");
}
});
@@ -708,6 +749,15 @@ public class AgentRunService {
private static final class RunControl {
private final Sinks.One<Void> stopSignal = Sinks.one();
private final AtomicInteger slotUsers;
private final CompletableFuture<Void> previousFinished;
private final CompletableFuture<Void> finished = new CompletableFuture<>();
private RunControl(RunControl previous) {
slotUsers = previous == null ? new AtomicInteger(1) : previous.slotUsers;
if (previous != null) slotUsers.incrementAndGet();
previousFinished = previous == null ? CompletableFuture.completedFuture(null) : previous.finished;
}
/**
* 取消 Agent 流订阅停止继续输出和后续工具调用

View File

@@ -1,19 +1,19 @@
package tech.easyflow.manuagent.agent;
package tech.easyflow.manuagent.agent.runtime;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.project.ProjectService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import tech.easyflow.manuagent.agent.project.ProjectService;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import tech.easyflow.manuagent.entity.AgentRunEntity;
import tech.easyflow.manuagent.entity.ModelConfigEntity;
import tech.easyflow.manuagent.mapper.AgentEventMapper;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
import tech.easyflow.manuagent.agent.runtime.AgentRunEntity;
import tech.easyflow.manuagent.agent.model.ModelConfigEntity;
import tech.easyflow.manuagent.agent.runtime.AgentEventMapper;
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
import tech.easyflow.manuagent.agent.model.ModelConfigMapper;
/**
* 集中读写 Agent Run 持久化状态
@@ -172,7 +172,7 @@ public class AgentRunStore {
throw new ApiException(HttpStatus.CONFLICT, "ASK_TYPE_MISMATCH", "确认内容与当前阶段不一致");
}
return run;
} catch (JsonProcessingException exception) {
} catch (JacksonException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ASK_STATE_INVALID", "确认状态无法读取");
}
}
@@ -228,7 +228,7 @@ public class AgentRunStore {
}
try {
return objectMapper.readTree(value);
} catch (JsonProcessingException exception) {
} catch (JacksonException exception) {
throw new ApiException(
HttpStatus.INTERNAL_SERVER_ERROR,
"MATERIAL_RESPONSE_INVALID",

View File

@@ -0,0 +1,44 @@
package tech.easyflow.manuagent.agent.runtime;
import io.agentscope.core.agent.Agent;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.event.AgentEvent;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.MsgRole;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.middleware.MiddlewareBase;
import io.agentscope.core.middleware.ModelCallInput;
import java.util.ArrayList;
import java.util.Map;
import java.util.function.Function;
import reactor.core.publisher.Flux;
/** 在每次模型调用前刷新正文语言约束,并保留被 AG-UI 转换丢失的异常原因。 */
final class AgentRuntimeMiddleware implements MiddlewareBase {
private volatile Throwable modelFailure;
@Override
public Flux<AgentEvent> onModelCall(Agent agent, RuntimeContext context, ModelCallInput input,
Function<ModelCallInput, Flux<AgentEvent>> next) {
var messages = new ArrayList<>(input.messages());
messages.add(Msg.builder().role(MsgRole.USER).name("system")
.content(TextBlock.builder().text("""
<system-reminder>
除非用户明确要求其他语言,本次及后续面向用户的正文、进度说明和最终回复均使用简体中文。
英文 Skill、工具说明和历史英文消息不改变此要求代码、命令、路径、标准原文和专有名词保留原语言。
只说明业务进展和必要确认事项,不输出工具选择、脚本调试或内部文件操作的过程旁白。
</system-reminder>
""").build())
.metadata(Map.of(Msg.METADATA_SYNTHETIC, true, Msg.METADATA_REMINDER_KIND, "response_language"))
.build());
return Flux.defer(() -> {
modelFailure = null;
return next.apply(new ModelCallInput(messages, input.tools(), input.options(), input.model()))
.doOnError(error -> modelFailure = error);
});
}
Throwable modelFailure() {
return modelFailure;
}
}

View File

@@ -0,0 +1,75 @@
package tech.easyflow.manuagent.agent.runtime;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Base64;
import java.util.Comparator;
import java.util.UUID;
/** 状态随 APP_DATA_ROOT 持久化;按项目复制旧状态,保留原件且不覆盖已迁移会话。 */
final class AgentStateFiles {
static Path legacyRoot() {
return Path.of(System.getProperty("agentscope.state.home",
Path.of(System.getProperty("user.home"), ".agentscope", "state").toString()));
}
static void migrateExisting(Path dataRoot) {
Path projects = dataRoot.resolve("projects");
if (!Files.isDirectory(projects)) return;
try (var paths = Files.list(projects)) {
for (Path project : paths.filter(Files::isDirectory).toList()) {
String name = project.getFileName().toString();
if (name.matches("[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}")) {
prepare(dataRoot, legacyRoot(), UUID.fromString(name));
}
}
} catch (IOException exception) {
throw new IllegalStateException("无法检查已有项目的 Agent 状态", exception);
}
}
static synchronized Path prepare(Path dataRoot, Path legacyRoot, UUID projectId) {
Path root = dataRoot.toAbsolutePath().normalize().resolve("agent-state/smart-factory-agent");
String session = "project-" + projectId;
// AgentScope 2.0.1 将 SESSION 沙箱元数据放在独立的 Base64 URL 目录中。
// 分别补迁,兼容此前只复制了会话的项目;保留已有沙箱状态(包括删除标记)。
String sandboxSession = Base64.getUrlEncoder().withoutPadding()
.encodeToString(("sandbox/session/" + session).getBytes(StandardCharsets.UTF_8));
for (String slot : new String[]{sandboxSession, session}) {
Path relative = Path.of("__anon__", slot);
copyMissing(legacyRoot.resolve("smart-factory-agent").resolve(relative), root.resolve(relative));
}
return root;
}
private static void copyMissing(Path source, Path target) {
if (!Files.isDirectory(source) || Files.exists(target)) return;
Path staging = null;
try {
Files.createDirectories(target.getParent());
staging = Files.createTempDirectory(target.getParent(), ".migrating-");
try (var files = Files.walk(source)) {
for (Path file : files.toList()) {
Path destination = staging.resolve(source.relativize(file));
if (Files.isSymbolicLink(file)) throw new IOException("旧状态目录不能包含符号链接");
if (Files.isDirectory(file)) Files.createDirectories(destination);
else Files.copy(file, destination, StandardCopyOption.COPY_ATTRIBUTES);
}
}
Files.move(staging, target, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException exception) {
throw new IllegalStateException("无法迁移 Agent 会话状态,原状态已保留", exception);
} finally {
if (staging != null && Files.exists(staging)) {
try (var files = Files.walk(staging)) {
for (Path file : files.sorted(Comparator.reverseOrder()).toList()) Files.delete(file);
} catch (IOException exception) {
org.slf4j.LoggerFactory.getLogger(AgentStateFiles.class).warn("清理状态迁移临时目录失败", exception);
}
}
}
}
}

View File

@@ -1,8 +1,8 @@
package tech.easyflow.manuagent.agent;
package tech.easyflow.manuagent.agent.runtime;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.message.Base64Source;
import io.agentscope.core.message.ContentBlock;
@@ -18,12 +18,15 @@ import io.agentscope.harness.agent.filesystem.model.FileDownloadResponse;
import io.agentscope.harness.agent.filesystem.model.WriteResult;
import io.agentscope.harness.agent.filesystem.sandbox.SandboxBackedFilesystem;
import java.nio.charset.StandardCharsets;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import tech.easyflow.manuagent.agent.project.ProjectFileService;
import tech.easyflow.manuagent.common.ApiException;
/**
* 在当前 Harness 沙箱内把指定文档视图渲染为多模态图片
@@ -33,6 +36,8 @@ public final class DocumentViewTool {
private static final int TOOL_TIMEOUT_SECONDS = 180;
private final ObjectMapper objectMapper;
private final ProjectFileService fileService;
private final UUID projectId;
private volatile HarnessAgent harness;
/**
@@ -40,8 +45,10 @@ public final class DocumentViewTool {
*
* @param objectMapper JSON 映射器
*/
public DocumentViewTool(ObjectMapper objectMapper) {
public DocumentViewTool(ObjectMapper objectMapper, ProjectFileService fileService, UUID projectId) {
this.objectMapper = objectMapper;
this.fileService = fileService;
this.projectId = projectId;
}
/**
@@ -98,7 +105,7 @@ public final class DocumentViewTool {
}
ExecuteResponse execution = sandbox.execute(
runtimeContext,
"python /opt/agent-runtime/document_view.py " + requestPath + " " + resultPath,
"python /opt/sandbox/document_view.py " + requestPath + " " + resultPath,
TOOL_TIMEOUT_SECONDS);
FileDownloadResponse downloaded = filesystem.downloadFiles(runtimeContext, List.of(resultPath)).getFirst();
if (!downloaded.isSuccess()) {
@@ -114,14 +121,18 @@ public final class DocumentViewTool {
List<Map<String, Object>> metadata = new ArrayList<>();
for (JsonNode image : result.path("images")) {
String path = image.path("path").asText();
FileDownloadResponse imageFile = filesystem.downloadFiles(runtimeContext, List.of(path)).getFirst();
if (!imageFile.isSuccess()) {
byte[] imageBytes;
try {
imageBytes = readPreview(path, image.path("sizeBytes").asLong(-1));
} catch (IOException | ApiException exception) {
output.add(TextBlock.builder().text("预览图读取失败:"
+ safeError(exception.getMessage(), null)).build());
continue;
}
output.add(ImageBlock.builder()
.source(Base64Source.builder()
.mediaType(image.path("mediaType").asText("image/png"))
.data(Base64.getEncoder().encodeToString(imageFile.content()))
.data(Base64.getEncoder().encodeToString(imageBytes))
.build())
.build());
metadata.add(objectMapper.convertValue(image, new TypeReference<LinkedHashMap<String, Object>>() { }));
@@ -132,6 +143,20 @@ public final class DocumentViewTool {
}
}
byte[] readPreview(String path, long expectedSize) throws IOException {
if (expectedSize <= 0 || expectedSize > 4 * 1024 * 1024) {
throw new IOException("预览图大小无效或超过 4 MB");
}
// work 已绑定宿主机复用预览路径校验避免 SDK 512 KB stdout 截断 Base64
try (var input = fileService.preview(projectId, path).resource().getInputStream()) {
byte[] bytes = input.readNBytes((int) expectedSize + 1);
if (bytes.length != expectedSize) {
throw new IOException("预览图字节数不一致,请重新渲染");
}
return bytes;
}
}
/**
* 选择并限制返回给 Agent 的诊断信息
*

View File

@@ -1,7 +1,7 @@
package tech.easyflow.manuagent.agent;
package tech.easyflow.manuagent.agent.runtime;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.tool.Tool;
import io.agentscope.core.tool.ToolParam;

View File

@@ -0,0 +1,35 @@
package tech.easyflow.manuagent.agent.runtime;
import io.agentscope.harness.agent.sandbox.Sandbox;
import io.agentscope.harness.agent.sandbox.SandboxState;
import io.agentscope.harness.agent.sandbox.impl.docker.DockerSandboxClient;
import io.agentscope.harness.agent.sandbox.impl.docker.DockerSandboxState;
import java.util.UUID;
/** AgentScope 2.0.1 在调用结束才保存容器 ID进程中断后使用会话的稳定名称恢复。 */
final class RecoverableDockerClient extends DockerSandboxClient {
private final String image;
RecoverableDockerClient(String image) {
this.image = image;
}
@Override
public Sandbox resume(SandboxState state) {
if (state instanceof DockerSandboxState docker && docker.isContainerOwned()
&& docker.getImage() != null && docker.getImage().startsWith("smart-factory-agent-runtime:")) {
// SDK 恢复时沿用持久化镜像名;更名后保留原会话和快照,使用当前沙箱镜像。
docker.setImage(image);
}
if (state instanceof DockerSandboxState docker && docker.isContainerOwned()
&& docker.getSessionId() != null
&& docker.getSessionId().matches("[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}")) {
String name = "agentscope-sandbox-" + UUID.fromString(docker.getSessionId());
if (name.equals(docker.getContainerName())) {
// Docker 接受名称作为标识;不存在时仍由 SDK 从原快照重建,不丢弃会话。
docker.setContainerId(name);
}
}
return super.resume(state);
}
}

View File

@@ -1,18 +1,14 @@
package tech.easyflow.manuagent.agent;
package tech.easyflow.manuagent.agent.runtime;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
/**
* 启动时终结因 JVM 中断而遗留的伪运行状态并保留原业务阶段供继续执行
*/
@Component
@Order(0)
public class RunRecoveryService implements ApplicationRunner {
public class RunRecoveryService {
private final AgentRunMapper runMapper;
@@ -28,11 +24,9 @@ public class RunRecoveryService implements ApplicationRunner {
/**
* 将仍为 RUNNING 的旧 Run 标记为已中断
*
* @param args 启动参数
*/
@Override
@Transactional
public void run(ApplicationArguments args) {
public void recoverInterruptedRuns() {
runMapper.interruptRunningAfterRestart();
}
}

View File

@@ -0,0 +1,111 @@
package tech.easyflow.manuagent.agent.runtime;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.tool.Tool;
import io.agentscope.core.tool.ToolParam;
import io.agentscope.harness.agent.filesystem.model.ExecuteResponse;
import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem;
import io.agentscope.harness.agent.workspace.WorkspacePathNormalizer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import tools.jackson.databind.ObjectMapper;
/** 修正 2.0.1 的编辑命令换行,并保留 shell 管道的真实退出状态。 */
final class SandboxTools {
private final AbstractSandboxFilesystem filesystem;
private final ObjectMapper mapper;
SandboxTools(AbstractSandboxFilesystem filesystem, ObjectMapper mapper) {
this.filesystem = filesystem;
this.mapper = mapper;
}
@Tool(name = "execute", description = "执行 Bash 命令,启用 errexit 和 pipefail。返回 success、exitCode、output失败时先诊断再继续不得使用 || true 掩盖必要步骤的错误。")
public Map<String, Object> execute(RuntimeContext context,
@ToolParam(name = "command", description = "Shell 命令") String command,
@ToolParam(name = "working_directory", description = "工作区内相对目录", required = false) String directory,
@ToolParam(name = "timeout", description = "超时秒数,默认 30", required = false) Integer timeout) {
if (command == null || command.isBlank()) return failure("命令不能为空");
if (directory != null && !directory.isBlank()) {
if (!relativePath(directory)) return failure("working_directory 必须是工作区内相对路径");
command = "cd " + quote(directory) + "\n" + command;
}
ExecuteResponse result = filesystem.execute(context,
"bash -e -o pipefail -c " + quote(command), timeout != null && timeout > 0 ? timeout : 30);
Map<String, Object> output = new LinkedHashMap<>();
output.put("success", result.isSuccess());
output.put("exitCode", result.exitCode());
output.put("output", result.output() == null ? "" : result.output());
output.put("truncated", result.truncated());
return output;
}
@Tool(name = "edit_file", description = "在 UTF-8 文件内精确替换。先读取文件old_string 必须非空且唯一,除非 replace_all=true。修改已有文件应使用此工具write_file 仅创建新文件。")
public Map<String, Object> editFile(RuntimeContext context,
@ToolParam(name = "path", description = "工作区内文件路径") String path,
@ToolParam(name = "old_string", description = "要替换的原文") String oldString,
@ToolParam(name = "new_string", description = "替换后的文本") String newString,
@ToolParam(name = "replace_all", description = "替换全部匹配,默认 false", required = false) Boolean replaceAll) {
path = WorkspacePathNormalizer.of("/workspace").normalize(path);
if (!relativePath(path)) return failure("path 必须是工作区内文件路径");
if (oldString == null || oldString.isEmpty() || newString == null) return failure("old_string 不能为空new_string 不能为 null");
String payload = Base64.getEncoder().encodeToString(mapper.writeValueAsString(Map.of(
"path", path, "old", oldString, "new", newString, "all", Boolean.TRUE.equals(replaceAll)))
.getBytes(StandardCharsets.UTF_8));
// 数据单独编码,避免文本中的引号、换行或 shell 字符改变脚本。
String command = """
python3 - <<'PY'
import base64, fcntl, json, os
p = json.loads(base64.b64decode('%s'))
try:
root = os.path.realpath('.')
path = os.path.realpath(p['path'])
if os.path.commonpath([root, path]) != root:
raise ValueError('文件必须位于工作区内')
with open(path, 'r+', encoding='utf-8', newline='') as f:
fcntl.flock(f, fcntl.LOCK_EX)
text = f.read()
count = text.count(p['old'])
if count == 0:
raise ValueError('未找到原文,请重新读取文件后编辑')
if count > 1 and not p['all']:
raise ValueError('原文出现多次,请扩大匹配范围或使用 replace_all')
updated = text.replace(p['old'], p['new'], -1 if p['all'] else 1)
f.seek(0)
f.write(updated)
f.truncate()
print(json.dumps({'success': True, 'path': p['path'], 'replacements': count if p['all'] else 1}, ensure_ascii=False))
except (OSError, UnicodeError, ValueError) as e:
print(json.dumps({'success': False, 'error': str(e)}, ensure_ascii=False))
PY
""".formatted(payload);
ExecuteResponse result = filesystem.execute(context, command, 30);
if (!result.isSuccess() || result.truncated()) return failure("编辑命令失败:" + result.output());
try {
return mapper.readValue(result.output(), new tools.jackson.core.type.TypeReference<Map<String, Object>>() { });
} catch (RuntimeException exception) {
throw new IllegalStateException("无法解析编辑工具结果", exception);
}
}
private static boolean relativePath(String path) {
if (path == null || path.isBlank() || path.startsWith("~")) return false;
try {
Path value = Path.of(path);
return !value.isAbsolute() && !value.normalize().startsWith("..");
} catch (java.nio.file.InvalidPathException exception) {
return false;
}
}
private static Map<String, Object> failure(String message) {
return Map.of("success", false, "error", message);
}
static String quote(String value) {
return "'" + value.replace("'", "'\\''") + "'";
}
}

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.entity;
package tech.easyflow.manuagent.agent.skill;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
@@ -6,7 +6,7 @@ import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
import java.time.OffsetDateTime;
import java.util.UUID;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler;
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/**
* 映射应用自管的 {@code app.skill_config}

View File

@@ -1,13 +1,14 @@
package tech.easyflow.manuagent.mapper;
package tech.easyflow.manuagent.agent.skill;
import com.mybatisflex.core.BaseMapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.SkillConfigEntity;
import tech.easyflow.manuagent.agent.skill.SkillConfigEntity;
/**
* 提供应用 Skill 配置 CRUD 及对 AgentScope Skill 元数据的只读联查
*/
@org.apache.ibatis.annotations.Mapper
public interface SkillConfigMapper extends BaseMapper<SkillConfigEntity> {
/** 列出全部 Skill 联合视图。 */

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.skill;
package tech.easyflow.manuagent.agent.skill;
import tech.easyflow.manuagent.common.ApiException;
import io.agentscope.core.skill.AgentSkill;

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.config;
package tech.easyflow.manuagent.agent.skill;
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
import javax.sql.DataSource;

View File

@@ -1,19 +1,17 @@
package tech.easyflow.manuagent.skill;
package tech.easyflow.manuagent.agent.skill;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties;
import tech.easyflow.manuagent.entity.SkillConfigEntity;
import tech.easyflow.manuagent.mapper.SkillConfigMapper;
import tech.easyflow.manuagent.mapper.SkillViewRow;
import tech.easyflow.manuagent.agent.config.AgentProperties;
import tech.easyflow.manuagent.agent.skill.SkillConfigEntity;
import tech.easyflow.manuagent.agent.skill.SkillConfigMapper;
import tech.easyflow.manuagent.agent.skill.SkillViewRow;
import io.agentscope.core.skill.AgentSkill;
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.Principal;
import java.time.OffsetDateTime;
import java.util.Comparator;
import java.util.List;
@@ -38,8 +36,7 @@ public class SkillService {
private final SkillConfigMapper skillMapper;
private final PostgresSkillRepository repository;
private final SkillPackageReader packageReader;
private final UserService userService;
private final AppProperties properties;
private final AgentProperties properties;
/**
* 创建 Skill 服务
@@ -47,19 +44,16 @@ public class SkillService {
* @param skillMapper 应用 Skill 配置 Mapper
* @param repository AgentScope PostgreSQL 仓库
* @param packageReader Skill 包读取器
* @param userService 用户服务
* @param properties 应用配置
*/
public SkillService(
SkillConfigMapper skillMapper,
PostgresSkillRepository repository,
SkillPackageReader packageReader,
UserService userService,
AppProperties properties) {
AgentProperties properties) {
this.skillMapper = skillMapper;
this.repository = repository;
this.packageReader = packageReader;
this.userService = userService;
this.properties = properties;
}
@@ -147,11 +141,11 @@ public class SkillService {
* 导入管理员上传的标准 Skill ZIP
*
* @param file ZIP 文件
* @param principal 当前用户
* @param userId 当前操作者 ID
* @return 导入后的 Skill
*/
@Transactional
public SkillView importZip(MultipartFile file, Principal principal) {
public SkillView importZip(MultipartFile file, UUID userId) {
if (file.isEmpty()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_ZIP_EMPTY", "Skill 压缩包为空");
}
@@ -168,7 +162,7 @@ public class SkillService {
throw new ApiException(HttpStatus.CONFLICT, "SKILL_NAME_CONFLICT", "同名 Skill 已存在");
}
repository.save(List.of(skillPackage.skill()), false);
UUID userId = userService.requireUserId(principal.getName());
SkillConfigEntity config = new SkillConfigEntity();
config.setSkillName(name);
config.setVersion(skillPackage.version());

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.mapper;
package tech.easyflow.manuagent.agent.skill;
import java.time.OffsetDateTime;

View File

@@ -2,19 +2,19 @@
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="tech.easyflow.manuagent.mapper.AgentEventMapper">
<mapper namespace="tech.easyflow.manuagent.agent.runtime.AgentEventMapper">
<!-- 显式结果映射避免 payload 列与实体 payloadJson 属性名称不同而丢失事件负载。 -->
<resultMap id="agentEventResultMap" type="tech.easyflow.manuagent.entity.AgentEventEntity">
<resultMap id="agentEventResultMap" type="tech.easyflow.manuagent.agent.runtime.AgentEventEntity">
<id property="id" column="id"/>
<result property="projectId" column="project_id"
typeHandler="tech.easyflow.manuagent.typehandler.UuidTypeHandler"/>
typeHandler="tech.easyflow.manuagent.common.typehandler.UuidTypeHandler"/>
<result property="runId" column="run_id"
typeHandler="tech.easyflow.manuagent.typehandler.UuidTypeHandler"/>
typeHandler="tech.easyflow.manuagent.common.typehandler.UuidTypeHandler"/>
<result property="eventType" column="event_type"/>
<result property="eventId" column="event_id"/>
<result property="payloadJson" column="payload"
typeHandler="tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler"/>
typeHandler="tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler"/>
<result property="createdAt" column="created_at"/>
</resultMap>
@@ -25,12 +25,12 @@
<select id="insertReturning" resultMap="agentEventResultMap" affectData="true" flushCache="true">
INSERT INTO app.agent_event(project_id, run_id, event_type, event_id, payload)
VALUES (
#{event.projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
#{event.runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
#{event.projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler},
#{event.runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler},
#{event.eventType},
#{event.eventId},
#{event.payloadJson, jdbcType=OTHER,
typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler})
typeHandler=tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler})
<!-- 与迁移前 JDBC 返回字段一致event_id 已完成持久化,但无需再次回传给业务层。 -->
RETURNING id, project_id, run_id, event_type, payload, created_at
</select>
@@ -39,7 +39,7 @@
<select id="selectLatestStartedPhase" resultType="string">
SELECT payload -&gt;&gt; 'phase'
FROM app.agent_event
WHERE run_id = #{runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE run_id = #{runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
AND event_type = 'RUN_STARTED'
ORDER BY id DESC
LIMIT 1
@@ -48,7 +48,7 @@
<select id="selectLatestMaterialResponseJson" resultType="string">
SELECT payload::text
FROM app.agent_event
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
AND event_type = 'ASK_RESPONDED'
AND jsonb_typeof(payload -&gt; 'decisions') = 'array'
ORDER BY id DESC

View File

@@ -2,13 +2,13 @@
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="tech.easyflow.manuagent.mapper.AgentRunMapper">
<mapper namespace="tech.easyflow.manuagent.agent.runtime.AgentRunMapper">
<!-- 以下更新均将“当前状态”写进 WHERE更新行数就是状态机竞争结果。 -->
<update id="completeWaiting">
UPDATE app.agent_run
SET status = 'COMPLETED', pending_interrupt = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
AND status = 'WAITING_INPUT'
</update>
@@ -17,7 +17,7 @@
SET status = 'INTERRUPTED', pending_interrupt = NULL,
error_code = 'USER_STOPPED', error_message = '用户已停止运行',
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
AND status = 'RUNNING'
</update>
@@ -25,9 +25,9 @@
UPDATE app.agent_run
SET status = 'WAITING_INPUT',
pending_interrupt = #{interruptJson, jdbcType=OTHER,
typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler},
typeHandler=tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler},
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
AND status = 'RUNNING'
</update>
@@ -35,7 +35,7 @@
UPDATE app.agent_run
SET status = 'COMPLETED', pending_interrupt = NULL,
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
AND status = 'RUNNING'
</update>
@@ -44,7 +44,7 @@
SET status = 'FAILED', pending_interrupt = NULL,
error_code = 'AGENT_RUN_FAILED', error_message = #{message},
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
AND status = 'RUNNING'
</update>

View File

@@ -2,26 +2,26 @@
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="tech.easyflow.manuagent.mapper.ArtifactMapper">
<mapper namespace="tech.easyflow.manuagent.agent.artifact.ArtifactMapper">
<!--
PostgreSQL 的 INSERT ... RETURNING 属于会修改数据的查询语句。
affectData 与 flushCache 确保 MyBatis 按 DML 事务语义处理并清理一级缓存。
-->
<select id="upsert"
resultType="tech.easyflow.manuagent.entity.ArtifactEntity"
resultType="tech.easyflow.manuagent.agent.artifact.ArtifactEntity"
affectData="true"
flushCache="true">
INSERT INTO app.artifact(
id, project_id, run_id, kind, name, relative_path, mime_type,
size_bytes, sha256, metadata_json)
VALUES (
#{artifact.id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
#{artifact.projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
#{artifact.runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
#{artifact.id, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler},
#{artifact.projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler},
#{artifact.runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler},
#{artifact.kind}, #{artifact.name}, #{artifact.relativePath}, #{artifact.mimeType},
#{artifact.sizeBytes}, #{artifact.sha256},
#{artifact.metadataJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler})
#{artifact.metadataJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler})
ON CONFLICT (project_id, relative_path) DO UPDATE SET
run_id = EXCLUDED.run_id,
kind = EXCLUDED.kind,

View File

@@ -1,15 +1,15 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="tech.easyflow.manuagent.mapper.ModelAssignmentMapper">
<mapper namespace="tech.easyflow.manuagent.agent.model.ModelAssignmentMapper">
<!-- 角色为主键,单语句 upsert 避免并发设置默认模型时出现先查后写竞态。 -->
<insert id="upsert">
INSERT INTO app.model_assignment(role, model_config_id, assigned_by)
VALUES (
#{assignment.role},
#{assignment.modelConfigId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
#{assignment.assignedBy, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler})
#{assignment.modelConfigId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler},
#{assignment.assignedBy, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler})
ON CONFLICT (role) DO UPDATE SET
model_config_id = EXCLUDED.model_config_id,
assigned_by = EXCLUDED.assigned_by,
@@ -19,6 +19,6 @@
<delete id="deleteByModelConfigId">
DELETE FROM app.model_assignment
WHERE model_config_id = #{modelConfigId,
typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
</delete>
</mapper>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="tech.easyflow.manuagent.mapper.ModelConfigMapper">
<mapper namespace="tech.easyflow.manuagent.agent.model.ModelConfigMapper">
<!--
JSONB 参数必须显式使用 JsonbStringTypeHandler。这样即使 Lambda Wrapper 在 Spring 初始化前
@@ -13,7 +13,7 @@
api_key_ciphertext, api_key_hint, key_version,
config_json, capabilities_json, enabled, is_default, created_by)
VALUES (
#{model.id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
#{model.id, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler},
#{model.name},
#{model.provider},
#{model.baseUrl},
@@ -21,11 +21,11 @@
#{model.apiKeyCiphertext},
#{model.apiKeyHint},
#{model.keyVersion},
#{model.configJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler},
#{model.capabilitiesJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler},
#{model.configJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler},
#{model.capabilitiesJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler},
COALESCE(#{model.enabled}, TRUE),
COALESCE(#{model.defaultModel}, FALSE),
#{model.createdBy, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler})
#{model.createdBy, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler})
</insert>
<!--
@@ -36,15 +36,15 @@
SET name = #{model.name},
base_url = #{model.baseUrl},
model_id = #{model.modelId},
config_json = #{model.configJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler},
capabilities_json = #{model.capabilitiesJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler},
config_json = #{model.configJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler},
capabilities_json = #{model.capabilitiesJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler},
<if test="model.apiKeyCiphertext != null">
api_key_ciphertext = #{model.apiKeyCiphertext},
api_key_hint = #{model.apiKeyHint},
key_version = #{model.keyVersion},
</if>
updated_at = CURRENT_TIMESTAMP
WHERE id = #{model.id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE id = #{model.id, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
</update>
<!-- 以下两条语句保持迁移前的执行顺序和条件,不额外引入模型启用状态判断。 -->
@@ -58,18 +58,18 @@
UPDATE app.model_config
SET is_default = TRUE,
updated_at = CURRENT_TIMESTAMP
WHERE id = #{id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE id = #{id, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
</update>
<update id="setEnabled">
UPDATE app.model_config
SET enabled = #{enabled},
updated_at = CURRENT_TIMESTAMP
WHERE id = #{id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE id = #{id, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
</update>
<delete id="deleteModel">
DELETE FROM app.model_config
WHERE id = #{id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE id = #{id, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
</delete>
</mapper>

View File

@@ -1,13 +1,13 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="tech.easyflow.manuagent.mapper.ProjectMapper">
<mapper namespace="tech.easyflow.manuagent.agent.project.ProjectMapper">
<!-- 项目删除前必须先阻止仍在运行的任务。 -->
<select id="hasRunningRun" resultType="boolean">
SELECT EXISTS(
SELECT 1 FROM app.agent_run
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
AND status = 'RUNNING'
)
</select>
@@ -15,28 +15,28 @@
<!-- 以下删除顺序与外键依赖顺序一致,并由 ProjectService 的 Spring 事务统一提交或回滚。 -->
<delete id="deleteEvents">
DELETE FROM app.agent_event
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
</delete>
<delete id="deleteArtifacts">
DELETE FROM app.artifact
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
</delete>
<delete id="deletePlans">
DELETE FROM app.project_plan
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
</delete>
<delete id="deleteFiles">
DELETE FROM app.project_file
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
</delete>
<delete id="deleteRuns">
DELETE FROM app.agent_run
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
</delete>
<update id="updateStatus">
UPDATE app.project
SET status = #{status}, version = version + 1, updated_at = CURRENT_TIMESTAMP
WHERE id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
</update>
</mapper>

View File

@@ -1,49 +1,49 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="tech.easyflow.manuagent.mapper.ProjectPlanMapper">
<mapper namespace="tech.easyflow.manuagent.agent.project.ProjectPlanMapper">
<!--
版本号计算与写入保持在同一条 PostgreSQL 语句内;唯一约束继续作为并发冲突的最终保护。
-->
<select id="insertNextDraft"
resultType="tech.easyflow.manuagent.entity.ProjectPlanEntity"
resultType="tech.easyflow.manuagent.agent.project.ProjectPlanEntity"
affectData="true"
flushCache="true">
INSERT INTO app.project_plan(id, project_id, plan_version, status, plan_json, created_by)
SELECT
#{plan.id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
#{plan.projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
#{plan.id, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler},
#{plan.projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler},
COALESCE(MAX(plan_version), 0) + 1,
'DRAFT',
#{plan.planJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler},
#{plan.createdBy, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
#{plan.planJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler},
#{plan.createdBy, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
FROM app.project_plan
WHERE project_id = #{plan.projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE project_id = #{plan.projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
RETURNING id, project_id, plan_version, status, plan_json, confirmed_at, created_at
</select>
<select id="selectCurrent" resultType="tech.easyflow.manuagent.entity.ProjectPlanEntity">
<select id="selectCurrent" resultType="tech.easyflow.manuagent.agent.project.ProjectPlanEntity">
SELECT id, project_id, plan_version, status, plan_json, confirmed_at, created_at
FROM app.project_plan
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
ORDER BY CASE status WHEN 'CONFIRMED' THEN 0 ELSE 1 END, plan_version DESC
LIMIT 1
</select>
<!-- 条件更新和 RETURNING 在同一语句中完成,避免确认状态检查与写入之间出现竞态。 -->
<select id="confirmDraft"
resultType="tech.easyflow.manuagent.entity.ProjectPlanEntity"
resultType="tech.easyflow.manuagent.agent.project.ProjectPlanEntity"
affectData="true"
flushCache="true">
UPDATE app.project_plan
SET status = 'CONFIRMED',
plan_json = #{planJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler},
confirmed_by = #{userId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
plan_json = #{planJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler},
confirmed_by = #{userId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler},
confirmed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = #{planId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
AND project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
WHERE id = #{planId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
AND project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
AND status = 'DRAFT'
RETURNING id, project_id, plan_version, status, plan_json, confirmed_at, created_at
</select>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="tech.easyflow.manuagent.mapper.SkillConfigMapper">
<mapper namespace="tech.easyflow.manuagent.agent.skill.SkillConfigMapper">
<!-- AgentScope 表严格只读;应用只在 app.skill_config 保存启停、来源和校验状态。 -->
<sql id="skillViewColumns">
@@ -9,14 +9,14 @@
c.validation_status, c.validation_message, c.updated_at
</sql>
<select id="selectViews" resultType="tech.easyflow.manuagent.mapper.SkillViewRow">
<select id="selectViews" resultType="tech.easyflow.manuagent.agent.skill.SkillViewRow">
SELECT <include refid="skillViewColumns"/>
FROM agentscope.agentscope_skills s
JOIN app.skill_config c ON c.skill_name = s.name
ORDER BY c.source_type, s.name
</select>
<select id="selectView" resultType="tech.easyflow.manuagent.mapper.SkillViewRow">
<select id="selectView" resultType="tech.easyflow.manuagent.agent.skill.SkillViewRow">
SELECT <include refid="skillViewColumns"/>
FROM agentscope.agentscope_skills s
JOIN app.skill_config c ON c.skill_name = s.name

View File

@@ -20,6 +20,8 @@
# 自主执行
生成 DOCX 前,读取并运行 `/opt/sandbox/docx-example.cjs`,将验证文件写入 `work/tmp/`;复用其中与当前 docx 版本匹配的表格、段落和原生批注 API。`comments` 必须是带 `children` 的对象,表格行的 `children` 必须是一维 TableCell 数组。需要核对其他 API 时读取 `/opt/sandbox/node_modules/docx/dist/index.d.ts`,不要猜测构造参数,也不要通过 `require('docx/package.json')` 获取版本。先用 `node --check` 检查脚本语法,再生成并验证 DOCX。必要步骤失败时立即修复不能用管道尾部成功或 `|| true` 掩盖失败。
1. 先递归查看 `inputs/`,保留并利用上传目录、原文件名和材料分类之间的语义关系;同名文件必须结合完整相对路径判断来源。
2. 主动选择与文件类型相符的 PDF、PPTX、XLS/XLSX、DOCX 等文档 Skill先读取 Skill 的完整 `SKILL.md`,再按其方法做结构化读取。不得只凭文件名推断正文。
3. `document_view` 是按需视觉补充工具,不是默认步骤。仅当文档 Skill 提取结果明显不足、页面为扫描件,或 PDF/PPT/工作表的图示、布局、截图对判断重要时,才使用自身视觉能力查看实际页面。由你决定页码、幻灯片、工作表与范围;建议每次最多渲染 5 张,可分批调用。大型工作表应主动拆分 range 查看,无需模拟滚动。

View File

@@ -1,6 +1,6 @@
请将当前会话压缩为可继续执行的结构化工作记忆。必须保留:
使用简体中文将当前会话压缩为可继续执行的结构化工作记忆。必须保留:
1. 企业名称、申报等级,以及 C已确认事实、E外部依据、R规划建议、P待实施、U待确认)边界。
1. 企业名称、申报等级,以及 C企业材料或用户确认的企业事实、E企业公开资料保留来源并标记待企业确认、R政策、标准、行业方法和同行案例仅支撑规划、P基于 R 的未来规划、建议目标和测算假设、U缺失、冲突或无法核实的企业现状)边界。
2. 已确认并冻结的建设规划包括建设方向、场景、KPI、投资区间、建设周期和版本。
3. 材料之间的冲突、全部未解决 U 项、Word 批注要求和事实约束。
4. 已调用 Skill、关键工具结果、已生成或已修改的工作区文件及其校验状态。

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.artifact;
package tech.easyflow.manuagent.agent.artifact;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -13,9 +13,9 @@ import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.entity.ArtifactEntity;
import tech.easyflow.manuagent.mapper.ArtifactMapper;
import tech.easyflow.manuagent.project.ProjectFileService;
import tech.easyflow.manuagent.agent.artifact.ArtifactEntity;
import tech.easyflow.manuagent.agent.artifact.ArtifactMapper;
import tech.easyflow.manuagent.agent.project.ProjectFileService;
/**
* 验证产物查询在 MyBatis-Flex 迁移后保持原 JDBC SQL 的最小字段范围

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.artifact;
package tech.easyflow.manuagent.agent.artifact;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.model;
package tech.easyflow.manuagent.agent.model;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -7,7 +7,7 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
@@ -16,12 +16,11 @@ import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.entity.ModelConfigEntity;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
import tech.easyflow.manuagent.agent.model.ModelConfigEntity;
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
import tech.easyflow.manuagent.agent.model.ModelAssignmentMapper;
import tech.easyflow.manuagent.agent.model.ModelConfigMapper;
/**
* 验证模型草稿连接测试的密钥选择和外部请求边界
@@ -39,9 +38,8 @@ class ModelServiceConnectionTest {
context.registerBean(ModelConfigMapper.class, () -> mock(ModelConfigMapper.class));
context.registerBean(ModelAssignmentMapper.class, () -> mock(ModelAssignmentMapper.class));
context.registerBean(AgentRunMapper.class, () -> mock(AgentRunMapper.class));
context.registerBean(UserService.class, () -> mock(UserService.class));
context.registerBean(KeyCipher.class, () -> mock(KeyCipher.class));
context.registerBean(ObjectMapper.class, () -> new ObjectMapper());
context.registerBean(ObjectMapper.class, () -> tools.jackson.databind.json.JsonMapper.builder().build());
context.register(ModelService.class);
context.refresh();
@@ -164,9 +162,8 @@ class ModelServiceConnectionTest {
modelMapper,
mock(ModelAssignmentMapper.class),
mock(AgentRunMapper.class),
mock(UserService.class),
keyCipher,
new ObjectMapper(),
keyCipher,
tools.jackson.databind.json.JsonMapper.builder().build(),
httpClient);
}

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.model;
package tech.easyflow.manuagent.agent.model;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@@ -6,7 +6,7 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import java.time.OffsetDateTime;
import java.util.List;
@@ -14,13 +14,11 @@ import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.boot.ApplicationRunner;
import org.mockito.ArgumentCaptor;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.config.AppProperties;
import tech.easyflow.manuagent.entity.ModelConfigEntity;
import tech.easyflow.manuagent.mapper.AppUserMapper;
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.agent.config.AgentProperties;
import tech.easyflow.manuagent.agent.model.ModelConfigEntity;
import tech.easyflow.manuagent.agent.model.ModelAssignmentMapper;
import tech.easyflow.manuagent.agent.model.ModelConfigMapper;
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
/**
* 验证模型管理接口使用最小字段投影不把加密 API Key 读入普通请求内存
@@ -47,9 +45,8 @@ class ModelServiceQueryTest {
modelMapper,
mock(ModelAssignmentMapper.class),
mock(AgentRunMapper.class),
mock(UserService.class),
mock(KeyCipher.class),
new ObjectMapper());
tools.jackson.databind.json.JsonMapper.builder().build());
List<ModelService.ModelView> models = service.list();

View File

@@ -1,11 +1,11 @@
package tech.easyflow.manuagent.model;
package tech.easyflow.manuagent.agent.model;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectMapper;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import java.util.Map;
@@ -13,11 +13,10 @@ import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
import tech.easyflow.manuagent.agent.model.ModelAssignmentMapper;
import tech.easyflow.manuagent.agent.model.ModelConfigMapper;
/**
* 验证模型配置在进入数据库和外部 HTTP 客户端之前具有明确可审计的输入边界
@@ -67,7 +66,7 @@ class ModelServiceValidationTest {
Map.of(),
Map.of("contextWindow", 8_192));
assertThatThrownBy(() -> service.save(null, input, () -> "admin"))
assertThatThrownBy(() -> service.save(null, input, UUID.randomUUID()))
.as("地址应被拒绝:%s", baseUrl)
.isInstanceOfSatisfying(ApiException.class, exception ->
assertThat(exception.code()).isEqualTo("MODEL_BASE_URL_INVALID"));
@@ -80,14 +79,12 @@ class ModelServiceValidationTest {
* @return 配置了当前用户的模型服务
*/
private ModelService service() {
UserService userService = mock(UserService.class);
when(userService.requireUserId("admin")).thenReturn(UUID.randomUUID());
return new ModelService(
mock(ModelConfigMapper.class),
mock(ModelAssignmentMapper.class),
mock(AgentRunMapper.class),
userService,
mock(KeyCipher.class),
new ObjectMapper());
tools.jackson.databind.json.JsonMapper.builder().build());
}
}

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.project;
package tech.easyflow.manuagent.agent.project;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -8,11 +8,10 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties;
import tech.easyflow.manuagent.entity.ProjectFileEntity;
import tech.easyflow.manuagent.mapper.ProjectFileMapper;
import tech.easyflow.manuagent.agent.config.AgentProperties;
import tech.easyflow.manuagent.agent.project.ProjectFileEntity;
import tech.easyflow.manuagent.agent.project.ProjectFileMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
@@ -37,12 +36,11 @@ class ProjectFileServiceTest {
*/
@Test
void shouldDeleteProjectWorkspace() throws Exception {
AppProperties properties = new AppProperties(
AgentProperties properties = new AgentProperties(
temporaryDirectory, Path.of("dashscope"),
"test-master", "admin", "admin",
"runtime:test", "bridge", Duration.ofMinutes(1));
"test-master", "runtime:test", "bridge", Duration.ofMinutes(1));
ProjectFileService service = new ProjectFileService(
mock(ProjectFileMapper.class), mock(UserService.class), mock(ProjectService.class), properties);
mock(ProjectFileMapper.class), mock(ProjectService.class), properties);
UUID projectId = UUID.randomUUID();
Path file = service.projectRoot(projectId).resolve("inputs/company.txt");
Files.createDirectories(file.getParent());
@@ -92,7 +90,7 @@ class ProjectFileServiceTest {
file.setSizeBytes(1L);
when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(file));
ProjectFileService service = new ProjectFileService(
mapper, mock(UserService.class), projectService, properties());
mapper, projectService, properties());
service.list(file.getProjectId());
@@ -111,7 +109,7 @@ class ProjectFileServiceTest {
*/
private ProjectFileService service() {
return new ProjectFileService(
mock(ProjectFileMapper.class), mock(UserService.class), mock(ProjectService.class), properties());
mock(ProjectFileMapper.class), mock(ProjectService.class), properties());
}
/**
@@ -119,10 +117,9 @@ class ProjectFileServiceTest {
*
* @return 指向临时数据目录的配置
*/
private AppProperties properties() {
return new AppProperties(
private AgentProperties properties() {
return new AgentProperties(
temporaryDirectory, Path.of("dashscope"),
"test-master", "admin", "admin",
"runtime:test", "bridge", Duration.ofMinutes(1));
"test-master", "runtime:test", "bridge", Duration.ofMinutes(1));
}
}

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.project;
package tech.easyflow.manuagent.agent.project;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -7,18 +7,17 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.entity.ProjectEntity;
import tech.easyflow.manuagent.entity.ProjectPlanEntity;
import tech.easyflow.manuagent.agent.project.ProjectEntity;
import tech.easyflow.manuagent.agent.project.ProjectPlanEntity;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.mapper.ProjectMapper;
import tech.easyflow.manuagent.mapper.ProjectPlanMapper;
import tech.easyflow.manuagent.agent.project.ProjectMapper;
import tech.easyflow.manuagent.agent.project.ProjectPlanMapper;
/**
* 验证项目查询在 MyBatis-Flex 迁移后保持原 JDBC SQL 的字段范围
@@ -72,7 +71,7 @@ class ProjectServiceQueryTest {
plan.setPlanJson("{invalid-json");
when(planMapper.selectCurrent(any(UUID.class))).thenReturn(plan);
ProjectService service = new ProjectService(
mock(ProjectMapper.class), planMapper, mock(UserService.class), new ObjectMapper());
mock(ProjectMapper.class), planMapper, tools.jackson.databind.json.JsonMapper.builder().build());
assertThatThrownBy(() -> service.currentPlan(UUID.randomUUID()))
.isInstanceOf(IllegalStateException.class)
@@ -101,8 +100,7 @@ class ProjectServiceQueryTest {
return new ProjectService(
mapper,
mock(ProjectPlanMapper.class),
mock(UserService.class),
new ObjectMapper());
tools.jackson.databind.json.JsonMapper.builder().build());
}
/**

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.agent;
package tech.easyflow.manuagent.agent.runtime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@@ -6,14 +6,14 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import tech.easyflow.manuagent.entity.AgentEventEntity;
import tech.easyflow.manuagent.mapper.AgentEventMapper;
import tech.easyflow.manuagent.agent.runtime.AgentEventEntity;
import tech.easyflow.manuagent.agent.runtime.AgentEventMapper;
/**
* 验证 Agent 事件回放查询在 ORM 迁移后保持原 JDBC 字段和游标语义
@@ -31,7 +31,7 @@ class AgentEventServiceQueryTest {
event.setProjectId(UUID.randomUUID());
event.setPayloadJson("{}");
when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(event));
AgentEventService service = new AgentEventService(mapper, new ObjectMapper());
AgentEventService service = new AgentEventService(mapper, tools.jackson.databind.json.JsonMapper.builder().build());
service.listAfter(event.getProjectId(), 0L, 100);

View File

@@ -0,0 +1,135 @@
package tech.easyflow.manuagent.agent.runtime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import tools.jackson.databind.ObjectMapper;
import io.agentscope.core.agui.adapter.AguiAgentAdapter;
import io.agentscope.core.agui.event.AguiEvent;
import java.nio.file.Path;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import tech.easyflow.manuagent.agent.project.ProjectFileService;
import tech.easyflow.manuagent.agent.project.ProjectService;
import tech.easyflow.manuagent.agent.skill.SkillService;
/**
* 验证 Agent 事件持久化的精简规则。
*/
class AgentExecutionServiceTest {
/**
* Run 在创建时已经固化模型配置 ID执行和模型重连都必须沿用该 ID
* 不能在工厂内部重新读取可能已经变化的全局默认模型。
*/
@Test
void shouldCreateAgentWithModelBoundToRun() {
UUID projectId = UUID.randomUUID();
UUID runId = UUID.randomUUID();
UUID modelId = UUID.randomUUID();
AgentFactory factory = mock(AgentFactory.class);
AgentFactory.AgentHandle handle = mock(AgentFactory.AgentHandle.class);
AguiAgentAdapter adapter = mock(AguiAgentAdapter.class);
SkillService skillService = mock(SkillService.class);
when(handle.adapter()).thenReturn(adapter);
when(adapter.run(any())).thenReturn(Flux.empty());
when(skillService.enabledNames()).thenReturn(new String[] {"document"});
when(factory.create(eq(projectId), eq(modelId), any(String[].class))).thenReturn(handle);
ProjectFileService files = mock(ProjectFileService.class);
when(files.projectRoot(projectId)).thenReturn(Path.of("/tmp/project"));
AgentExecutionService service = new AgentExecutionService(
tools.jackson.databind.json.JsonMapper.builder().build(),
factory,
mock(AgentEventService.class),
files,
skillService, new tech.easyflow.manuagent.agent.config.AgentProperties(
Path.of("/tmp"), null, "test", "runtime", "bridge", java.time.Duration.ofMinutes(1)));
ProjectService.ProjectView project = mock(ProjectService.ProjectView.class);
when(project.id()).thenReturn(projectId);
when(project.threadId()).thenReturn("project-" + projectId);
AgentRunService.RunView run = new AgentRunService.RunView(
runId, projectId, modelId, "INITIAL", "RUNNING", null, null, java.time.OffsetDateTime.now(), null);
service.execute(project, run, "执行测试", Mono.never(), () -> { }, () -> false);
verify(factory).create(eq(projectId), eq(modelId), any(String[].class));
when(adapter.run(any())).thenReturn(Flux.just(new AguiEvent.RunError(
"project-" + projectId, runId.toString(), "sandbox failed: /tmp/project sk-testsecret123", "AGENT_ERROR")));
assertThatThrownBy(() -> service.execute(project, run, "执行测试", Mono.never(), () -> { }, () -> false))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("sandbox failed")
.hasMessageNotContaining("/tmp/project")
.hasMessageNotContaining("sk-testsecret123");
verify(factory, times(2)).create(eq(projectId), eq(modelId), any(String[].class));
}
/**
* 验证文档视觉结果保留图片路径元数据并丢弃 Base64 正文。
*/
@Test
void shouldStripInlineImageDataFromPersistedToolResult() {
String content = """
document_view_result={"images":[{"path":"work/tmp/document-view/a/render-1.png"}]}
{"type":"image","source":{"media_type":"image/png","data":"very-large-base64"}}
""";
String result = AgentExecutionService.stripInlineImageData(content);
assertThat(result).contains("render-1.png");
assertThat(result).doesNotContain("very-large-base64");
}
@Test
void shouldRetryConvertedModelErrorsAndEnforceTotalDeadlineEvenWhileStreaming() {
var factory = mock(AgentFactory.class);
var handle = mock(AgentFactory.AgentHandle.class);
var adapter = mock(AguiAgentAdapter.class);
var runtime = mock(AgentRuntimeMiddleware.class);
when(handle.adapter()).thenReturn(adapter);
when(handle.runtime()).thenReturn(runtime);
when(factory.create(any(), any(), any())).thenReturn(handle);
var skills = mock(SkillService.class);
when(skills.enabledNames()).thenReturn(new String[0]);
var files = mock(ProjectFileService.class);
when(files.projectRoot(any())).thenReturn(Path.of("/tmp/project"));
var events = mock(AgentEventService.class);
var properties = new tech.easyflow.manuagent.agent.config.AgentProperties(
Path.of("/tmp"), null, "test", "runtime", "bridge", java.time.Duration.ofSeconds(5));
var service = new AgentExecutionService(tools.jackson.databind.json.JsonMapper.builder().build(),
factory, events, files, skills, properties);
var project = mock(ProjectService.ProjectView.class);
var projectId = UUID.randomUUID();
when(project.id()).thenReturn(projectId);
when(project.threadId()).thenReturn("test");
var run = new AgentRunService.RunView(UUID.randomUUID(), projectId, UUID.randomUUID(),
"INITIAL", "RUNNING", null, null, java.time.OffsetDateTime.now(), null);
when(runtime.modelFailure()).thenReturn(new io.agentscope.core.model.transport.HttpTransportException("lost", 503, ""));
when(adapter.run(any())).thenReturn(Flux.just(new AguiEvent.RunError("test", "run", "lost", "INTERNAL_ERROR")))
.thenReturn(Flux.empty());
service.execute(project, run, "开始", Mono.never(), () -> { }, () -> false);
verify(factory, times(2)).create(any(), any(), any());
verify(events).append(eq(projectId), eq(run.id()), eq("MODEL_RETRY"), any());
var shortService = new AgentExecutionService(tools.jackson.databind.json.JsonMapper.builder().build(),
factory, events, files, skills, new tech.easyflow.manuagent.agent.config.AgentProperties(
Path.of("/tmp"), null, "test", "runtime", "bridge", java.time.Duration.ofMillis(250)));
var shortRun = new AgentRunService.RunView(UUID.randomUUID(), projectId, UUID.randomUUID(),
"INITIAL", "RUNNING", null, null, java.time.OffsetDateTime.now(), null);
when(adapter.run(any())).thenReturn(Flux.interval(java.time.Duration.ofMillis(20))
.map(i -> new AguiEvent.RunStarted("test", "run")));
assertThatThrownBy(() -> shortService.execute(project, shortRun, "开始", Mono.never(), () -> { }, () -> false))
.isInstanceOf(tech.easyflow.manuagent.common.ApiException.class).hasMessageContaining("运行时限");
verify(handle, times(3)).close();
assertThatThrownBy(() -> shortService.remainingTime(shortRun)).hasMessageContaining("运行时限");
}
}

View File

@@ -0,0 +1,326 @@
package tech.easyflow.manuagent.agent.runtime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.springframework.test.util.ReflectionTestUtils.invokeMethod;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
import io.agentscope.core.model.transport.HttpTransportException;
import io.agentscope.core.skill.AgentSkill;
import java.nio.file.Path;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.RejectedExecutionException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import tech.easyflow.manuagent.agent.artifact.ArtifactService;
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
import tech.easyflow.manuagent.agent.project.ProjectFileService;
import tech.easyflow.manuagent.agent.project.ProjectService;
/**
* 验证 Agent Run 的模型重连边界。
*/
class AgentRunServiceTest {
@Test
void shouldRepairMalformedJsonInBothAgentOutputPaths() throws Exception {
UUID projectId = UUID.randomUUID();
var mapper = tools.jackson.databind.json.JsonMapper.builder().build();
tools.jackson.core.JacksonException malformed;
try {
mapper.readTree("{invalid");
throw new AssertionError("Malformed JSON accepted");
} catch (tools.jackson.core.JacksonException exception) {
malformed = exception;
}
var repaired = mapper.createObjectNode().put("summary", "已修复");
var output = mock(AgentOutputService.class);
when(output.readMaterialCheck(projectId)).thenThrow(malformed).thenReturn(repaired);
when(output.readProposedPlan(projectId)).thenThrow(malformed).thenReturn(repaired);
var execution = mock(AgentExecutionService.class);
var store = mock(AgentRunStore.class);
var project = mock(ProjectService.ProjectView.class);
when(project.id()).thenReturn(projectId);
var run = new AgentRunService.RunView(UUID.randomUUID(), projectId, UUID.randomUUID(),
"INITIAL", "RUNNING", null, null, OffsetDateTime.now(), null);
var service = new AgentRunService(mock(AgentRunMapper.class), mapper, execution, output,
store, mock(AgentEventService.class), mock(ProjectService.class),
mock(ProjectFileService.class), mock(ArtifactService.class),
mock(ExecutorService.class), mock(TransactionTemplate.class), 2);
for (String method : java.util.List.of("readMaterialCheckWithRepair", "readProposedPlanWithRepair")) {
Object result = org.springframework.test.util.ReflectionTestUtils.invokeMethod(service, method, project, run);
assertThat(result).isSameAs(repaired);
}
verify(execution, org.mockito.Mockito.times(2)).execute(
org.mockito.ArgumentMatchers.eq(project), org.mockito.ArgumentMatchers.eq(run),
org.mockito.ArgumentMatchers.contains("修复"), org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
verify(store, org.mockito.Mockito.times(2)).ensureRunning(run.id());
}
/**
* 运行中模型配置被编辑后,用户应能选择同一模型 ID 创建新的恢复 Run
* 使新 Agent 客户端重新读取数据库中的最新地址、模型标识和密钥。
*/
@ParameterizedTest
@ValueSource(ints = {1, 2})
void shouldRestartRunningTaskWhenSameModelConfigurationWasUpdatedAtCapacity(int limit) {
UUID projectId = UUID.randomUUID();
UUID modelId = UUID.randomUUID();
UUID currentRunId = UUID.randomUUID();
UUID replacementRunId = UUID.randomUUID();
OffsetDateTime now = OffsetDateTime.now();
ProjectService.ProjectView project = new ProjectService.ProjectView(
projectId, "测试企业", "测试项目", "thread-1", "ADVANCED", "MATERIAL_CHECK", 0L, now, now);
AgentRunService.RunView current = new AgentRunService.RunView(
currentRunId, projectId, modelId, "INITIAL", "RUNNING", null, null, now, null);
AgentRunService.RunView replacement = new AgentRunService.RunView(
replacementRunId, projectId, modelId, "RESUME", "RUNNING", null, null, now, null);
AgentRunMapper runMapper = mock(AgentRunMapper.class);
AgentRunStore runStore = mock(AgentRunStore.class);
ProjectService projectService = mock(ProjectService.class);
when(projectService.require(projectId)).thenReturn(project);
when(runStore.latest(projectId)).thenReturn(current);
when(runStore.interruptedPhase(current, project)).thenReturn("MATERIAL_CHECK");
when(runMapper.interruptRunning(currentRunId)).thenReturn(1);
when(runStore.create(projectId, "RESUME", currentRunId, modelId)).thenReturn(replacement);
AgentRunService service = new AgentRunService(
runMapper,
tools.jackson.databind.json.JsonMapper.builder().build(),
mock(AgentExecutionService.class),
mock(AgentOutputService.class),
runStore,
mock(AgentEventService.class),
projectService,
mock(ProjectFileService.class),
mock(ArtifactService.class),
mock(ExecutorService.class),
mock(TransactionTemplate.class), limit);
// 服务会注册“提交后取消旧流并启动新流”的回调;测试只验证注册前的事务内状态转换。
TransactionSynchronizationManager.initSynchronization();
try {
for (int i = 0; i < limit; i++) {
invokeMethod(service, "afterCommit", i == 0 ? currentRunId : UUID.randomUUID(), (Runnable) () -> {});
}
assertThat(service.switchModel(projectId, modelId, UUID.randomUUID())).isEqualTo(replacement);
verify(runMapper).interruptRunning(currentRunId);
verify(runStore).create(projectId, "RESUME", currentRunId, modelId);
} finally {
TransactionSynchronizationManager.getSynchronizations().forEach(sync ->
sync.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
TransactionSynchronizationManager.clearSynchronization();
}
}
@Test
void shouldWaitForPreviousTaskCleanupBeforeStartingReplacement() throws Exception {
var queue = new LinkedBlockingQueue<Runnable>();
var executor = mock(ExecutorService.class);
when(executor.submit(any(Runnable.class))).thenAnswer(call -> {
queue.add(call.getArgument(0));
return java.util.concurrent.CompletableFuture.completedFuture(null);
});
var service = schedulingService(executor, mock(AgentRunStore.class));
var previous = UUID.randomUUID();
var replacementStarted = new CountDownLatch(1);
transaction(() -> invokeMethod(service, "afterCommit", previous, (Runnable) () -> {}), true);
Runnable previousTask = queue.remove();
transaction(() -> invokeMethod(service, "afterCommit", UUID.randomUUID(),
(Runnable) replacementStarted::countDown, previous), true);
var replacement = Thread.startVirtualThread(queue.remove());
try {
assertThat(replacementStarted.await(200, TimeUnit.MILLISECONDS)).isFalse();
assertThatThrownBy(() -> transaction(() -> invokeMethod(service, "afterCommit",
UUID.randomUUID(), (Runnable) () -> {}), true)).hasMessageContaining("已达上限");
} finally {
previousTask.run();
replacement.join(5_000);
}
assertThat(replacement.isAlive()).isFalse();
assertThat(replacementStarted.getCount()).isZero();
// 接续完成后恢复一个名额。
transaction(() -> invokeMethod(service, "afterCommit", UUID.randomUUID(), (Runnable) () -> {}), false);
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void shouldRetainPredecessorCapacityWhenReplacementRollsBackOrIsRejected(boolean rejected) {
var executor = mock(ExecutorService.class);
var queue = new LinkedBlockingQueue<Runnable>();
when(executor.submit(any(Runnable.class))).thenAnswer(call -> {
queue.add(call.getArgument(0));
return java.util.concurrent.CompletableFuture.completedFuture(null);
});
var store = mock(AgentRunStore.class);
var previous = UUID.randomUUID();
var replacement = UUID.randomUUID();
when(store.require(replacement)).thenReturn(new AgentRunService.RunView(replacement,
UUID.randomUUID(), UUID.randomUUID(), "RESUME", "RUNNING", null, null, OffsetDateTime.now(), null));
var service = schedulingService(executor, store);
transaction(() -> invokeMethod(service, "afterCommit", previous, (Runnable) () -> {}), true);
Runnable previousTask = queue.remove();
if (rejected) when(executor.submit(any(Runnable.class))).thenThrow(new RejectedExecutionException("test"));
transaction(() -> invokeMethod(service, "afterCommit", replacement, (Runnable) () -> {}, previous), rejected);
assertThatThrownBy(() -> transaction(() -> invokeMethod(service, "afterCommit",
UUID.randomUUID(), (Runnable) () -> {}), false)).hasMessageContaining("已达上限");
previousTask.run();
transaction(() -> invokeMethod(service, "afterCommit", UUID.randomUUID(), (Runnable) () -> {}), false);
}
private static AgentRunService schedulingService(ExecutorService executor, AgentRunStore store) {
return new AgentRunService(mock(AgentRunMapper.class), tools.jackson.databind.json.JsonMapper.builder().build(),
mock(AgentExecutionService.class), mock(AgentOutputService.class), store,
mock(AgentEventService.class), mock(ProjectService.class), mock(ProjectFileService.class),
mock(ArtifactService.class), executor, mock(TransactionTemplate.class), 1);
}
@Test
void shouldShareCapacityWhenRestartingMaterialCheckWhileStoppedRunIsClosing() {
UUID project = UUID.randomUUID();
var previous = new AgentRunService.RunView(UUID.randomUUID(), project, UUID.randomUUID(),
"INITIAL", "INTERRUPTED", null, null, OffsetDateTime.now(), OffsetDateTime.now());
var next = new AgentRunService.RunView(UUID.randomUUID(), project, previous.modelConfigId(),
"INITIAL", "RUNNING", null, null, OffsetDateTime.now(), null);
var store = mock(AgentRunStore.class);
when(store.latest(project)).thenReturn(previous);
when(store.create(project, "INITIAL", null)).thenReturn(next);
var service = schedulingService(mock(ExecutorService.class), store);
transaction(() -> {
invokeMethod(service, "afterCommit", previous.id(), (Runnable) () -> {});
assertThat(service.startMaterialCheck(project, UUID.randomUUID())).isEqualTo(next);
}, false);
}
private static void transaction(Runnable action, boolean commit) {
TransactionSynchronizationManager.initSynchronization();
int status = TransactionSynchronization.STATUS_ROLLED_BACK;
try {
action.run();
if (commit) {
status = TransactionSynchronization.STATUS_COMMITTED;
TransactionSynchronizationManager.getSynchronizations().forEach(TransactionSynchronization::afterCommit);
}
} finally {
for (var sync : TransactionSynchronizationManager.getSynchronizations()) sync.afterCompletion(status);
TransactionSynchronizationManager.clearSynchronization();
}
}
/**
* 网络故障和服务端错误允许重连,参数错误保持原始失败。
*/
@Test
void shouldRetryOnlyRecoverableModelFailures() {
assertThat(AgentExecutionService.MAX_MODEL_RECONNECTS).isEqualTo(5);
assertThat(AgentExecutionService.isRetryableModelFailure(
new RuntimeException(new HttpTransportException("disconnected")))).isTrue();
assertThat(AgentExecutionService.isRetryableModelFailure(
new HttpTransportException("unavailable", 503, ""))).isTrue();
assertThat(AgentExecutionService.isRetryableModelFailure(
new HttpTransportException("invalid request", 400, ""))).isFalse();
assertThat(AgentExecutionService.isRetryableModelFailure(
new IllegalArgumentException("invalid prompt"))).isFalse();
}
/**
* 验证上下文压缩只按模型窗口 90% Token 触发。
*/
@Test
void shouldCompactAtNinetyPercentTokensOnly() {
var config = AgentFactory.compactionFor(100_000, "summary");
assertThat(config.getTriggerTokens()).isEqualTo(90_000);
assertThat(config.getTriggerMessages()).isZero();
assertThat(config.getSummaryPrompt()).isEqualTo("summary");
}
/**
* 验证 Agent 调用 Skill 时使用无来源后缀的名称,并完整保留仓库信息。
*/
@Test
void shouldExposeCanonicalSkillIdWithoutLosingSkillInformation() {
AgentSkill source = new AgentSkill(
Map.of("name", "pdf", "description", "读取 PDF", "version", "1.0"),
"使用说明",
Map.of("references/guide.md", "参考内容"),
"imported",
Path.of("/skills/pdf"));
AgentSkill canonical = AgentFactory.canonicalSkill(source);
assertThat(canonical.getSkillId()).isEqualTo("pdf");
assertThat(canonical.getMetadata()).isEqualTo(source.getMetadata());
assertThat(canonical.getSkillContent()).isEqualTo(source.getSkillContent());
assertThat(canonical.getResources()).isEqualTo(source.getResources());
assertThat(canonical.getSource()).isEqualTo("imported");
assertThat(canonical.getOriginDir()).isEqualTo(source.getOriginDir());
}
/**
* 验证模型输出年份数组时可归一化为前端和确认接口使用的规划年数。
*/
@Test
void shouldNormalizePlanningYearArray() {
ObjectNode plan = tools.jackson.databind.json.JsonMapper.builder().build().createObjectNode();
plan.putArray("planningYears").add("2026").add("2027");
AgentOutputService.normalizePlanningYears(plan);
assertThat(plan.path("planningYears").asInt()).isEqualTo(2);
assertThat(plan.path("planningPeriod").asText()).isEqualTo("2026-2027");
}
@Test
void shouldBoundRepairsAndReleaseCapacityOnRollback() throws Exception {
var projectId = UUID.randomUUID();
var output = mock(AgentOutputService.class);
when(output.readMaterialCheck(projectId)).thenThrow(new java.io.IOException("broken JSON"));
when(output.readProposedPlan(projectId)).thenThrow(new java.io.IOException("broken JSON"));
var execution = mock(AgentExecutionService.class);
var project = mock(ProjectService.ProjectView.class);
when(project.id()).thenReturn(projectId);
var run = new AgentRunService.RunView(UUID.randomUUID(), projectId, UUID.randomUUID(),
"INITIAL", "RUNNING", null, null, OffsetDateTime.now(), null);
var service = new AgentRunService(mock(AgentRunMapper.class), tools.jackson.databind.json.JsonMapper.builder().build(),
execution, output, mock(AgentRunStore.class), mock(AgentEventService.class), mock(ProjectService.class),
mock(ProjectFileService.class), mock(ArtifactService.class), mock(ExecutorService.class), mock(TransactionTemplate.class), 1);
for (String method : java.util.List.of("readMaterialCheckWithRepair", "readProposedPlanWithRepair")) {
org.assertj.core.api.Assertions.assertThatThrownBy(() -> org.springframework.test.util.ReflectionTestUtils.invokeMethod(
service, method, project, run)).hasMessageContaining("连续修复失败");
}
verify(execution, org.mockito.Mockito.times(6)).execute(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
TransactionSynchronizationManager.initSynchronization();
try {
Runnable task = () -> { };
org.springframework.test.util.ReflectionTestUtils.invokeMethod(service, "afterCommit", UUID.randomUUID(), task);
org.assertj.core.api.Assertions.assertThatThrownBy(() -> org.springframework.test.util.ReflectionTestUtils.invokeMethod(
service, "afterCommit", UUID.randomUUID(), task)).hasMessageContaining("已达上限");
var synchronization = TransactionSynchronizationManager.getSynchronizations().getFirst();
synchronization.afterCompletion(org.springframework.transaction.support.TransactionSynchronization.STATUS_ROLLED_BACK);
org.springframework.test.util.ReflectionTestUtils.invokeMethod(service, "afterCommit", UUID.randomUUID(), task);
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
}

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.agent;
package tech.easyflow.manuagent.agent.runtime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -7,17 +7,17 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import tech.easyflow.manuagent.entity.AgentRunEntity;
import tech.easyflow.manuagent.agent.runtime.AgentRunEntity;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.entity.ModelConfigEntity;
import tech.easyflow.manuagent.mapper.AgentEventMapper;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
import tech.easyflow.manuagent.agent.model.ModelConfigEntity;
import tech.easyflow.manuagent.agent.runtime.AgentEventMapper;
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
import tech.easyflow.manuagent.agent.model.ModelConfigMapper;
/**
* 验证 Agent 执行热路径只读取判断运行状态所需的最小列
@@ -37,7 +37,7 @@ class AgentRunStoreQueryTest {
runMapper,
mock(AgentEventMapper.class),
mock(ModelConfigMapper.class),
new ObjectMapper());
tools.jackson.databind.json.JsonMapper.builder().build());
store.ensureRunning(UUID.randomUUID());
@@ -61,7 +61,7 @@ class AgentRunStoreQueryTest {
runMapper,
mock(AgentEventMapper.class),
mock(ModelConfigMapper.class),
new ObjectMapper());
tools.jackson.databind.json.JsonMapper.builder().build());
assertThatThrownBy(() -> store.create(UUID.randomUUID(), "INITIAL", null))
.isInstanceOfSatisfying(ApiException.class, exception -> {
@@ -99,7 +99,7 @@ class AgentRunStoreQueryTest {
runMapper,
mock(AgentEventMapper.class),
modelMapper,
new ObjectMapper());
tools.jackson.databind.json.JsonMapper.builder().build());
AgentRunService.RunView run = store.create(projectId, "RESUME", null, modelId);
@@ -118,7 +118,7 @@ class AgentRunStoreQueryTest {
mock(AgentRunMapper.class),
mock(AgentEventMapper.class),
mock(ModelConfigMapper.class),
new ObjectMapper());
tools.jackson.databind.json.JsonMapper.builder().build());
UUID runId = UUID.randomUUID();
assertThatThrownBy(() -> store.require(runId))

View File

@@ -0,0 +1,51 @@
package tech.easyflow.manuagent.agent.runtime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import io.agentscope.core.ReActAgent;
import io.agentscope.core.agui.adapter.AguiAdapterConfig;
import io.agentscope.core.agui.adapter.AguiAgentAdapter;
import io.agentscope.core.agui.event.AguiEvent;
import io.agentscope.core.agui.model.AguiMessage;
import io.agentscope.core.agui.model.RunAgentInput;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.model.ChatResponse;
import io.agentscope.core.model.Model;
import io.agentscope.core.model.transport.HttpTransportException;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
class AgentRuntimeMiddlewareTest {
@Test
void shouldKeepOriginalFailureAcrossRealAguiConversionAndRefreshLanguage() {
var model = mock(Model.class);
when(model.getModelName()).thenReturn("test-model");
var failure = new HttpTransportException("unavailable", 503, "");
var inputs = new ArrayList<List<Msg>>();
when(model.stream(any(), any(), any())).thenAnswer(call -> {
inputs.add(call.getArgument(0));
return inputs.size() == 1 ? Flux.error(failure) : Flux.just(ChatResponse.builder()
.content(List.of(TextBlock.builder().text("已完成").build())).build());
});
var middleware = new AgentRuntimeMiddleware();
var agent = ReActAgent.builder().name("test-agent").model(model).middleware(middleware).build();
var adapter = new AguiAgentAdapter(agent, AguiAdapterConfig.builder().build());
var input = RunAgentInput.builder().threadId("language-test").runId("run-1")
.messages(List.of(AguiMessage.userMessage("m1", "Please inspect the file."))).build();
var events = adapter.run(input).collectList().block();
assertThat(events).anyMatch(AguiEvent.RunError.class::isInstance);
assertThat(middleware.modelFailure()).isSameAs(failure);
adapter.run(input).blockLast();
assertThat(middleware.modelFailure()).isNull();
assertThat(inputs).hasSize(2);
for (var messages : inputs) {
assertThat(messages.getLast().getTextContent()).contains("简体中文", "英文 Skill", "专有名词");
assertThat(messages.stream().filter(msg -> msg.getTextContent().contains("<system-reminder>"))).hasSize(1);
}
}
}

View File

@@ -0,0 +1,92 @@
package tech.easyflow.manuagent.agent.runtime;
import static org.assertj.core.api.Assertions.assertThat;
import io.agentscope.core.state.JsonFileAgentStateStore;
import io.agentscope.core.state.AgentState;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.harness.agent.IsolationScope;
import io.agentscope.harness.agent.sandbox.SandboxIsolationKey;
import io.agentscope.harness.agent.sandbox.SessionSandboxStateStore;
import io.agentscope.harness.agent.sandbox.impl.docker.DockerSandboxClient;
import io.agentscope.harness.agent.sandbox.impl.docker.DockerSandboxState;
import io.agentscope.harness.agent.sandbox.snapshot.LocalSnapshotSpec;
import java.io.ByteArrayInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class AgentStateFilesTest {
@TempDir Path root;
@Test
void shouldPreserveLegacySessionAndReloadFromPersistentRoot() throws Exception {
UUID projectId = UUID.randomUUID();
String session = "project-" + projectId;
Path legacy = root.resolve("legacy");
Path source = legacy.resolve("smart-factory-agent/__anon__").resolve(session);
Files.createDirectories(source);
Files.writeString(source.resolve("memory_messages.jsonl"), "历史记忆\n");
var oldStore = new JsonFileAgentStateStore(legacy.resolve("smart-factory-agent"));
oldStore.save(null, session, "agent_state", AgentState.builder().sessionId(session).build());
Path target = AgentStateFiles.prepare(root.resolve("data"), legacy, projectId);
var restored = new JsonFileAgentStateStore(target);
assertThat(restored.get(null, session, "agent_state", AgentState.class)).isPresent();
assertThat(Files.readString(target.resolve("__anon__").resolve(session).resolve("memory_messages.jsonl")))
.isEqualTo("历史记忆\n");
assertThat(source.resolve("agent_state.json")).exists();
Files.writeString(source.resolve("memory_messages.jsonl"), "旧进程后来修改的内容");
AgentStateFiles.prepare(root.resolve("data"), legacy, projectId);
assertThat(Files.readString(target.resolve("__anon__").resolve(session).resolve("memory_messages.jsonl")))
.isEqualTo("历史记忆\n");
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void shouldRestoreSandboxSnapshotAndBackfillAlreadyMigratedSessions(boolean migratedBefore) throws Exception {
UUID projectId = UUID.randomUUID();
String session = "project-" + projectId;
Path legacy = root.resolve("legacy");
Path data = root.resolve("data");
var oldStore = new JsonFileAgentStateStore(legacy.resolve("smart-factory-agent"));
var key = SandboxIsolationKey.resolve(IsolationScope.SESSION,
RuntimeContext.builder().sessionId(session).build(), "smart-factory-agent").orElseThrow();
var oldSandbox = new SessionSandboxStateStore(oldStore, "smart-factory-agent");
oldStore.save(null, session, "agent_state", AgentState.builder().sessionId(session).build());
if (migratedBefore) {
// 模拟上一版本已经复制会话,但尚未复制沙箱独立状态槽。
AgentStateFiles.prepare(data, legacy, projectId);
}
var client = new DockerSandboxClient();
var state = new DockerSandboxState();
state.setSessionId(UUID.randomUUID().toString());
state.setImage("agent-sandbox:0.1");
state.setSnapshot(new LocalSnapshotSpec(data.resolve("sandbox-snapshots")).build(state.getSessionId()));
byte[] archive = "snapshot-fixture".getBytes(java.nio.charset.StandardCharsets.UTF_8);
state.getSnapshot().persist(new ByteArrayInputStream(archive));
String saved = client.serializeState(state);
oldSandbox.save(key, saved);
Path target = AgentStateFiles.prepare(data, legacy, projectId);
var restoredStore = new JsonFileAgentStateStore(target);
var restoredSandbox = new SessionSandboxStateStore(restoredStore, "smart-factory-agent");
assertThat(restoredStore.get(null, session, "agent_state", AgentState.class)).isPresent();
assertThat(restoredSandbox.load(key)).contains(saved);
try (var input = client.deserializeState(restoredSandbox.load(key).orElseThrow()).getSnapshot().restore()) {
assertThat(input.readAllBytes()).isEqualTo(archive);
}
assertThat(oldSandbox.load(key)).contains(saved);
restoredSandbox.save(key, "newer-state");
AgentStateFiles.prepare(data, legacy, projectId);
assertThat(restoredSandbox.load(key)).contains("newer-state");
restoredSandbox.delete(key);
AgentStateFiles.prepare(data, legacy, projectId);
assertThat(restoredSandbox.load(key)).isEmpty();
}
}

View File

@@ -0,0 +1,51 @@
package tech.easyflow.manuagent.agent.runtime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import tools.jackson.databind.ObjectMapper;
import io.agentscope.core.model.ToolSchema;
import io.agentscope.core.tool.Toolkit;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import java.io.IOException;
import org.springframework.core.io.ByteArrayResource;
import tech.easyflow.manuagent.agent.project.ProjectFileService;
/**
* 验证文档视觉工具可以被 AgentScope 注册并暴露结构化参数。
*/
class DocumentViewToolTest {
/**
* 验证多视图参数能够进入模型工具定义。
*/
@Test
void shouldRegisterMultiViewSchema() {
Toolkit toolkit = new Toolkit();
toolkit.registerTool(new DocumentViewTool(tools.jackson.databind.json.JsonMapper.builder().build(),
mock(ProjectFileService.class), UUID.randomUUID()));
ToolSchema schema = toolkit.getToolSchemas().getFirst();
assertThat(schema.getName()).isEqualTo("document_view");
assertThat(schema.getParameters().toString()).contains("views", "path", "page", "sheet", "range");
}
@Test
void shouldPreserveLargePreviewBytesAndRejectIncompleteImages() throws IOException {
UUID projectId = UUID.randomUUID();
String path = "work/tmp/document-view/test/render-1.png";
byte[] bytes = new byte[700_000];
new java.util.Random(1).nextBytes(bytes);
ProjectFileService files = mock(ProjectFileService.class);
when(files.preview(projectId, path)).thenReturn(
new ProjectFileService.Preview("image/png", new ByteArrayResource(bytes)));
var tool = new DocumentViewTool(tools.jackson.databind.json.JsonMapper.builder().build(), files, projectId);
assertThat(tool.readPreview(path, bytes.length)).isEqualTo(bytes);
assertThatThrownBy(() -> tool.readPreview(path, bytes.length + 1)).isInstanceOf(IOException.class);
assertThatThrownBy(() -> tool.readPreview(path, 5 * 1024 * 1024)).isInstanceOf(IOException.class);
}
}

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.agent;
package tech.easyflow.manuagent.agent.runtime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@@ -7,7 +7,7 @@ import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectMapper;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.harness.agent.filesystem.model.ExecuteResponse;
import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem;
@@ -35,7 +35,7 @@ class PagedReadFileToolTest {
0,
false));
PagedReadFileTool tool = new PagedReadFileTool(
filesystem, WorkspacePathNormalizer.of("/workspace"), new ObjectMapper());
filesystem, WorkspacePathNormalizer.of("/workspace"), tools.jackson.databind.json.JsonMapper.builder().build());
String result = tool.readFile(RuntimeContext.empty(), "/workspace/work/report.txt", 0, 2);
@@ -56,7 +56,7 @@ class PagedReadFileToolTest {
0,
true));
PagedReadFileTool tool = new PagedReadFileTool(
filesystem, WorkspacePathNormalizer.of("/workspace"), new ObjectMapper());
filesystem, WorkspacePathNormalizer.of("/workspace"), tools.jackson.databind.json.JsonMapper.builder().build());
String result = tool.readFile(RuntimeContext.empty(), "work/report.txt", 0, 10);

View File

@@ -0,0 +1,62 @@
package tech.easyflow.manuagent.agent.runtime;
import static org.assertj.core.api.Assertions.assertThat;
import io.agentscope.harness.agent.sandbox.impl.docker.DockerSandboxState;
import io.agentscope.harness.agent.sandbox.WorkspaceSpec;
import org.junit.jupiter.api.Test;
class RecoverableDockerClientTest {
@Test
void shouldRecoverOnlyTheSdkOwnedCanonicalSessionContainer() {
var client = new RecoverableDockerClient("agent-sandbox:0.1");
var state = new DockerSandboxState();
state.setWorkspaceSpec(new WorkspaceSpec());
state.setSessionId("08726648-85b1-42c0-a9c8-161e6b44150f");
state.setContainerName("agentscope-sandbox-" + state.getSessionId());
state.setContainerId("obsolete-id");
client.resume(state);
assertThat(state.getContainerId()).isEqualTo(state.getContainerName());
state.setContainerOwned(false);
state.setContainerId("external-id");
client.resume(state);
assertThat(state.getContainerId()).isEqualTo("external-id");
state.setContainerOwned(true);
state.setContainerName("other-project");
client.resume(state);
assertThat(state.getContainerId()).isEqualTo("external-id");
state.setSessionId("untrusted-name");
state.setContainerName("agentscope-sandbox-untrusted-name");
client.resume(state);
assertThat(state.getContainerId()).isEqualTo("external-id");
}
@Test
void shouldMigrateOnlyOwnedLegacyImagesToTheConfiguredImage() {
var client = new RecoverableDockerClient("registry.example/agent-sandbox:0.1");
var state = new DockerSandboxState();
var workspace = new WorkspaceSpec();
state.setWorkspaceSpec(workspace);
state.setSessionId("08726648-85b1-42c0-a9c8-161e6b44150f");
for (String tag : new String[]{"0.1.0", "pre-font-fix"}) {
state.setImage("smart-factory-agent-runtime:" + tag);
var sandbox = client.resume(state);
assertThat(sandbox.getState()).isSameAs(state);
assertThat(state.getImage()).isEqualTo("registry.example/agent-sandbox:0.1");
assertThat(state.getWorkspaceSpec()).isSameAs(workspace);
assertThat(state.getSessionId()).isEqualTo("08726648-85b1-42c0-a9c8-161e6b44150f");
}
state.setImage("custom-sandbox:latest");
client.resume(state);
assertThat(state.getImage()).isEqualTo("custom-sandbox:latest");
state.setContainerOwned(false);
state.setImage("smart-factory-agent-runtime:0.1.0");
client.resume(state);
assertThat(state.getImage()).isEqualTo("smart-factory-agent-runtime:0.1.0");
}
}

View File

@@ -0,0 +1,60 @@
package tech.easyflow.manuagent.agent.runtime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import io.agentscope.harness.agent.filesystem.model.ExecuteResponse;
import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SandboxToolsTest {
@TempDir Path root;
@Test
void shouldExecuteRealEditsAndPreserveShellFailures() throws Exception {
var filesystem = mock(AbstractSandboxFilesystem.class);
when(filesystem.execute(any(), anyString(), anyInt())).thenAnswer(call -> {
Process process = new ProcessBuilder("bash", "-c", call.getArgument(1, String.class))
.directory(root.toFile()).redirectErrorStream(true).start();
String output = new String(process.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
return new ExecuteResponse(output, process.waitFor(), false);
});
var sandboxTools = new SandboxTools(filesystem, tools.jackson.databind.json.JsonMapper.builder().build());
Path file = root.resolve("含 ' 引号.txt");
Files.writeString(file, "中文原文\n重复 重复\n");
var edited = sandboxTools.editFile(null, file.getFileName().toString(), "中文原文", "引号'、反引号`、$(false)\n第二行", false);
assertThat(edited.get("success")).isEqualTo(true);
assertThat(Files.readString(file)).startsWith("引号'、反引号`、$(false)\n第二行");
String before = Files.readString(file);
assertThat(sandboxTools.editFile(null, file.getFileName().toString(), "重复", "替换", false).get("success")).isEqualTo(false);
assertThat(Files.readString(file)).isEqualTo(before);
assertThat(sandboxTools.editFile(null, file.getFileName().toString(), "重复", "替换", true).get("replacements")).isEqualTo(2);
assertThat(sandboxTools.editFile(null, "../outside", "x", "y", false).get("success")).isEqualTo(false);
Files.createSymbolicLink(root.resolve("outside"), root.getParent());
assertThat(sandboxTools.editFile(null, "outside/no-file", "x", "y", false).get("success")).isEqualTo(false);
var failed = sandboxTools.execute(null, "false | tail -1\ntouch should-not-exist", null, 5);
assertThat(failed.get("success")).isEqualTo(false);
assertThat(failed.get("exitCode")).isEqualTo(1);
assertThat(root.resolve("should-not-exist")).doesNotExist();
assertThat(sandboxTools.execute(null, "printf '正常'", null, 5).get("output")).isEqualTo("正常");
// 通过 SDK 的反射注册和调用链验证工具覆盖,避免只测 Java 方法而漏掉协议接入。
var toolkit = new io.agentscope.core.tool.Toolkit();
toolkit.registerTool(sandboxTools);
var input = java.util.Map.<String, Object>of("path", file.getFileName().toString(), "old_string", "第二行", "new_string", "SDK 编辑成功");
var call = io.agentscope.core.message.ToolUseBlock.builder().id("edit-check").name("edit_file")
.input(input).content(tools.jackson.databind.json.JsonMapper.builder().build().writeValueAsString(input)).build();
var result = toolkit.callTool(io.agentscope.core.tool.ToolCallParam.builder().toolUseBlock(call).build()).block();
assertThat(result).isNotNull();
assertThat(((io.agentscope.core.message.TextBlock) result.getOutput().getFirst()).getText()).contains("\"success\":true");
assertThat(Files.readString(file)).contains("SDK 编辑成功");
}
}

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.skill;
package tech.easyflow.manuagent.agent.skill;
import static org.assertj.core.api.Assertions.assertThat;

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>tech.easyflow</groupId>
<artifactId>manuagent-parent</artifactId>
<version>0.1.0</version>
</parent>
<artifactId>manuagent-common</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.typehandler;
package tech.easyflow.manuagent.common.typehandler;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.typehandler;
package tech.easyflow.manuagent.common.typehandler;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;

View File

@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>tech.easyflow</groupId>
<artifactId>manuagent-parent</artifactId>
<version>0.1.0</version>
</parent>
<artifactId>manuagent-web</artifactId>
<dependencies>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>manuagent-common</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>manuagent-agent</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>manuagent-admin</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>com.mybatis-flex</groupId>
<artifactId>mybatis-flex-spring-boot4-starter</artifactId>
</dependency>
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>manuagent-web</finalName>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
</resource>
<resource>
<directory>${project.basedir}/../src/main/resources</directory>
<filtering>false</filtering>
<includes><include>db/migration/**</include></includes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -1,6 +1,6 @@
package tech.easyflow.manuagent;
import tech.easyflow.manuagent.config.AppProperties;
import tech.easyflow.manuagent.agent.config.AgentProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -9,7 +9,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
* 智造申报 Agent 服务入口
*/
@SpringBootApplication
@EnableConfigurationProperties(AppProperties.class)
@EnableConfigurationProperties({AgentProperties.class, tech.easyflow.manuagent.admin.config.AdminProperties.class})
public class ManuAgentApplication {
/**

View File

@@ -1,9 +1,13 @@
package tech.easyflow.manuagent.agent;
package tech.easyflow.manuagent.web.agent;
import com.fasterxml.jackson.databind.JsonNode;
import tech.easyflow.manuagent.agent.runtime.AgentRunService;
import tech.easyflow.manuagent.agent.runtime.AgentEventService;
import tools.jackson.databind.JsonNode;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.security.Principal;
import tech.easyflow.manuagent.admin.auth.UserService;
import java.util.List;
import java.util.UUID;
import org.springframework.http.MediaType;
@@ -24,6 +28,8 @@ import reactor.core.publisher.Flux;
@RequestMapping("/api/projects/{projectId}")
public class AgentController {
private final UserService userService;
private final AgentRunService runService;
private final AgentEventService eventService;
@@ -33,7 +39,8 @@ public class AgentController {
* @param runService Run 服务
* @param eventService 事件服务
*/
public AgentController(AgentRunService runService, AgentEventService eventService) {
public AgentController(UserService userService, AgentRunService runService, AgentEventService eventService) {
this.userService = userService;
this.runService = runService;
this.eventService = eventService;
}
@@ -47,7 +54,7 @@ public class AgentController {
*/
@PostMapping("/runs/material-check")
public AgentRunService.RunView startMaterialCheck(@PathVariable UUID projectId, Principal principal) {
return runService.startMaterialCheck(projectId, principal);
return runService.startMaterialCheck(projectId, userService.requireUserId(principal.getName()));
}
/**
@@ -63,7 +70,7 @@ public class AgentController {
@PathVariable UUID projectId,
@RequestBody JsonNode response,
Principal principal) {
return runService.confirmMaterials(projectId, response, principal);
return runService.confirmMaterials(projectId, response, userService.requireUserId(principal.getName()));
}
/**
@@ -86,7 +93,7 @@ public class AgentController {
*/
@PostMapping("/runs/stop")
public AgentRunService.RunView stop(@PathVariable UUID projectId, Principal principal) {
return runService.stop(projectId, principal);
return runService.stop(projectId, userService.requireUserId(principal.getName()));
}
/**
@@ -101,7 +108,7 @@ public class AgentController {
@PathVariable UUID projectId,
@Valid @RequestBody(required = false) ResumeInput input,
Principal principal) {
return runService.resume(projectId, input == null ? null : input.modelConfigId(), principal);
return runService.resume(projectId, input == null ? null : input.modelConfigId(), userService.requireUserId(principal.getName()));
}
/**
@@ -117,7 +124,7 @@ public class AgentController {
@PathVariable UUID projectId,
@Valid @RequestBody ResumeInput input,
Principal principal) {
return runService.switchModel(projectId, input.modelConfigId(), principal);
return runService.switchModel(projectId, input.modelConfigId(), userService.requireUserId(principal.getName()));
}
/**

View File

@@ -1,4 +1,6 @@
package tech.easyflow.manuagent.artifact;
package tech.easyflow.manuagent.web.artifact;
import tech.easyflow.manuagent.agent.artifact.ArtifactService;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.auth;
package tech.easyflow.manuagent.web.auth;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

View File

@@ -1,4 +1,7 @@
package tech.easyflow.manuagent.common;
package tech.easyflow.manuagent.web.common;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.common.ApiError;
import jakarta.validation.ConstraintViolationException;
import java.util.UUID;

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.common;
package tech.easyflow.manuagent.web.common;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.config;
package tech.easyflow.manuagent.web.config;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

View File

@@ -1,4 +1,4 @@
package tech.easyflow.manuagent.config;
package tech.easyflow.manuagent.web.config;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@@ -6,11 +6,12 @@ import org.springframework.context.annotation.Configuration;
/**
* 配置 MyBatis-Flex Mapper 扫描
*
* <p>业务 Mapper 统一放在 {@code tech.easyflow.manuagent.mapper} 包中数据库连接连接池和
* <p>业务 Mapper Agent/Admin 领域装配数据库连接连接池和
* Spring 事务管理器继续复用 Spring Boot 已配置的数据源使 MyBatis-Flex Mapper 调用
* 事件写入与应用服务的 {@code @Transactional} 边界共享同一物理事务</p>
*/
@Configuration
@MapperScan("tech.easyflow.manuagent.mapper")
@MapperScan(basePackages = {"tech.easyflow.manuagent.agent", "tech.easyflow.manuagent.admin"},
annotationClass = org.apache.ibatis.annotations.Mapper.class)
public class MyBatisFlexConfiguration {
}

View File

@@ -1,8 +1,8 @@
package tech.easyflow.manuagent.config;
package tech.easyflow.manuagent.web.config;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.admin.auth.UserService;
import tech.easyflow.manuagent.common.ApiError;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectMapper;
import java.time.Instant;
import java.util.UUID;
import org.springframework.context.annotation.Bean;

View File

@@ -0,0 +1,19 @@
package tech.easyflow.manuagent.web.config;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import tech.easyflow.manuagent.admin.auth.UserService;
import tech.easyflow.manuagent.agent.runtime.RunRecoveryService;
/** Flyway 完成后由唯一应用入口执行恢复与账号初始化,保持原先顺序。 */
@Configuration(proxyBeanMethods = false)
public class StartupConfiguration {
@Bean
ApplicationRunner initializeApplication(RunRecoveryService recovery, UserService users) {
return args -> {
recovery.recoverInterruptedRuns();
users.initializeAdministrator();
};
}
}

View File

@@ -1,8 +1,11 @@
package tech.easyflow.manuagent.model;
package tech.easyflow.manuagent.web.model;
import tech.easyflow.manuagent.agent.model.ModelService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.security.Principal;
import tech.easyflow.manuagent.admin.auth.UserService;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
@@ -24,6 +27,8 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/models")
public class ModelController {
private final UserService userService;
private final ModelService modelService;
/**
@@ -31,7 +36,8 @@ public class ModelController {
*
* @param modelService 模型服务
*/
public ModelController(ModelService modelService) {
public ModelController(UserService userService, ModelService modelService) {
this.userService = userService;
this.modelService = modelService;
}
@@ -55,7 +61,7 @@ public class ModelController {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ModelService.ModelView create(@Valid @RequestBody ModelService.ModelInput input, Principal principal) {
return modelService.save(null, input, principal);
return modelService.save(null, input, userService.requireUserId(principal.getName()));
}
/**
@@ -71,7 +77,7 @@ public class ModelController {
@PathVariable UUID id,
@Valid @RequestBody ModelService.ModelInput input,
Principal principal) {
return modelService.save(id, input, principal);
return modelService.save(id, input, userService.requireUserId(principal.getName()));
}
/**
@@ -112,7 +118,7 @@ public class ModelController {
@PostMapping("/{id}/default")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void setDefault(@PathVariable UUID id, Principal principal) {
modelService.setDefault(id, principal);
modelService.setDefault(id, userService.requireUserId(principal.getName()));
}
/**

View File

@@ -1,12 +1,16 @@
package tech.easyflow.manuagent.project;
package tech.easyflow.manuagent.web.project;
import tech.easyflow.manuagent.agent.AgentRunService;
import com.fasterxml.jackson.databind.JsonNode;
import tech.easyflow.manuagent.agent.project.ProjectFileService;
import tech.easyflow.manuagent.agent.project.ProjectService;
import tech.easyflow.manuagent.agent.runtime.AgentRunService;
import tools.jackson.databind.JsonNode;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import tech.easyflow.manuagent.admin.auth.UserService;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpHeaders;
@@ -31,6 +35,8 @@ import org.springframework.web.multipart.MultipartFile;
@RequestMapping("/api/projects")
public class ProjectController {
private final UserService userService;
private final ProjectService projectService;
private final ProjectFileService fileService;
private final AgentRunService runService;
@@ -42,10 +48,11 @@ public class ProjectController {
* @param fileService 文件服务
* @param runService Agent Run 服务
*/
public ProjectController(
public ProjectController(UserService userService,
ProjectService projectService,
ProjectFileService fileService,
AgentRunService runService) {
this.userService = userService;
this.projectService = projectService;
this.fileService = fileService;
this.runService = runService;
@@ -71,7 +78,7 @@ public class ProjectController {
@PostMapping
public ProjectService.ProjectView create(@Valid @RequestBody CreateProjectRequest request, Principal principal) {
ProjectService.ProjectView project = projectService.create(
request.companyName(), request.applicationLevel(), principal);
request.companyName(), request.applicationLevel(), userService.requireUserId(principal.getName()));
fileService.ensureWorkspace(project.id());
return project;
}
@@ -124,7 +131,7 @@ public class ProjectController {
@PathVariable UUID projectId,
@Valid @RequestBody ConfirmPlanRequest request,
Principal principal) {
return runService.confirmPlanAndStartWriting(projectId, request.planId(), request.plan(), principal);
return runService.confirmPlanAndStartWriting(projectId, request.planId(), request.plan(), userService.requireUserId(principal.getName()));
}
/**
@@ -142,7 +149,7 @@ public class ProjectController {
@RequestParam MultipartFile file,
@RequestParam(required = false) String relativePath,
Principal principal) {
return fileService.upload(projectId, file, relativePath, principal);
return fileService.upload(projectId, file, relativePath, userService.requireUserId(principal.getName()));
}
/**

View File

@@ -1,6 +1,9 @@
package tech.easyflow.manuagent.skill;
package tech.easyflow.manuagent.web.skill;
import tech.easyflow.manuagent.agent.skill.SkillService;
import java.security.Principal;
import tech.easyflow.manuagent.admin.auth.UserService;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
@@ -21,6 +24,8 @@ import org.springframework.web.multipart.MultipartFile;
@RequestMapping("/api/skills")
public class SkillController {
private final UserService userService;
private final SkillService skillService;
/**
@@ -28,7 +33,8 @@ public class SkillController {
*
* @param skillService Skill 服务
*/
public SkillController(SkillService skillService) {
public SkillController(UserService userService, SkillService skillService) {
this.userService = userService;
this.skillService = skillService;
}
@@ -75,7 +81,7 @@ public class SkillController {
@PostMapping(path = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ResponseStatus(HttpStatus.CREATED)
public SkillService.SkillView importZip(@RequestParam MultipartFile file, Principal principal) {
return skillService.importZip(file, principal);
return skillService.importZip(file, userService.requireUserId(principal.getName()));
}
/**

View File

@@ -24,8 +24,7 @@ spring:
mybatis-flex:
mapper-locations:
- classpath*:/mapper/**/*.xml
type-aliases-package: tech.easyflow.manuagent.entity
type-handlers-package: tech.easyflow.manuagent.typehandler
type-handlers-package: tech.easyflow.manuagent.common.typehandler
configuration:
map-underscore-to-camel-case: true
cache-enabled: false
@@ -46,9 +45,10 @@ app:
master-key: smart-factory-local-master-key
admin-username: admin
admin-password: admin123
sandbox-image: smart-factory-agent-runtime:0.1.0
sandbox-image: agent-sandbox:0.1
sandbox-network: bridge
run-timeout: 60m
max-concurrent-runs: 2
logging:
pattern:

Some files were not shown because too many files have changed in this diff Show More