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

@@ -6,17 +6,4 @@ APP_ADMIN_PASSWORD=replace-with-a-strong-admin-password
# 百炼知识库未启用时可以保留为空;模型 API Key 仍通过页面配置并加密保存。 # 百炼知识库未启用时可以保留为空;模型 API Key 仍通过页面配置并加密保存。
DASHSCOPE_API_KEY= DASHSCOPE_API_KEY=
# 非敏感运行参数。 # 镜像、数据库名称、端口和数据目录等普通配置直接修改 docker-compose.yml。
POSTGRES_DB=smart_factory_agent
POSTGRES_USER=smart_factory
APP_ADMIN_USERNAME=admin
APP_RUN_TIMEOUT=60m
SESSION_COOKIE_SECURE=false
MANUAGENT_DATA_ROOT=/srv/manuagent/data
# 默认值即部署要求的 0.0.0.0:5173普通 Linux 部署无需修改。
FRONTEND_PORT=5173
# 可选镜像标签,便于私有镜像仓库或版本升级时覆盖。
AGENT_RUNTIME_IMAGE=smart-factory-agent-runtime:0.1.0
BACKEND_IMAGE=manuagent-backend:0.1.0
FRONTEND_IMAGE=manuagent-frontend:0.1.0

8
.gitignore vendored
View File

@@ -1,6 +1,6 @@
server/target/ server/**/target/
web-ui/node_modules/ web-ui/**/node_modules/
web-ui/dist/ web-ui/**/dist/
sandbox/node_modules/ sandbox/node_modules/
*.tsbuildinfo *.tsbuildinfo
data/ data/
@@ -18,4 +18,4 @@ deepseek_key.txt
test_data/ test_data/
# 部署镜像体积较大,只在本地或服务器之间传输,不提交到 Git 仓库。 # 部署镜像体积较大,只在本地或服务器之间传输,不提交到 Git 仓库。
deploy/*.tar /agent-sandbox-*.tar

104
README.md
View File

@@ -4,21 +4,53 @@
## 技术栈 ## 技术栈
- Spring Boot 3.5.16、JDK 21、AgentScope Java 2.0.1、AG-UI - Spring Boot 4.1.1、JDK 25、AgentScope Java 2.0.1、AG-UI
- MyBatis-Flex 1.11.8Boot 4 starter、业务与 HTTP 使用 Jackson 3AgentScope 内部保留 Jackson 2
- Vue 3.5、Vite、Element Plus、Vue Element Plus X - Vue 3.5、Vite、Element Plus、Vue Element Plus X
- PostgreSQL 17、Flyway - PostgreSQL 17、Flyway
## 本地启动 ## 本地启动
需要 JDK 25、Maven、Node.js 22 和 Docker。先设置 `JAVA_HOME` 指向 JDK 25IDEA 从
`server/pom.xml` 导入 Maven reactor项目 SDK 与 Maven runner 均选 JDK 25运行
`manuagent-web``ManuAgentApplication`,工作目录设为仓库根目录。
IDEA 运行配置同时设置 `APP_DATA_ROOT` 为既有数据目录的绝对路径,以及
`APP_DASHSCOPE_KEY_FILE` 为知识库密钥文件的绝对路径,与下面的命令行启动配置保持一致。
后端通过 `SPRING_DATASOURCE_URL``SPRING_DATASOURCE_USERNAME``SPRING_DATASOURCE_PASSWORD`
连接可用的 PostgreSQL 17。根 Compose 的数据库端口只供容器内部使用;宿主机开发需使用已发布
端口的本地数据库,或在本地 Compose override 中增加 PostgreSQL 端口映射。
```bash ```bash
docker build -t smart-factory-agent-runtime:0.1.0 -f sandbox/Dockerfile sandbox docker build -t agent-sandbox:0.1 -f sandbox/Dockerfile sandbox
docker compose up -d postgres mvn -f server/pom.xml clean package
mvn -q -f server/pom.xml spring-boot:run export APP_DATA_ROOT="$PWD/data"
npm --prefix web-ui install export APP_DASHSCOPE_KEY_FILE="$PWD/dashscope_key.txt"
java -jar server/manuagent-web/target/manuagent-web.jar
# 在另一个终端启动前端;依赖只从 workspace 根目录安装。
npm --prefix web-ui ci
npm --prefix web-ui run dev npm --prefix web-ui run dev
``` ```
本地开发服务器默认使用 Vite 配置的端口。容器化部署固定从 <http://127.0.0.1:5173> 访问。 本地开发入口为 <http://127.0.0.1:15173>,代理后端 8080。唯一可执行产物是
`server/manuagent-web/target/manuagent-web.jar`。上述命令从仓库根目录执行,将 `APP_DATA_ROOT` 设为
根目录下 `data` 的绝对路径;已有数据时改为对应绝对路径,避免移动启动目录后找不到历史文件。
容器化部署固定从 <http://127.0.0.1:5173> 访问。
Agent 会话状态保存在 `APP_DATA_ROOT/agent-state`,与项目文件和沙箱快照一起持久化。
启动时,会为数据目录内的已有项目从当前运行用户的 `~/.agentscope/state`(或 JVM `agentscope.state.home`
指定目录)分别复制会话与沙箱状态,补齐此前只迁移会话的项目;保留旧目录,不覆盖已有新状态。
跨机器或从旧容器升级时,须先将旧状态目录复制到新环境的对应位置,并保留快照原有绝对路径;
仅保留项目文件不能恢复完整会话。旧进程停止后再迁移,避免继续写入旧位置。
沙箱镜像统一为 `agent-sandbox:0.1`。升级前停止旧后端并确认旧沙箱容器已退出;历史会话恢复时
会将旧 `smart-factory-agent-runtime` 镜像引用迁移到当前 `APP_SANDBOX_IMAGE` 配置,保留会话和快照。
`APP_RUN_TIMEOUT` 限制整个 Run含重连与结果修复的总时长默认 60 分钟;结构化结果最多修复
3 次。`APP_MAX_CONCURRENT_RUNS` 限制单个后端同时运行的任务数,默认 2新增任务满额时返回 429。
同项目的模型切换、停止后继续及阶段接续共用原运行名额,等待旧任务释放资源、保存快照后再启动。
修改沙箱文件后须重建 `sandbox/Dockerfile` 镜像;其中 `docx-example.cjs` 提供与已安装依赖
一致的表格、原生批注示例及可执行验证。
模型连接只能在“模型配置”页面新增并持久化到 PostgreSQLAPI Key 会使用 `APP_MASTER_KEY` 模型连接只能在“模型配置”页面新增并持久化到 PostgreSQLAPI Key 会使用 `APP_MASTER_KEY`
环境变量提供的主密钥加密后保存。`dashscope_key.txt` 仅供百炼知识库使用,不参与模型配置。 环境变量提供的主密钥加密后保存。`dashscope_key.txt` 仅供百炼知识库使用,不参与模型配置。
@@ -31,23 +63,23 @@ $env:APP_MASTER_KEY = '<使用独立生成的高强度密钥>'
## Docker Compose 部署 ## Docker Compose 部署
部署包含 Nginx 前端、Spring Boot 后端、Agent Runtime 镜像和 PostgreSQL。Agent Runtime 部署包含 Nginx 前端、Spring Boot 后端、沙箱镜像和 PostgreSQL。沙箱
不是常驻 API 服务:镜像在开发机预先构建并导入服务器,服务器上的 `agent-runtime` 服务只校验 不是常驻 API 服务:镜像在开发机预先构建并导入服务器,服务器上的 `sandbox` 服务只校验
镜像存在,随后以状态码 0 退出;后端再通过 Docker Socket 为每个 Agent Session 动态创建隔离容器。 镜像存在,随后以状态码 0 退出;后端再通过 Docker Socket 为每个 Agent Session 动态创建隔离容器。
1. 在开发机构建并导出 Agent Runtime 镜像: 1. 在开发机构建并导出沙箱镜像:
```bash ```bash
docker build -t smart-factory-agent-runtime:0.1.0 -f sandbox/Dockerfile sandbox docker build -t agent-sandbox:0.1 -f sandbox/Dockerfile sandbox
docker save -o smart-factory-agent-runtime-0.1.0.tar smart-factory-agent-runtime:0.1.0 docker save -o agent-sandbox-0.1.tar agent-sandbox:0.1
``` ```
将 Tar 文件传到服务器后导入。Compose 设置了 `pull_policy: never`,若镜像不存在会直接报错, 将 Tar 文件传到服务器后导入。Compose 设置了 `pull_policy: never`,若镜像不存在会直接报错,
不会在服务器自动拉取或构建: 不会在服务器自动拉取或构建:
```bash ```bash
docker load -i smart-factory-agent-runtime-0.1.0.tar docker load -i agent-sandbox-0.1.tar
docker image inspect smart-factory-agent-runtime:0.1.0 docker image inspect agent-sandbox:0.1
``` ```
2. 复制环境变量模板并替换其中的三个必填密码/密钥;生产环境建议使用密码管理系统生成随机值。 2. 复制环境变量模板并替换其中的三个必填密码/密钥;生产环境建议使用密码管理系统生成随机值。
@@ -56,9 +88,9 @@ $env:APP_MASTER_KEY = '<使用独立生成的高强度密钥>'
cp .env.example .env cp .env.example .env
``` ```
3. 确认 `MANUAGENT_DATA_ROOT` 是 Docker 宿主机上的绝对路径。这个路径会以完全相同的路径挂载到 3. Compose 中的镜像、数据库名称、端口和数据目录均使用静态值;调整时直接修改根目录的 `docker-compose.yml`。
后端容器,供动态 Agent Runtime 继续挂载项目材料、工作目录和产物。Linux 默认值为 数据目录固定为 `/srv/manuagent/data`;修改时须同步更新后端的 `APP_DATA_ROOT` 和对应挂载的
`/srv/manuagent/data` `source`、`target`,保持容器内外使用同一个宿主机绝对路径,供动态沙箱挂载项目材料和产物
4. 构建前后端并启动完整服务: 4. 构建前后端并启动完整服务:
@@ -67,7 +99,11 @@ $env:APP_MASTER_KEY = '<使用独立生成的高强度密钥>'
docker compose -f docker-compose.yml ps docker compose -f docker-compose.yml ps
``` ```
5. 打开 <http://127.0.0.1:5173>,使用 `.env` 中的 `APP_ADMIN_USERNAME` 和 根目录的 `docker-compose.yml` 是唯一部署入口。后端镜像使用 Maven 3.9.13 / Temurin 25.0.2
构建和 Temurin 25.0.2 JRE 运行;前端镜像从
`web-ui/apps/web/dist` 复制静态文件。
5. 打开 <http://127.0.0.1:5173>,使用 Compose 中的管理员账号 `admin` 和 `.env` 中的
`APP_ADMIN_PASSWORD` 登录。查看日志或停止服务: `APP_ADMIN_PASSWORD` 登录。查看日志或停止服务:
```bash ```bash
@@ -75,22 +111,42 @@ $env:APP_MASTER_KEY = '<使用独立生成的高强度密钥>'
docker compose -f docker-compose.yml down docker compose -f docker-compose.yml down
``` ```
默认端口映射为 `0.0.0.0:5173:80`。若 Windows 的动态端口排除范围占用了 5173在本机 端口映射为 `0.0.0.0:5173:80`。若 Windows 的动态端口排除范围占用了 5173可在本机验证时
验证时临时执行 `$env:FRONTEND_PORT = '15173'`Linux 服务器部署应保留默认的 5173。 将 Compose 的端口映射临时改为 `0.0.0.0:15173:80`Linux 服务器部署应保留 5173。
PostgreSQL 数据保存在 `postgres-data` 命名卷中项目材料、Agent 工作区、快照和生成文件保存在 PostgreSQL 数据保存在 `postgres-data` 命名卷中项目材料、Agent 工作区、快照和生成文件保存在
`MANUAGENT_DATA_ROOT`。`docker compose down` 不会删除它们,只有显式增加 `--volumes` 才会删除 Compose 配置的数据目录。`docker compose down` 不会删除它们,只有显式增加 `--volumes` 才会删除
PostgreSQL 卷。后端挂载 Docker Socket 等价于授予其管理宿主机容器的高权限,应只在受信任的 PostgreSQL 卷。后端挂载 Docker Socket 等价于授予其管理宿主机容器的高权限,应只在受信任的
Docker 主机上运行,并限制 5173 端口的网络访问范围。通过 HTTPS 反向代理部署时,请将 Docker 主机上运行,并限制 5173 端口的网络访问范围。通过 HTTPS 反向代理部署时,请将
`SESSION_COOKIE_SECURE` 设为 `true`。 Compose 中的 `SERVER_SERVLET_SESSION_COOKIE_SECURE` 设为字符串 `"true"`。
## 验证 ## 验证
```bash ```bash
mvn -q -f server/pom.xml test mvn -f server/pom.xml verify
npm --prefix web-ui run test npm --prefix web-ui test
npm --prefix web-ui run build npm --prefix web-ui run build
docker compose -f docker-compose.yml config --quiet docker compose -f docker-compose.yml config --quiet
``` ```
产品、数据库与验收设计见 [docs](docs/)。内置 Skill 与资源由 Flyway 种子迁移写入数据库,无需额外导入文件。 以上命令分别验证后端测试/打包、前端测试、四个 workspace 的类型检查及单应用构建、Compose 配置。
模块依赖边界仍需在代码审查中核对,当前没有自动边界检查命令。
## 模块边界
| 后端模块 | 前端 workspace | 职责 |
| --- | --- | --- |
| `manuagent-common` | `packages/common` | 错误、TypeHandler前端共享请求、CSRF、基础样式 |
| `manuagent-admin` | `packages/admin` | 用户服务与用户持久化;登录页面 |
| `manuagent-agent` | `packages/agent` | 项目、文件、模型、Skill、Agent 执行、规划与产物;业务页面和侧栏 |
| `manuagent-web` | `apps/web` | HTTP、认证上下文、基础设施装配与启动单应用壳和路由 |
Agent 和 Admin 只依赖 CommonWeb 装配三者。仍是一个后端进程、一个数据源和一个事务体系。
Controller 从登录身份解析用户 UUID业务服务负责事务及审计字段。`AdminProperties` 和
`AgentProperties` 继续绑定原有 `app.*` 键,数据库配置和环境变量名保持兼容。
历史 Flyway 文件保留在 `server/src/main/resources/db/migration`,由 Web 原样打包一份;
请勿迁移、重命名或改写这些已共享脚本。Agent 的 Mapper XML 和提示词随普通库 JAR 加载。
前端只有根目录一份 `package-lock.json`,业务包通过 `@manuagent/*` 的公开 exports 引用。
本次改造的证据与验收边界见 [MANU-1 实现与验收](docs/MANU-1-implementation.md)。内置 Skill 与资源由 Flyway 种子迁移写入数据库,无需额外导入文件。

View File

@@ -1,22 +0,0 @@
# 复制为 .env 后必须替换以下三个敏感值;不要把真实 .env 提交到 Git。
POSTGRES_PASSWORD=replace-with-a-strong-database-password
APP_MASTER_KEY=replace-with-at-least-32-random-characters
APP_ADMIN_PASSWORD=replace-with-a-strong-admin-password
# 百炼知识库未启用时可以保留为空;模型 API Key 仍通过页面配置并加密保存。
DASHSCOPE_API_KEY=
# 非敏感运行参数。
POSTGRES_DB=smart_factory_agent
POSTGRES_USER=smart_factory
APP_ADMIN_USERNAME=admin
APP_RUN_TIMEOUT=60m
SESSION_COOKIE_SECURE=false
MANUAGENT_DATA_ROOT=/srv/manuagent/data
# 默认值即部署要求的 0.0.0.0:5173普通 Linux 部署无需修改。
FRONTEND_PORT=5173
# 可选镜像标签,便于私有镜像仓库或版本升级时覆盖。
AGENT_RUNTIME_IMAGE=smart-factory-agent-runtime:0.1.0
BACKEND_IMAGE=manuagent-backend:0.1.0
FRONTEND_IMAGE=manuagent-frontend:0.1.0

View File

@@ -1,130 +0,0 @@
name: manuagent
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-smart_factory_agent}
POSTGRES_USER: ${POSTGRES_USER:-smart_factory}
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
secrets:
- postgres_password
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- manuagent-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
# 此服务负责校验并登记 AgentScope 使用的 Runtime 镜像。
# Runtime 镜像在开发机预先构建并导入服务器;此服务只校验镜像存在,不在服务器构建或拉取。
# 它成功退出后,后端会通过 Docker Socket 按 Session 动态创建真正执行任务的 Runtime 容器。
agent-runtime:
image: ${AGENT_RUNTIME_IMAGE:-agent-runtime:0.1.0}
pull_policy: never
command: ["/bin/true"]
restart: "no"
networks:
- manuagent-network
backend:
image: ${BACKEND_IMAGE:-manuagent-backend:0.1.0}
build:
context: ./server
dockerfile: Dockerfile
restart: unless-stopped
init: true
depends_on:
postgres:
condition: service_healthy
restart: true
agent-runtime:
condition: service_completed_successfully
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-smart_factory_agent}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-smart_factory}
# Spring Boot 将 secrets 目录中的点分文件名作为配置属性加载,避免把密码写进普通环境变量。
SPRING_CONFIG_IMPORT: optional:configtree:/run/secrets/
APP_DATA_ROOT: ${MANUAGENT_DATA_ROOT:-/srv/manuagent/data}
APP_DASHSCOPE_KEY_FILE: /run/secrets/dashscope_key
APP_ADMIN_USERNAME: ${APP_ADMIN_USERNAME:-admin}
APP_SANDBOX_IMAGE: ${AGENT_RUNTIME_IMAGE:-smart-factory-agent-runtime:0.1.0}
APP_SANDBOX_NETWORK: manuagent-network
APP_RUN_TIMEOUT: ${APP_RUN_TIMEOUT:-60m}
SERVER_SERVLET_SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
secrets:
- source: postgres_password
target: spring.datasource.password
- source: app_master_key
target: app.master-key
- source: admin_password
target: app.admin-password
- source: dashscope_api_key
target: dashscope_key
volumes:
# DockerSandbox 的 bind mount 源路径由宿主机 daemon 解释,因此容器内外必须使用相同绝对路径。
- type: bind
source: ${MANUAGENT_DATA_ROOT:-/srv/manuagent/data}
target: ${MANUAGENT_DATA_ROOT:-/srv/manuagent/data}
# AgentScope 需要通过宿主机 Docker Engine 创建、执行并销毁隔离的 Runtime 容器。
- type: bind
source: /var/run/docker.sock
target: /var/run/docker.sock
expose:
- "8080"
networks:
- manuagent-network
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:8080/api/auth/csrf"]
interval: 10s
timeout: 5s
retries: 18
start_period: 30s
stop_grace_period: 30s
frontend:
image: ${FRONTEND_IMAGE:-manuagent-frontend:0.1.0}
build:
context: ./web-ui
dockerfile: Dockerfile
restart: unless-stopped
init: true
depends_on:
backend:
condition: service_healthy
restart: true
ports:
# 默认严格监听 0.0.0.0:5173仅当宿主机保留该端口时才通过 FRONTEND_PORT 临时覆盖。
- "0.0.0.0:${FRONTEND_PORT:-5173}:80"
networks:
- manuagent-network
healthcheck:
test: ["CMD", "wget", "--quiet", "--output-document=/dev/null", "http://127.0.0.1/"]
interval: 10s
timeout: 5s
retries: 6
start_period: 10s
networks:
manuagent-network:
# 固定网络名,确保后端动态创建的 Agent Runtime 容器可以加入同一个网络。
name: manuagent-network
driver: bridge
volumes:
postgres-data:
secrets:
postgres_password:
environment: POSTGRES_PASSWORD
app_master_key:
environment: APP_MASTER_KEY
admin_password:
environment: APP_ADMIN_PASSWORD
dashscope_api_key:
environment: DASHSCOPE_API_KEY

View File

@@ -5,8 +5,8 @@ services:
image: postgres:17-alpine image: postgres:17-alpine
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-smart_factory_agent} POSTGRES_DB: smart_factory_agent
POSTGRES_USER: ${POSTGRES_USER:-smart_factory} POSTGRES_USER: smart_factory
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
secrets: secrets:
- postgres_password - postgres_password
@@ -15,17 +15,17 @@ services:
networks: networks:
- manuagent-network - manuagent-network
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] test: ["CMD", "pg_isready", "-U", "smart_factory", "-d", "smart_factory_agent"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 20 retries: 20
start_period: 10s start_period: 10s
# 此服务负责校验并登记 AgentScope 使用的 Runtime 镜像。 # 此服务负责校验并登记 AgentScope 使用的沙箱镜像。
# Runtime 镜像在开发机预先构建并导入服务器;此服务只校验镜像存在,不在服务器构建或拉取。 # 沙箱镜像在开发机预先构建并导入服务器;此服务只校验镜像存在,不在服务器构建或拉取。
# 它成功退出后,后端会通过 Docker Socket 按 Session 动态创建真正执行任务的 Runtime 容器。 # 它成功退出后,后端会通过 Docker Socket 按 Session 动态创建真正执行任务的沙箱容器。
agent-runtime: sandbox:
image: ${AGENT_RUNTIME_IMAGE:-agent-runtime:0.1.0} image: agent-sandbox:0.1
pull_policy: never pull_policy: never
command: ["/bin/true"] command: ["/bin/true"]
restart: "no" restart: "no"
@@ -33,7 +33,7 @@ services:
- manuagent-network - manuagent-network
backend: backend:
image: ${BACKEND_IMAGE:-manuagent-backend:0.1.0} image: manuagent-backend:0.1.0
build: build:
context: ./server context: ./server
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -43,20 +43,21 @@ services:
postgres: postgres:
condition: service_healthy condition: service_healthy
restart: true restart: true
agent-runtime: sandbox:
condition: service_completed_successfully condition: service_completed_successfully
environment: environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-smart_factory_agent} SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/smart_factory_agent
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-smart_factory} SPRING_DATASOURCE_USERNAME: smart_factory
# Spring Boot 将 secrets 目录中的点分文件名作为配置属性加载,避免把密码写进普通环境变量。 # Spring Boot 将 secrets 目录中的点分文件名作为配置属性加载,避免把密码写进普通环境变量。
SPRING_CONFIG_IMPORT: optional:configtree:/run/secrets/ SPRING_CONFIG_IMPORT: optional:configtree:/run/secrets/
APP_DATA_ROOT: ${MANUAGENT_DATA_ROOT:-/srv/manuagent/data} APP_DATA_ROOT: /srv/manuagent/data
APP_DASHSCOPE_KEY_FILE: /run/secrets/dashscope_key APP_DASHSCOPE_KEY_FILE: /run/secrets/dashscope_key
APP_ADMIN_USERNAME: ${APP_ADMIN_USERNAME:-admin} APP_ADMIN_USERNAME: admin
APP_SANDBOX_IMAGE: ${AGENT_RUNTIME_IMAGE:-smart-factory-agent-runtime:0.1.0} APP_SANDBOX_IMAGE: agent-sandbox:0.1
APP_SANDBOX_NETWORK: manuagent-network APP_SANDBOX_NETWORK: manuagent-network
APP_RUN_TIMEOUT: ${APP_RUN_TIMEOUT:-60m} APP_RUN_TIMEOUT: 60m
SERVER_SERVLET_SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false} APP_MAX_CONCURRENT_RUNS: "2"
SERVER_SERVLET_SESSION_COOKIE_SECURE: "false"
secrets: secrets:
- source: postgres_password - source: postgres_password
target: spring.datasource.password target: spring.datasource.password
@@ -69,9 +70,9 @@ services:
volumes: volumes:
# DockerSandbox 的 bind mount 源路径由宿主机 daemon 解释,因此容器内外必须使用相同绝对路径。 # DockerSandbox 的 bind mount 源路径由宿主机 daemon 解释,因此容器内外必须使用相同绝对路径。
- type: bind - type: bind
source: ${MANUAGENT_DATA_ROOT:-/srv/manuagent/data} source: /srv/manuagent/data
target: ${MANUAGENT_DATA_ROOT:-/srv/manuagent/data} target: /srv/manuagent/data
# AgentScope 需要通过宿主机 Docker Engine 创建、执行并销毁隔离的 Runtime 容器。 # AgentScope 需要通过宿主机 Docker Engine 创建、执行并销毁隔离的沙箱容器。
- type: bind - type: bind
source: /var/run/docker.sock source: /var/run/docker.sock
target: /var/run/docker.sock target: /var/run/docker.sock
@@ -88,7 +89,7 @@ services:
stop_grace_period: 30s stop_grace_period: 30s
frontend: frontend:
image: ${FRONTEND_IMAGE:-manuagent-frontend:0.1.0} image: manuagent-frontend:0.1.0
build: build:
context: ./web-ui context: ./web-ui
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -99,8 +100,8 @@ services:
condition: service_healthy condition: service_healthy
restart: true restart: true
ports: ports:
# 默认严格监听 0.0.0.0:5173仅当宿主机保留该端口时,才通过 FRONTEND_PORT 临时覆盖 # 固定监听 0.0.0.0:5173需要调整端口时直接修改此映射
- "0.0.0.0:${FRONTEND_PORT:-5173}:80" - "0.0.0.0:5173:80"
networks: networks:
- manuagent-network - manuagent-network
healthcheck: healthcheck:
@@ -112,7 +113,7 @@ services:
networks: networks:
manuagent-network: manuagent-network:
# 固定网络名,确保后端动态创建的 Agent Runtime 容器可以加入同一个网络。 # 固定网络名,确保后端动态创建的沙箱容器可以加入同一个网络。
name: manuagent-network name: manuagent-network
driver: bridge driver: bridge

View File

@@ -6,12 +6,12 @@ COPY --from=node /usr/local /usr/local
ENV JAVA_HOME=/opt/java/openjdk \ ENV JAVA_HOME=/opt/java/openjdk \
PATH=/opt/java/openjdk/bin:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin \ PATH=/opt/java/openjdk/bin:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin \
NODE_PATH=/opt/agent-runtime/node_modules \ NODE_PATH=/opt/sandbox/node_modules \
PLAYWRIGHT_BROWSERS_PATH=/opt/agent-runtime/ms-playwright \ PLAYWRIGHT_BROWSERS_PATH=/opt/sandbox/ms-playwright \
PYTHONDONTWRITEBYTECODE=1 \ PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1
WORKDIR /opt/agent-runtime WORKDIR /opt/sandbox
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --omit=dev \ RUN npm ci --omit=dev \
&& pip install --no-cache-dir \ && pip install --no-cache-dir \
@@ -34,7 +34,7 @@ RUN ln -s /opt/java/openjdk/bin/java /usr/local/bin/java \
COPY fonts-local.conf /etc/fonts/local.conf COPY fonts-local.conf /etc/fonts/local.conf
RUN fc-cache -f RUN fc-cache -f
COPY --chown=agent:agent document_view.py ./ COPY --chown=agent:agent document_view.py docx-example.cjs ./
USER agent USER agent
WORKDIR /workspace WORKDIR /workspace

30
sandbox/docx-example.cjs Normal file
View File

@@ -0,0 +1,30 @@
// 使用 Runtime 实际安装的 docx API可作为表格与原生批注的最小验证脚本。
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const { Document, Paragraph, TextRun, Table, TableRow, TableCell, Packer,
CommentRangeStart, CommentRangeEnd, CommentReference } = require('docx');
async function main() {
const output = process.argv[2];
assert(output, '用法node docx-example.cjs <输出.docx>');
const document = new Document({
comments: { children: [{ id: 0, author: '材料评审', date: new Date(),
children: [new Paragraph({ children: [new TextRun('请企业确认设备数量。')] })] }] },
sections: [{ children: [
new Paragraph({ children: [
new CommentRangeStart(0), new TextRun('待企业提供设备数量。'),
new CommentRangeEnd(0), new TextRun({ children: [new CommentReference(0)] })
] }),
new Table({ rows: [new TableRow({ children: ['项目', '待确认'].map(text =>
new TableCell({ children: [new Paragraph({ children: [new TextRun(text)] })] })) })] })
] }]
});
const buffer = await Packer.toBuffer(document);
assert.equal(buffer.subarray(0, 2).toString(), 'PK');
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, buffer);
console.log(`DOCX API 验证通过:${buffer.length} 字节`);
}
main().catch(error => { console.error(error); process.exitCode = 1; });

View File

@@ -1,11 +1,11 @@
{ {
"name": "smart-factory-agent-runtime", "name": "agent-sandbox",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "smart-factory-agent-runtime", "name": "agent-sandbox",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"docx": "^9.5.1", "docx": "^9.5.1",

View File

@@ -1,5 +1,5 @@
{ {
"name": "smart-factory-agent-runtime", "name": "agent-sandbox",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
# Agent Runtime 的目录通过宿主机 Docker daemon 再次挂载,因此后端必须能写入共享数据根目录。 # 沙箱的目录通过宿主机 Docker daemon 再次挂载,因此后端必须能写入共享数据根目录。
data_root="${APP_DATA_ROOT:-/srv/manuagent/data}" data_root="${APP_DATA_ROOT:-/srv/manuagent/data}"
mkdir -p "$data_root" mkdir -p "$data_root"
chown manuagent:manuagent "$data_root" chown manuagent:manuagent "$data_root"
@@ -10,7 +10,7 @@ chown manuagent:manuagent "$data_root"
# 启动时读取真实组 ID 并把低权限应用用户加入对应组,避免以 root 身份运行 Spring Boot。 # 启动时读取真实组 ID 并把低权限应用用户加入对应组,避免以 root 身份运行 Spring Boot。
docker_socket="/var/run/docker.sock" docker_socket="/var/run/docker.sock"
if [ ! -S "$docker_socket" ]; then if [ ! -S "$docker_socket" ]; then
echo "错误:未挂载 $docker_socket,后端无法创建 Agent Runtime 容器。" >&2 echo "错误:未挂载 $docker_socket,后端无法创建沙箱容器。" >&2
exit 1 exit 1
fi 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.Id;
import com.mybatisflex.annotation.KeyType; import com.mybatisflex.annotation.KeyType;
@@ -7,7 +7,7 @@ import com.mybatisflex.annotation.Column;
import com.mybatisflex.core.keygen.KeyGenerators; import com.mybatisflex.core.keygen.KeyGenerators;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.UUID; import java.util.UUID;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler; import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/** /**
* 映射 {@code app.app_user} 表的管理员用户实体 * 映射 {@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 com.mybatisflex.core.BaseMapper;
import tech.easyflow.manuagent.entity.AppUserEntity; import tech.easyflow.manuagent.admin.auth.AppUserEntity;
/** /**
* 提供 {@code app.app_user} 表的 MyBatis-Flex 基础数据访问能力 * 提供 {@code app.app_user} 表的 MyBatis-Flex 基础数据访问能力
@@ -9,5 +9,6 @@ import tech.easyflow.manuagent.entity.AppUserEntity;
* <p>用户表只有简单单表操作因此直接使用 {@link BaseMapper} Lambda QueryWrapper * <p>用户表只有简单单表操作因此直接使用 {@link BaseMapper} Lambda QueryWrapper
* 不额外维护 Mapper XML</p> * 不额外维护 Mapper XML</p>
*/ */
@org.apache.ibatis.annotations.Mapper
public interface AppUserMapper extends BaseMapper<AppUserEntity> { 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 com.mybatisflex.core.query.QueryWrapper;
import java.util.UUID; 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.http.HttpStatus;
import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails; 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.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties; import tech.easyflow.manuagent.admin.config.AdminProperties;
import tech.easyflow.manuagent.entity.AppUserEntity; import tech.easyflow.manuagent.admin.auth.AppUserEntity;
import tech.easyflow.manuagent.mapper.AppUserMapper; import tech.easyflow.manuagent.admin.auth.AppUserMapper;
/** /**
* 管理单管理员账户和当前用户标识 * 管理单管理员账户和当前用户标识
*/ */
@Service @Service
@Order(1) public class UserService implements UserDetailsService {
public class UserService implements UserDetailsService, ApplicationRunner {
private final AppUserMapper userMapper; private final AppUserMapper userMapper;
private final PasswordEncoder passwordEncoder; 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 passwordEncoder 密码编码器
* @param properties 应用配置 * @param properties 应用配置
*/ */
public UserService(AppUserMapper userMapper, PasswordEncoder passwordEncoder, AppProperties properties) { public UserService(AppUserMapper userMapper, PasswordEncoder passwordEncoder, AdminProperties properties) {
this.userMapper = userMapper; this.userMapper = userMapper;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.properties = properties; this.properties = properties;
@@ -44,10 +40,8 @@ public class UserService implements UserDetailsService, ApplicationRunner {
/** /**
* 初始化本地管理员账户 * 初始化本地管理员账户
* *
* @param args 启动参数
*/ */
@Override public void initializeAdministrator() {
public void run(ApplicationArguments args) {
long count = userMapper.selectCountByQuery(QueryWrapper.create()); long count = userMapper.selectCountByQuery(QueryWrapper.create());
if (count > 0) { if (count > 0) {
return; 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; 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.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties; import tech.easyflow.manuagent.admin.config.AdminProperties;
import tech.easyflow.manuagent.entity.AppUserEntity; import tech.easyflow.manuagent.admin.auth.AppUserEntity;
import tech.easyflow.manuagent.mapper.AppUserMapper; import tech.easyflow.manuagent.admin.auth.AppUserMapper;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler; import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/** /**
* 验证 {@link UserService} 在迁移到 MyBatis-Flex 后保持原有管理员初始化与认证语义 * 验证 {@link UserService} 在迁移到 MyBatis-Flex 后保持原有管理员初始化与认证语义
@@ -33,7 +33,7 @@ class UserServiceTest {
private AppUserMapper mapper; private AppUserMapper mapper;
private PasswordEncoder passwordEncoder; private PasswordEncoder passwordEncoder;
private AppProperties properties; private AdminProperties properties;
private UserService service; private UserService service;
/** /**
@@ -52,7 +52,7 @@ class UserServiceTest {
mapper = mock(AppUserMapper.class); mapper = mock(AppUserMapper.class);
passwordEncoder = mock(PasswordEncoder.class); passwordEncoder = mock(PasswordEncoder.class);
properties = mock(AppProperties.class); properties = mock(AdminProperties.class);
service = new UserService(mapper, passwordEncoder, properties); service = new UserService(mapper, passwordEncoder, properties);
} }
@@ -66,7 +66,7 @@ class UserServiceTest {
when(properties.adminPassword()).thenReturn("plain-password"); when(properties.adminPassword()).thenReturn("plain-password");
when(passwordEncoder.encode("plain-password")).thenReturn("encoded-password"); when(passwordEncoder.encode("plain-password")).thenReturn("encoded-password");
service.run(null); service.initializeAdministrator();
ArgumentCaptor<AppUserEntity> captor = ArgumentCaptor.forClass(AppUserEntity.class); ArgumentCaptor<AppUserEntity> captor = ArgumentCaptor.forClass(AppUserEntity.class);
verify(mapper).insertSelective(captor.capture()); verify(mapper).insertSelective(captor.capture());
@@ -84,7 +84,7 @@ class UserServiceTest {
void shouldNotCreateAdministratorWhenAnyUserExists() { void shouldNotCreateAdministratorWhenAnyUserExists() {
when(mapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(1L); when(mapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(1L);
service.run(null); service.initializeAdministrator();
verify(mapper, never()).insertSelective(any(AppUserEntity.class)); 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.Column;
import com.mybatisflex.annotation.Id; import com.mybatisflex.annotation.Id;
@@ -7,8 +7,8 @@ import com.mybatisflex.annotation.Table;
import com.mybatisflex.core.keygen.KeyGenerators; import com.mybatisflex.core.keygen.KeyGenerators;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.UUID; import java.util.UUID;
import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler; import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler; import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/** /**
* 映射 {@code app.artifact} 表的最终产物实体 * 映射 {@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 com.mybatisflex.core.BaseMapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.ArtifactEntity; import tech.easyflow.manuagent.agent.artifact.ArtifactEntity;
/** /**
* 提供产物基础查询以及 PostgreSQL 原子 upsert 能力 * 提供产物基础查询以及 PostgreSQL 原子 upsert 能力
*/ */
@org.apache.ibatis.annotations.Mapper
public interface ArtifactMapper extends BaseMapper<ArtifactEntity> { 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 com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.entity.ArtifactEntity; import tech.easyflow.manuagent.agent.artifact.ArtifactEntity;
import tech.easyflow.manuagent.mapper.ArtifactMapper; import tech.easyflow.manuagent.agent.artifact.ArtifactMapper;
import tech.easyflow.manuagent.project.ProjectFileService; import tech.easyflow.manuagent.agent.project.ProjectFileService;
import com.fasterxml.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.file.Files; import java.nio.file.Files;
@@ -78,9 +78,9 @@ public class ArtifactService {
} }
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_COPY_FAILED", "申报书发布失败"); throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_COPY_FAILED", "申报书发布失败");
} }
com.fasterxml.jackson.databind.node.ObjectNode enriched = metadata.isObject() tools.jackson.databind.node.ObjectNode enriched = metadata.isObject()
? ((com.fasterxml.jackson.databind.node.ObjectNode) metadata).deepCopy() ? ((tools.jackson.databind.node.ObjectNode) metadata).deepCopy()
: com.fasterxml.jackson.databind.node.JsonNodeFactory.instance.objectNode(); : tools.jackson.databind.node.JsonNodeFactory.instance.objectNode();
enriched.put("docxValidated", true); enriched.put("docxValidated", true);
enriched.put("docxEntries", validation.entryCount()); enriched.put("docxEntries", validation.entryCount());
enriched.put("commentCount", validation.commentCount()); 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 tech.easyflow.manuagent.common.ApiException;
import java.io.IOException; 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.nio.file.Path;
import java.time.Duration; import java.time.Duration;
@@ -10,19 +10,15 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* @param dataRoot 项目材料工作区和产物根目录 * @param dataRoot 项目材料工作区和产物根目录
* @param dashscopeKeyFile 百炼 Key 文件 * @param dashscopeKeyFile 百炼 Key 文件
* @param masterKey 模型密钥加密主密钥 * @param masterKey 模型密钥加密主密钥
* @param adminUsername 本地管理员用户名
* @param adminPassword 本地管理员初始密码
* @param sandboxImage Agent Docker 运行镜像 * @param sandboxImage Agent Docker 运行镜像
* @param sandboxNetwork Agent Docker 网络 * @param sandboxNetwork Agent Docker 网络
* @param runTimeout 单次 Agent 运行超时 * @param runTimeout 单次 Agent 运行超时
*/ */
@ConfigurationProperties(prefix = "app") @ConfigurationProperties(prefix = "app")
public record AppProperties( public record AgentProperties(
Path dataRoot, Path dataRoot,
Path dashscopeKeyFile, Path dashscopeKeyFile,
String masterKey, String masterKey,
String adminUsername,
String adminPassword,
String sandboxImage, String sandboxImage,
String sandboxNetwork, String sandboxNetwork,
Duration runTimeout) { 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.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties; import tech.easyflow.manuagent.agent.config.AgentProperties;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException; import java.security.GeneralSecurityException;
@@ -31,7 +31,7 @@ public class KeyCipher {
* *
* @param properties 应用配置 * @param properties 应用配置
*/ */
public KeyCipher(AppProperties properties) { public KeyCipher(AgentProperties properties) {
try { try {
byte[] digest = MessageDigest.getInstance("SHA-256") byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(properties.masterKey().getBytes(StandardCharsets.UTF_8)); .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.Column;
import com.mybatisflex.annotation.Id; import com.mybatisflex.annotation.Id;
@@ -6,7 +6,7 @@ import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table; import com.mybatisflex.annotation.Table;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.UUID; import java.util.UUID;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler; import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/** /**
* 映射 {@code app.model_assignment} 表的 Agent 角色模型分配实体 * 映射 {@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 com.mybatisflex.core.BaseMapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.ModelAssignmentEntity; import tech.easyflow.manuagent.agent.model.ModelAssignmentEntity;
/** /**
* 提供 Agent 角色模型分配及 PostgreSQL 原子 upsert * 提供 Agent 角色模型分配及 PostgreSQL 原子 upsert
*/ */
@org.apache.ibatis.annotations.Mapper
public interface ModelAssignmentMapper extends BaseMapper<ModelAssignmentEntity> { 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.Column;
import com.mybatisflex.annotation.Id; import com.mybatisflex.annotation.Id;
@@ -8,8 +8,8 @@ import com.mybatisflex.core.keygen.KeyGenerators;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.UUID; import java.util.UUID;
import org.apache.ibatis.type.JdbcType; import org.apache.ibatis.type.JdbcType;
import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler; import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler; import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/** /**
* 映射 {@code app.model_config} 表的模型配置与加密密钥实体 * 映射 {@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 com.mybatisflex.core.BaseMapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.ModelConfigEntity; import tech.easyflow.manuagent.agent.model.ModelConfigEntity;
/** /**
* 提供模型配置的 MyBatis-Flex CRUD 能力 * 提供模型配置的 MyBatis-Flex CRUD 能力
@@ -10,6 +10,7 @@ import tech.easyflow.manuagent.entity.ModelConfigEntity;
* <p>普通查询和条件更新继续复用 {@link BaseMapper}包含 PostgreSQL JSONB 参数的新增更新 * <p>普通查询和条件更新继续复用 {@link BaseMapper}包含 PostgreSQL JSONB 参数的新增更新
* 使用 XML 显式声明 TypeHandler避免写入行为依赖 MyBatis-Flex 全局表元数据的初始化顺序</p> * 使用 XML 显式声明 TypeHandler避免写入行为依赖 MyBatis-Flex 全局表元数据的初始化顺序</p>
*/ */
@org.apache.ibatis.annotations.Mapper
public interface ModelConfigMapper extends BaseMapper<ModelConfigEntity> { 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 com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.entity.ModelAssignmentEntity; import tech.easyflow.manuagent.agent.model.ModelAssignmentEntity;
import tech.easyflow.manuagent.entity.ModelConfigEntity; import tech.easyflow.manuagent.agent.model.ModelConfigEntity;
import tech.easyflow.manuagent.entity.AgentRunEntity; import tech.easyflow.manuagent.agent.runtime.AgentRunEntity;
import tech.easyflow.manuagent.mapper.AgentRunMapper; import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; import tech.easyflow.manuagent.agent.model.ModelAssignmentMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper; import tech.easyflow.manuagent.agent.model.ModelConfigMapper;
import com.fasterxml.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.security.Principal;
import java.time.Duration; import java.time.Duration;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
@@ -38,7 +36,6 @@ public class ModelService {
private final ModelConfigMapper modelMapper; private final ModelConfigMapper modelMapper;
private final ModelAssignmentMapper assignmentMapper; private final ModelAssignmentMapper assignmentMapper;
private final AgentRunMapper runMapper; private final AgentRunMapper runMapper;
private final UserService userService;
private final KeyCipher keyCipher; private final KeyCipher keyCipher;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final HttpClient httpClient; private final HttpClient httpClient;
@@ -49,7 +46,6 @@ public class ModelService {
* @param modelMapper 模型配置 Mapper * @param modelMapper 模型配置 Mapper
* @param assignmentMapper 角色模型分配 Mapper * @param assignmentMapper 角色模型分配 Mapper
* @param runMapper Agent Run Mapper * @param runMapper Agent Run Mapper
* @param userService 用户服务
* @param keyCipher 密钥加密器 * @param keyCipher 密钥加密器
* @param objectMapper JSON 映射器 * @param objectMapper JSON 映射器
*/ */
@@ -58,14 +54,12 @@ public class ModelService {
ModelConfigMapper modelMapper, ModelConfigMapper modelMapper,
ModelAssignmentMapper assignmentMapper, ModelAssignmentMapper assignmentMapper,
AgentRunMapper runMapper, AgentRunMapper runMapper,
UserService userService,
KeyCipher keyCipher, KeyCipher keyCipher,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
this( this(
modelMapper, modelMapper,
assignmentMapper, assignmentMapper,
runMapper, runMapper,
userService,
keyCipher, keyCipher,
objectMapper, objectMapper,
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build()); HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build());
@@ -77,7 +71,6 @@ public class ModelService {
* @param modelMapper 模型配置 Mapper * @param modelMapper 模型配置 Mapper
* @param assignmentMapper 角色模型分配 Mapper * @param assignmentMapper 角色模型分配 Mapper
* @param runMapper Agent Run Mapper * @param runMapper Agent Run Mapper
* @param userService 用户服务
* @param keyCipher 密钥加密器 * @param keyCipher 密钥加密器
* @param objectMapper JSON 映射器 * @param objectMapper JSON 映射器
* @param httpClient 模型连接使用的 HTTP 客户端 * @param httpClient 模型连接使用的 HTTP 客户端
@@ -86,14 +79,12 @@ public class ModelService {
ModelConfigMapper modelMapper, ModelConfigMapper modelMapper,
ModelAssignmentMapper assignmentMapper, ModelAssignmentMapper assignmentMapper,
AgentRunMapper runMapper, AgentRunMapper runMapper,
UserService userService,
KeyCipher keyCipher, KeyCipher keyCipher,
ObjectMapper objectMapper, ObjectMapper objectMapper,
HttpClient httpClient) { HttpClient httpClient) {
this.modelMapper = modelMapper; this.modelMapper = modelMapper;
this.assignmentMapper = assignmentMapper; this.assignmentMapper = assignmentMapper;
this.runMapper = runMapper; this.runMapper = runMapper;
this.userService = userService;
this.keyCipher = keyCipher; this.keyCipher = keyCipher;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.httpClient = httpClient; this.httpClient = httpClient;
@@ -118,12 +109,12 @@ public class ModelService {
* *
* @param id 可选模型 ID * @param id 可选模型 ID
* @param input 模型输入 * @param input 模型输入
* @param principal 当前用户 * @param userId 当前操作者 ID
* @return 保存后的模型 * @return 保存后的模型
*/ */
@Transactional @Transactional
public ModelView save(UUID id, ModelInput input, Principal principal) { public ModelView save(UUID id, ModelInput input, UUID userId) {
UUID userId = userService.requireUserId(principal.getName());
contextWindow(input.capabilities()); contextWindow(input.capabilities());
if (id == null) { if (id == null) {
if (input.apiKey() == null || input.apiKey().isBlank()) { if (input.apiKey() == null || input.apiKey().isBlank()) {
@@ -165,15 +156,15 @@ public class ModelService {
* 将模型设置为所有角色默认模型 * 将模型设置为所有角色默认模型
* *
* @param id 模型 ID * @param id 模型 ID
* @param principal 当前用户 * @param userId 当前操作者 ID
*/ */
@Transactional @Transactional
public void setDefault(UUID id, Principal principal) { public void setDefault(UUID id, UUID userId) {
ModelView target = require(id); ModelView target = require(id);
if (!target.enabled()) { if (!target.enabled()) {
throw new ApiException(HttpStatus.CONFLICT, "MODEL_DISABLED", "停用模型不能设为默认模型"); throw new ApiException(HttpStatus.CONFLICT, "MODEL_DISABLED", "停用模型不能设为默认模型");
} }
UUID userId = userService.requireUserId(principal.getName());
modelMapper.clearDefault(); modelMapper.clearDefault();
if (modelMapper.setDefault(id) != 1) { if (modelMapper.setDefault(id) != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在"); throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
@@ -387,7 +378,7 @@ public class ModelService {
private Map<String, Object> parseCapabilities(String json) { private Map<String, Object> parseCapabilities(String json) {
try { try {
return objectMapper.readValue(json, Map.class); return objectMapper.readValue(json, Map.class);
} catch (IOException exception) { } catch (tools.jackson.core.JacksonException exception) {
throw new ApiException( throw new ApiException(
HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR,
"MODEL_CAPABILITIES_INVALID", "MODEL_CAPABILITIES_INVALID",
@@ -528,7 +519,7 @@ public class ModelService {
private String json(Object value) { private String json(Object value) {
try { try {
return objectMapper.writeValueAsString(value == null ? Map.of() : value); 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", "模型配置无法序列化"); 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.Column;
import com.mybatisflex.annotation.Id; import com.mybatisflex.annotation.Id;
@@ -7,7 +7,7 @@ import com.mybatisflex.annotation.Table;
import com.mybatisflex.core.keygen.KeyGenerators; import com.mybatisflex.core.keygen.KeyGenerators;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.UUID; import java.util.UUID;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler; import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/** /**
* 映射 {@code app.project} 表的企业申报项目实体 * 映射 {@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.Column;
import com.mybatisflex.annotation.Id; import com.mybatisflex.annotation.Id;
@@ -7,7 +7,7 @@ import com.mybatisflex.annotation.Table;
import com.mybatisflex.core.keygen.KeyGenerators; import com.mybatisflex.core.keygen.KeyGenerators;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.UUID; import java.util.UUID;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler; import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/** /**
* 映射 {@code app.project_file} 表的项目材料实体 * 映射 {@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 com.mybatisflex.core.BaseMapper;
import tech.easyflow.manuagent.entity.ProjectFileEntity; import tech.easyflow.manuagent.agent.project.ProjectFileEntity;
/** /**
* 提供 {@code app.project_file} 表的单表持久化能力 * 提供 {@code app.project_file} 表的单表持久化能力
*/ */
@org.apache.ibatis.annotations.Mapper
public interface ProjectFileMapper extends BaseMapper<ProjectFileEntity> { 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 com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties; import tech.easyflow.manuagent.agent.config.AgentProperties;
import tech.easyflow.manuagent.entity.ProjectFileEntity; import tech.easyflow.manuagent.agent.project.ProjectFileEntity;
import tech.easyflow.manuagent.mapper.ProjectFileMapper; import tech.easyflow.manuagent.agent.project.ProjectFileMapper;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -15,7 +14,6 @@ import java.nio.file.Path;
import java.security.DigestInputStream; import java.security.DigestInputStream;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.security.Principal;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.Comparator; import java.util.Comparator;
import java.util.HexFormat; import java.util.HexFormat;
@@ -42,7 +40,6 @@ public class ProjectFileService {
"png", "jpg", "jpeg", "webp", "vsdx", "dwg"); "png", "jpg", "jpeg", "webp", "vsdx", "dwg");
private final ProjectFileMapper fileMapper; private final ProjectFileMapper fileMapper;
private final UserService userService;
private final ProjectService projectService; private final ProjectService projectService;
private final Path dataRoot; private final Path dataRoot;
private final Tika tika = new Tika(); private final Tika tika = new Tika();
@@ -51,17 +48,14 @@ public class ProjectFileService {
* 创建材料服务 * 创建材料服务
* *
* @param fileMapper 项目材料 Mapper * @param fileMapper 项目材料 Mapper
* @param userService 用户服务
* @param projectService 项目服务 * @param projectService 项目服务
* @param properties 应用配置 * @param properties 应用配置
*/ */
public ProjectFileService( public ProjectFileService(
ProjectFileMapper fileMapper, ProjectFileMapper fileMapper,
UserService userService,
ProjectService projectService, ProjectService projectService,
AppProperties properties) { AgentProperties properties) {
this.fileMapper = fileMapper; this.fileMapper = fileMapper;
this.userService = userService;
this.projectService = projectService; this.projectService = projectService;
this.dataRoot = properties.dataRoot().toAbsolutePath().normalize(); this.dataRoot = properties.dataRoot().toAbsolutePath().normalize();
} }
@@ -110,11 +104,11 @@ public class ProjectFileService {
* @param projectId 项目 ID * @param projectId 项目 ID
* @param file 上传文件 * @param file 上传文件
* @param relativePath 浏览器提供的文件夹内相对路径单文件上传时可为空 * @param relativePath 浏览器提供的文件夹内相对路径单文件上传时可为空
* @param principal 当前用户 * @param userId 当前操作者 ID
* @return 文件元数据 * @return 文件元数据
*/ */
@Transactional @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); projectService.require(projectId);
String originalName = safeName(file.getOriginalFilename()); String originalName = safeName(file.getOriginalFilename());
String extension = extension(originalName); String extension = extension(originalName);
@@ -143,7 +137,7 @@ public class ProjectFileService {
} }
Files.move(temporary, target); Files.move(temporary, target);
moved = true; moved = true;
UUID userId = userService.requireUserId(principal.getName());
// 数据库只保存受控路径和摘要selective insert 继续使用状态时间字段的数据库默认值 // 数据库只保存受控路径和摘要selective insert 继续使用状态时间字段的数据库默认值
ProjectFileEntity entity = new ProjectFileEntity(); ProjectFileEntity entity = new ProjectFileEntity();
entity.setId(fileId); 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 com.mybatisflex.core.BaseMapper;
import java.util.UUID; import java.util.UUID;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.ProjectEntity; import tech.easyflow.manuagent.agent.project.ProjectEntity;
/** /**
* 提供项目基础 CRUD阶段更新和项目级联清理所需的显式 SQL 接口 * 提供项目基础 CRUD阶段更新和项目级联清理所需的显式 SQL 接口
*/ */
@org.apache.ibatis.annotations.Mapper
public interface ProjectMapper extends BaseMapper<ProjectEntity> { public interface ProjectMapper extends BaseMapper<ProjectEntity> {
/** 判断项目是否仍有运行中的 Agent。 */ /** 判断项目是否仍有运行中的 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.Column;
import com.mybatisflex.annotation.Id; import com.mybatisflex.annotation.Id;
@@ -8,8 +8,8 @@ import com.mybatisflex.core.keygen.KeyGenerators;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.UUID; import java.util.UUID;
import org.apache.ibatis.type.JdbcType; import org.apache.ibatis.type.JdbcType;
import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler; import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler; import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/** /**
* 映射 {@code app.project_plan} 表的不可变规划版本实体 * 映射 {@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 com.mybatisflex.core.BaseMapper;
import java.util.UUID; import java.util.UUID;
import org.apache.ibatis.annotations.Param; 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> { 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 com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.entity.ProjectEntity; import tech.easyflow.manuagent.agent.project.ProjectEntity;
import tech.easyflow.manuagent.entity.ProjectPlanEntity; import tech.easyflow.manuagent.agent.project.ProjectPlanEntity;
import tech.easyflow.manuagent.mapper.ProjectMapper; import tech.easyflow.manuagent.agent.project.ProjectMapper;
import tech.easyflow.manuagent.mapper.ProjectPlanMapper; import tech.easyflow.manuagent.agent.project.ProjectPlanMapper;
import com.fasterxml.jackson.core.JsonProcessingException; import tools.jackson.core.JacksonException;
import com.fasterxml.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import java.security.Principal;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -26,7 +24,6 @@ public class ProjectService {
private final ProjectMapper projectMapper; private final ProjectMapper projectMapper;
private final ProjectPlanMapper planMapper; private final ProjectPlanMapper planMapper;
private final UserService userService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
/** /**
@@ -34,17 +31,14 @@ public class ProjectService {
* *
* @param projectMapper 项目 Mapper * @param projectMapper 项目 Mapper
* @param planMapper 规划版本 Mapper * @param planMapper 规划版本 Mapper
* @param userService 用户服务
* @param objectMapper JSON 映射器 * @param objectMapper JSON 映射器
*/ */
public ProjectService( public ProjectService(
ProjectMapper projectMapper, ProjectMapper projectMapper,
ProjectPlanMapper planMapper, ProjectPlanMapper planMapper,
UserService userService,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
this.projectMapper = projectMapper; this.projectMapper = projectMapper;
this.planMapper = planMapper; this.planMapper = planMapper;
this.userService = userService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@@ -53,14 +47,14 @@ public class ProjectService {
* *
* @param companyName 企业名称 * @param companyName 企业名称
* @param applicationLevel 申报等级 * @param applicationLevel 申报等级
* @param principal 当前用户 * @param userId 当前操作者 ID
* @return 新项目 * @return 新项目
*/ */
@Transactional @Transactional
public ProjectView create(String companyName, String applicationLevel, Principal principal) { public ProjectView create(String companyName, String applicationLevel, UUID userId) {
String level = normalizeLevel(applicationLevel); String level = normalizeLevel(applicationLevel);
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
UUID userId = userService.requireUserId(principal.getName());
String threadId = "project-" + id; String threadId = "project-" + id;
ProjectEntity entity = new ProjectEntity(); ProjectEntity entity = new ProjectEntity();
entity.setId(id); entity.setId(id);
@@ -176,12 +170,12 @@ public class ProjectService {
* @param projectId 项目 ID * @param projectId 项目 ID
* @param planId 草稿规划 ID * @param planId 草稿规划 ID
* @param plan 用户确认后的完整规划 * @param plan 用户确认后的完整规划
* @param principal 当前用户 * @param userId 当前操作者 ID
* @return 已确认规划 * @return 已确认规划
*/ */
@Transactional @Transactional
public PlanView confirmPlan(UUID projectId, UUID planId, JsonNode plan, Principal principal) { public PlanView confirmPlan(UUID projectId, UUID planId, JsonNode plan, UUID userId) {
UUID userId = userService.requireUserId(principal.getName());
ProjectPlanEntity confirmed = planMapper.confirmDraft(projectId, planId, plan.toString(), userId); ProjectPlanEntity confirmed = planMapper.confirmDraft(projectId, planId, plan.toString(), userId);
if (confirmed == null) { if (confirmed == null) {
throw new ApiException(HttpStatus.CONFLICT, "PLAN_ALREADY_CONFIRMED", "规划已确认或版本不存在"); throw new ApiException(HttpStatus.CONFLICT, "PLAN_ALREADY_CONFIRMED", "规划已确认或版本不存在");
@@ -201,7 +195,7 @@ public class ProjectService {
objectMapper.readTree(entity.getPlanJson()), objectMapper.readTree(entity.getPlanJson()),
entity.getConfirmedAt(), entity.getConfirmedAt(),
entity.getCreatedAt()); entity.getCreatedAt());
} catch (JsonProcessingException exception) { } catch (JacksonException exception) {
// 迁移前 ResultSet 映射会将损坏的存量 JSON 作为未预期数据库读取异常处理不新增业务错误码 // 迁移前 ResultSet 映射会将损坏的存量 JSON 作为未预期数据库读取异常处理不新增业务错误码
throw new IllegalStateException("规划 JSON 无法解析", exception); 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.Column;
import com.mybatisflex.annotation.Id; import com.mybatisflex.annotation.Id;
@@ -7,8 +7,8 @@ import com.mybatisflex.annotation.Table;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.UUID; import java.util.UUID;
import org.apache.ibatis.type.JdbcType; import org.apache.ibatis.type.JdbcType;
import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler; import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler; import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/** /**
* 映射 {@code app.agent_event} 表的持久化 Agent 事件 * 映射 {@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 com.mybatisflex.core.BaseMapper;
import java.util.UUID; import java.util.UUID;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.AgentEventEntity; import tech.easyflow.manuagent.agent.runtime.AgentEventEntity;
/** /**
* 提供 Agent 事件写入游标回放及运行恢复所需的持久化能力 * 提供 Agent 事件写入游标回放及运行恢复所需的持久化能力
*/ */
@org.apache.ibatis.annotations.Mapper
public interface AgentEventMapper extends BaseMapper<AgentEventEntity> { 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 tools.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode; import tools.jackson.databind.node.ObjectNode;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
@@ -17,8 +17,8 @@ import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks; import reactor.core.publisher.Sinks;
import reactor.core.scheduler.Schedulers; import reactor.core.scheduler.Schedulers;
import tech.easyflow.manuagent.entity.AgentEventEntity; import tech.easyflow.manuagent.agent.runtime.AgentEventEntity;
import tech.easyflow.manuagent.mapper.AgentEventMapper; import tech.easyflow.manuagent.agent.runtime.AgentEventMapper;
/** /**
* 持久化并查询项目级 AG-UI 事件 * 持久化并查询项目级 AG-UI 事件
@@ -175,7 +175,7 @@ public class AgentEventService {
entity.getEventType(), entity.getEventType(),
objectMapper.readTree(entity.getPayloadJson()), objectMapper.readTree(entity.getPayloadJson()),
entity.getCreatedAt()); entity.getCreatedAt());
} catch (com.fasterxml.jackson.core.JsonProcessingException exception) { } catch (tools.jackson.core.JacksonException exception) {
throw new IllegalStateException("Agent 事件 JSON 无法解析", 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.agent.project.ProjectFileService;
import tech.easyflow.manuagent.project.ProjectService; import tech.easyflow.manuagent.agent.project.ProjectService;
import tech.easyflow.manuagent.skill.SkillService; import tech.easyflow.manuagent.agent.skill.SkillService;
import com.fasterxml.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode; import tools.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode; import tools.jackson.databind.node.ObjectNode;
import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.agui.event.AguiEvent;
import io.agentscope.core.agui.model.AguiMessage; import io.agentscope.core.agui.model.AguiMessage;
import io.agentscope.core.agui.model.RunAgentInput; import io.agentscope.core.agui.model.RunAgentInput;
import io.agentscope.core.model.ModelHttpException; import io.agentscope.core.model.ModelHttpException;
import io.agentscope.core.model.transport.HttpTransportException; import io.agentscope.core.model.transport.HttpTransportException;
import java.time.Duration; 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.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
@@ -36,6 +40,7 @@ public class AgentExecutionService {
private final AgentEventService eventService; private final AgentEventService eventService;
private final ProjectFileService fileService; private final ProjectFileService fileService;
private final SkillService skillService; private final SkillService skillService;
private final Duration runTimeout;
/** /**
* 创建 Agent 执行服务 * 创建 Agent 执行服务
@@ -45,18 +50,24 @@ public class AgentExecutionService {
* @param eventService 事件服务 * @param eventService 事件服务
* @param fileService 工作区服务 * @param fileService 工作区服务
* @param skillService Skill 服务 * @param skillService Skill 服务
* @param properties Run 总时限配置
*/ */
public AgentExecutionService( public AgentExecutionService(
ObjectMapper objectMapper, ObjectMapper objectMapper,
AgentFactory agentFactory, AgentFactory agentFactory,
AgentEventService eventService, AgentEventService eventService,
ProjectFileService fileService, ProjectFileService fileService,
SkillService skillService) { SkillService skillService,
AgentProperties properties) {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.agentFactory = agentFactory; this.agentFactory = agentFactory;
this.eventService = eventService; this.eventService = eventService;
this.fileService = fileService; this.fileService = fileService;
this.skillService = skillService; 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()); EventAccumulator accumulator = new EventAccumulator(project.id(), run.id());
for (int reconnects = 0; ; reconnects++) { for (int reconnects = 0; ; reconnects++) {
ensureRunning.run(); ensureRunning.run();
remainingTime(run);
String attemptPrompt = reconnects == 0 ? prompt : """ String attemptPrompt = reconnects == 0 ? prompt : """
模型连接刚刚中断请恢复同一线程的会话状态读取 MEMORY.md 和工作区已有成果 模型连接刚刚中断请恢复同一线程的会话状态读取 MEMORY.md 和工作区已有成果
检查未完成的输出后从中断处继续复用已经完成的工具结果不要重复已完成操作 检查未完成的输出后从中断处继续复用已经完成的工具结果不要重复已完成操作
@@ -90,13 +102,15 @@ public class AgentExecutionService {
.build(); .build();
try (AgentFactory.AgentHandle handle = agentFactory.create( try (AgentFactory.AgentHandle handle = agentFactory.create(
project.id(), run.modelConfigId(), skillService.enabledNames())) { project.id(), run.modelConfigId(), skillService.enabledNames())) {
Mono<Void> deadline = Mono.delay(remainingTime(run)).then(Mono.error(timeoutFailure()));
handle.adapter().run(input) handle.adapter().run(input)
.takeUntilOther(stopSignal) .takeUntilOther(Mono.firstWithSignal(stopSignal, deadline))
.bufferTimeout(64, Duration.ofMillis(120)) .bufferTimeout(64, Duration.ofMillis(120))
.doOnNext(accumulator::accept) .doOnNext(batch -> accumulator.accept(batch, handle.runtime()))
.blockLast(); .blockLast();
accumulator.flush(); accumulator.flush();
ensureRunning.run(); ensureRunning.run();
remainingTime(run);
return; return;
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
accumulator.flush(); accumulator.flush();
@@ -109,7 +123,7 @@ public class AgentExecutionService {
int attempt = reconnects + 1; int attempt = reconnects + 1;
eventService.append(project.id(), run.id(), "MODEL_RETRY", Map.of( eventService.append(project.id(), run.id(), "MODEL_RETRY", Map.of(
"attempt", attempt, "maxAttempts", MAX_MODEL_RECONNECTS)); "attempt", attempt, "maxAttempts", MAX_MODEL_RECONNECTS));
pauseBeforeReconnect(attempt, interrupted); pauseBeforeReconnect(attempt, interrupted, remainingTime(run));
} }
} }
} }
@@ -137,9 +151,19 @@ public class AgentExecutionService {
return false; 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 { 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) { } catch (InterruptedException exception) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
if (interrupted.getAsBoolean()) { if (interrupted.getAsBoolean()) {
@@ -173,18 +197,24 @@ public class AgentExecutionService {
this.runId = runId; this.runId = runId;
} }
private void accept(List<AguiEvent> batch) { private void accept(List<AguiEvent> batch, AgentRuntimeMiddleware runtime) {
for (AguiEvent event : batch) { for (AguiEvent event : batch) {
accept(event); accept(event, runtime);
} }
if (System.nanoTime() - lastFlushNanos >= Duration.ofMillis(400).toNanos()) { if (System.nanoTime() - lastFlushNanos >= Duration.ofMillis(400).toNanos()) {
flush(); flush();
} }
} }
private void accept(AguiEvent event) { private void accept(AguiEvent event, AgentRuntimeMiddleware runtime) {
String type = event.getType().name(); 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; return;
} }
ObjectNode payload = (ObjectNode) sanitize( 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.agent.config.AgentProperties;
import tech.easyflow.manuagent.model.ModelService; import tech.easyflow.manuagent.agent.model.ModelService;
import tech.easyflow.manuagent.project.ProjectFileService; import tech.easyflow.manuagent.agent.project.ProjectFileService;
import com.fasterxml.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import io.agentscope.core.agui.adapter.AguiAdapterConfig; import io.agentscope.core.agui.adapter.AguiAdapterConfig;
import io.agentscope.core.agui.adapter.AguiAgentAdapter; import io.agentscope.core.agui.adapter.AguiAgentAdapter;
import io.agentscope.core.skill.AgentSkill; 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.AgentSkillRepositoryInfo;
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository; import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
import io.agentscope.core.tool.Toolkit; 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.extensions.model.openai.OpenAIChatModel;
import io.agentscope.harness.agent.IsolationScope; import io.agentscope.harness.agent.IsolationScope;
import io.agentscope.harness.agent.HarnessAgent; import io.agentscope.harness.agent.HarnessAgent;
@@ -45,7 +47,7 @@ public class AgentFactory {
private final ModelService modelService; private final ModelService modelService;
private final PostgresSkillRepository skillRepository; private final PostgresSkillRepository skillRepository;
private final ProjectFileService fileService; private final ProjectFileService fileService;
private final AppProperties properties; private final AgentProperties properties;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final String systemPrompt; private final String systemPrompt;
private final String compactionPrompt; private final String compactionPrompt;
@@ -63,7 +65,7 @@ public class AgentFactory {
ModelService modelService, ModelService modelService,
PostgresSkillRepository skillRepository, PostgresSkillRepository skillRepository,
ProjectFileService fileService, ProjectFileService fileService,
AppProperties properties, AgentProperties properties,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
this.modelService = modelService; this.modelService = modelService;
this.skillRepository = skillRepository; this.skillRepository = skillRepository;
@@ -72,6 +74,7 @@ public class AgentFactory {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.systemPrompt = readPrompt("prompts/smart-factory-agent-system.md", "Agent 全局提示词"); this.systemPrompt = readPrompt("prompts/smart-factory-agent-system.md", "Agent 全局提示词");
this.compactionPrompt = readPrompt("prompts/smart-factory-compaction.md", "上下文压缩提示词"); 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<>(); Map<String, String> environment = new HashMap<>();
environment.put("DASHSCOPE_API_KEY", readOptionalKey(properties.dashscopeKeyFile())); environment.put("DASHSCOPE_API_KEY", readOptionalKey(properties.dashscopeKeyFile()));
environment.put("PATH", "/opt/java/openjdk/bin:/usr/local/bin:/usr/bin:/bin"); 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); environment.put("SKILL_SESSION_ID", "project-" + projectId);
WorkspaceSpec workspace = new WorkspaceSpec(); WorkspaceSpec workspace = new WorkspaceSpec();
@@ -115,6 +118,7 @@ public class AgentFactory {
throw new IllegalStateException("无法创建 Agent 沙箱快照目录", exception); throw new IllegalStateException("无法创建 Agent 沙箱快照目录", exception);
} }
DockerFilesystemSpec filesystem = new DockerFilesystemSpec() DockerFilesystemSpec filesystem = new DockerFilesystemSpec()
.client(new RecoverableDockerClient(properties.sandboxImage()))
.image(properties.sandboxImage()) .image(properties.sandboxImage())
.workspaceRoot("/workspace") .workspaceRoot("/workspace")
.environment(environment) .environment(environment)
@@ -133,13 +137,17 @@ public class AgentFactory {
CompactionConfig compaction = compactionFor(model.contextWindow(), compactionPrompt); CompactionConfig compaction = compactionFor(model.contextWindow(), compactionPrompt);
Toolkit toolkit = new Toolkit(); Toolkit toolkit = new Toolkit();
DocumentViewTool documentView = new DocumentViewTool(objectMapper); DocumentViewTool documentView = new DocumentViewTool(objectMapper, fileService, projectId);
toolkit.registerTool(documentView); toolkit.registerTool(documentView);
AgentRuntimeMiddleware runtime = new AgentRuntimeMiddleware();
Path stateRoot = AgentStateFiles.prepare(properties.dataRoot(), AgentStateFiles.legacyRoot(), projectId);
HarnessAgent agent = HarnessAgent.builder() HarnessAgent agent = HarnessAgent.builder()
.name("smart-factory-agent") .name("smart-factory-agent")
.description("智能工厂申报书规划、编写与评审 Agent") .description("智能工厂申报书规划、编写与评审 Agent")
.sysPrompt(systemPrompt) .sysPrompt(systemPrompt)
.model(chatModel) .model(chatModel)
.stateStore(new JsonFileAgentStateStore(stateRoot))
.middleware(runtime)
.toolkit(toolkit) .toolkit(toolkit)
.workspace(projectRoot) .workspace(projectRoot)
.filesystem(filesystem) .filesystem(filesystem)
@@ -148,6 +156,8 @@ public class AgentFactory {
.toolResultEviction(ToolResultEvictionConfig.defaults()) .toolResultEviction(ToolResultEvictionConfig.defaults())
.maxContextTokens(model.contextWindow()) .maxContextTokens(model.contextWindow())
.maxIters(96) .maxIters(96)
// 中断的工具调用以失败结果补齐由模型检查已有成果后继续不绕过权限确认
.enablePendingToolRecovery(true)
.enableAgentTracingLog(false) .enableAgentTracingLog(false)
.disableSubagents() .disableSubagents()
.build(); .build();
@@ -155,15 +165,16 @@ public class AgentFactory {
agent.getWorkspaceManager().getFilesystem(), agent.getWorkspaceManager().getFilesystem(),
WorkspacePathNormalizer.of("/workspace"), WorkspacePathNormalizer.of("/workspace"),
objectMapper)); objectMapper));
agent.getToolkit().registerTool(new SandboxTools(
(AbstractSandboxFilesystem) agent.getWorkspaceManager().getFilesystem(), objectMapper));
documentView.bind(agent); documentView.bind(agent);
AguiAdapterConfig config = AguiAdapterConfig.builder() AguiAdapterConfig config = AguiAdapterConfig.builder()
.enableReasoning(true) .enableReasoning(true)
.emitToolCallArgs(true) .emitToolCallArgs(true)
.emitStateEvents(false) .emitStateEvents(false)
.runTimeout(properties.runTimeout())
.defaultAgentId("smart-factory-agent") .defaultAgentId("smart-factory-agent")
.build(); .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 adapter AG-UI 适配器
* @param agent Harness Agent * @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 沙箱资源 * 结束 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.common.ApiException;
import tech.easyflow.manuagent.project.ProjectFileService; import tech.easyflow.manuagent.agent.project.ProjectFileService;
import com.fasterxml.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode; import tools.jackson.databind.node.ObjectNode;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; 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.Column;
import com.mybatisflex.annotation.Id; import com.mybatisflex.annotation.Id;
@@ -8,8 +8,8 @@ import com.mybatisflex.core.keygen.KeyGenerators;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.UUID; import java.util.UUID;
import org.apache.ibatis.type.JdbcType; import org.apache.ibatis.type.JdbcType;
import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler; import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler; import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/** /**
* 映射 {@code app.agent_run} 表的 Agent 运行状态实体 * 映射 {@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 com.mybatisflex.core.BaseMapper;
import java.util.UUID; import java.util.UUID;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.AgentRunEntity; import tech.easyflow.manuagent.agent.runtime.AgentRunEntity;
/** /**
* 提供 Agent Run 查询以及带前置状态条件的原子状态迁移 * 提供 Agent Run 查询以及带前置状态条件的原子状态迁移
*/ */
@org.apache.ibatis.annotations.Mapper
public interface AgentRunMapper extends BaseMapper<AgentRunEntity> { public interface AgentRunMapper extends BaseMapper<AgentRunEntity> {
/** 将等待输入的 Run 标记为已完成。 */ /** 将等待输入的 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.agent.artifact.ArtifactService;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.project.ProjectFileService; import tech.easyflow.manuagent.agent.project.ProjectFileService;
import tech.easyflow.manuagent.project.ProjectService; import tech.easyflow.manuagent.agent.project.ProjectService;
import com.fasterxml.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode; import tools.jackson.databind.node.ObjectNode;
import java.io.IOException; import java.io.IOException;
import java.security.Principal;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutorService; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; 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.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.support.TransactionTemplate;
import reactor.core.publisher.Sinks; import reactor.core.publisher.Sinks;
import tech.easyflow.manuagent.mapper.AgentRunMapper; import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
/** /**
* 驱动材料检验规划 Ask 和自动编写 Run * 驱动材料检验规划 Ask 和自动编写 Run
@@ -43,10 +45,11 @@ public class AgentRunService {
private final AgentEventService eventService; private final AgentEventService eventService;
private final ProjectService projectService; private final ProjectService projectService;
private final ProjectFileService fileService; private final ProjectFileService fileService;
private final UserService userService;
private final ArtifactService artifactService; private final ArtifactService artifactService;
private final ExecutorService executor; private final ExecutorService executor;
private final TransactionTemplate transactions; private final TransactionTemplate transactions;
static final int MAX_OUTPUT_REPAIRS = 3;
private final Semaphore runSlots;
private final ConcurrentMap<UUID, RunControl> activeRuns = new ConcurrentHashMap<>(); private final ConcurrentMap<UUID, RunControl> activeRuns = new ConcurrentHashMap<>();
/** /**
@@ -60,10 +63,10 @@ public class AgentRunService {
* @param eventService 事件服务 * @param eventService 事件服务
* @param projectService 项目服务 * @param projectService 项目服务
* @param fileService 材料与工作区服务 * @param fileService 材料与工作区服务
* @param userService 用户服务
* @param artifactService 产物服务 * @param artifactService 产物服务
* @param applicationExecutor 虚拟线程执行器 * @param applicationExecutor 虚拟线程执行器
* @param transactions 编程式事务模板 * @param transactions 编程式事务模板
* @param maxConcurrentRuns 单进程同时运行的任务上限
*/ */
public AgentRunService( public AgentRunService(
AgentRunMapper runMapper, AgentRunMapper runMapper,
@@ -74,10 +77,10 @@ public class AgentRunService {
AgentEventService eventService, AgentEventService eventService,
ProjectService projectService, ProjectService projectService,
ProjectFileService fileService, ProjectFileService fileService,
UserService userService,
ArtifactService artifactService, ArtifactService artifactService,
ExecutorService applicationExecutor, ExecutorService applicationExecutor,
TransactionTemplate transactions) { TransactionTemplate transactions,
@Value("${app.max-concurrent-runs:2}") int maxConcurrentRuns) {
this.runMapper = runMapper; this.runMapper = runMapper;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.executionService = executionService; this.executionService = executionService;
@@ -86,26 +89,27 @@ public class AgentRunService {
this.eventService = eventService; this.eventService = eventService;
this.projectService = projectService; this.projectService = projectService;
this.fileService = fileService; this.fileService = fileService;
this.userService = userService;
this.artifactService = artifactService; this.artifactService = artifactService;
this.executor = applicationExecutor; this.executor = applicationExecutor;
this.transactions = transactions; this.transactions = transactions;
if (maxConcurrentRuns < 1) throw new IllegalArgumentException("app.max-concurrent-runs 必须大于 0");
this.runSlots = new Semaphore(maxConcurrentRuns);
} }
/** /**
* 后台启动材料检验 * 后台启动材料检验
* *
* @param projectId 项目 ID * @param projectId 项目 ID
* @param principal 当前用户 * @param userId 当前操作者 ID
* @return Run * @return Run
*/ */
@Transactional @Transactional
public RunView startMaterialCheck(UUID projectId, Principal principal) { public RunView startMaterialCheck(UUID projectId, UUID userId) {
ProjectService.ProjectView project = projectService.require(projectId); ProjectService.ProjectView project = projectService.require(projectId);
userService.requireUserId(principal.getName()); RunView previous = latest(projectId);
RunView run = runStore.create(projectId, "INITIAL", null); RunView run = runStore.create(projectId, "INITIAL", null);
projectService.updateStatus(projectId, "MATERIAL_CHECK"); 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; return run;
} }
@@ -114,13 +118,13 @@ public class AgentRunService {
* *
* @param projectId 项目 ID * @param projectId 项目 ID
* @param response 用户对材料缺口的处理结果 * @param response 用户对材料缺口的处理结果
* @param principal 当前用户 * @param userId 当前操作者 ID
* @return 新规划 Run * @return 新规划 Run
*/ */
@Transactional @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); ProjectService.ProjectView project = projectService.require(projectId);
UUID userId = userService.requireUserId(principal.getName());
RunView waiting = runStore.requireWaiting(projectId, "material_check"); RunView waiting = runStore.requireWaiting(projectId, "material_check");
if (!response.isObject() || !response.path("decisions").isArray()) { if (!response.isObject() || !response.path("decisions").isArray()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "MATERIAL_RESPONSE_INVALID", "请确认每项材料缺口"); throw new ApiException(HttpStatus.BAD_REQUEST, "MATERIAL_RESPONSE_INVALID", "请确认每项材料缺口");
@@ -129,7 +133,7 @@ public class AgentRunService {
runStore.completeWaiting(waiting.id()); runStore.completeWaiting(waiting.id());
RunView run = runStore.create(projectId, "RESUME", waiting.id()); RunView run = runStore.create(projectId, "RESUME", waiting.id());
projectService.updateStatus(projectId, "PLANNING"); 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; return run;
} }
@@ -139,7 +143,7 @@ public class AgentRunService {
* @param projectId 项目 ID * @param projectId 项目 ID
* @param planId 规划 ID * @param planId 规划 ID
* @param confirmedPlan 用户确认后的规划 * @param confirmedPlan 用户确认后的规划
* @param principal 当前用户 * @param userId 当前操作者 ID
* @return 已确认规划及编写 Run * @return 已确认规划及编写 Run
*/ */
@Transactional @Transactional
@@ -147,7 +151,7 @@ public class AgentRunService {
UUID projectId, UUID projectId,
UUID planId, UUID planId,
JsonNode confirmedPlan, JsonNode confirmedPlan,
Principal principal) { UUID userId) {
ProjectService.ProjectView project = projectService.require(projectId); ProjectService.ProjectView project = projectService.require(projectId);
ProjectService.PlanView current = projectService.currentPlan(projectId); ProjectService.PlanView current = projectService.currentPlan(projectId);
if (current != null && current.id().equals(planId) && "CONFIRMED".equals(current.status())) { if (current != null && current.id().equals(planId) && "CONFIRMED".equals(current.status())) {
@@ -158,11 +162,11 @@ public class AgentRunService {
} }
outputService.validateConfirmedPlan(confirmedPlan); outputService.validateConfirmedPlan(confirmedPlan);
RunView waiting = runStore.requireWaiting(projectId, "planning"); 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)); eventService.append(projectId, waiting.id(), "ASK_RESPONDED", Map.of("planId", planId));
runStore.completeWaiting(waiting.id()); runStore.completeWaiting(waiting.id());
RunView run = runStore.create(projectId, "RESUME", 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); return new ConfirmPlanResult(plan, run);
} }
@@ -188,7 +192,7 @@ public class AgentRunService {
} }
RunView run = runStore.create(projectId, "RESUME", latest == null ? null : latest.id()); RunView run = runStore.create(projectId, "RESUME", latest == null ? null : latest.id());
projectService.updateStatus(projectId, "WRITING"); 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; return run;
} }
@@ -196,13 +200,13 @@ public class AgentRunService {
* 立即停止当前 Run并保留项目阶段和工作区成果 * 立即停止当前 Run并保留项目阶段和工作区成果
* *
* @param projectId 项目 ID * @param projectId 项目 ID
* @param principal 当前用户 * @param userId 当前操作者 ID
* @return 已中断的 Run * @return 已中断的 Run
*/ */
@Transactional @Transactional
public RunView stop(UUID projectId, Principal principal) { public RunView stop(UUID projectId, UUID userId) {
projectService.require(projectId); projectService.require(projectId);
userService.requireUserId(principal.getName());
RunView run = latest(projectId); RunView run = latest(projectId);
if (run == null || !"RUNNING".equals(run.status())) { if (run == null || !"RUNNING".equals(run.status())) {
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务"); throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务");
@@ -223,12 +227,12 @@ public class AgentRunService {
* 从已中断 Run 的原阶段继续复用同一线程状态和工作区成果 * 从已中断 Run 的原阶段继续复用同一线程状态和工作区成果
* *
* @param projectId 项目 ID * @param projectId 项目 ID
* @param principal 当前用户 * @param userId 当前操作者 ID
* @return 新的恢复 Run * @return 新的恢复 Run
*/ */
@Transactional @Transactional
public RunView resume(UUID projectId, Principal principal) { public RunView resume(UUID projectId, UUID userId) {
return resume(projectId, null, principal); return resume(projectId, null, userId);
} }
/** /**
@@ -236,13 +240,13 @@ public class AgentRunService {
* *
* @param projectId 项目 ID * @param projectId 项目 ID
* @param modelConfigId 替代模型为空时使用当前默认模型 * @param modelConfigId 替代模型为空时使用当前默认模型
* @param principal 当前用户 * @param userId 当前操作者 ID
* @return 新的恢复 Run * @return 新的恢复 Run
*/ */
@Transactional @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); ProjectService.ProjectView project = projectService.require(projectId);
UUID userId = userService.requireUserId(principal.getName());
RunView interrupted = latest(projectId); RunView interrupted = latest(projectId);
if (interrupted == null || !"INTERRUPTED".equals(interrupted.status())) { if (interrupted == null || !"INTERRUPTED".equals(interrupted.status())) {
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_INTERRUPTED", "当前没有可继续的任务"); throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_INTERRUPTED", "当前没有可继续的任务");
@@ -250,7 +254,7 @@ public class AgentRunService {
String phase = runStore.interruptedPhase(interrupted, project); String phase = runStore.interruptedPhase(interrupted, project);
RunView run = runStore.create(projectId, "RESUME", interrupted.id(), modelConfigId); RunView run = runStore.create(projectId, "RESUME", interrupted.id(), modelConfigId);
projectService.updateStatus(projectId, phase); projectService.updateStatus(projectId, phase);
scheduleResume(project, run, userId, phase); scheduleResume(project, run, userId, phase, interrupted.id());
return run; return run;
} }
@@ -263,13 +267,13 @@ public class AgentRunService {
* *
* @param projectId 项目 ID * @param projectId 项目 ID
* @param modelConfigId 替代模型 ID * @param modelConfigId 替代模型 ID
* @param principal 当前用户 * @param userId 当前操作者 ID
* @return 绑定替代模型的新恢复 Run * @return 绑定替代模型的新恢复 Run
*/ */
@Transactional @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); ProjectService.ProjectView project = projectService.require(projectId);
UUID userId = userService.requireUserId(principal.getName());
RunView current = latest(projectId); RunView current = latest(projectId);
if (current == null || !"RUNNING".equals(current.status())) { if (current == null || !"RUNNING".equals(current.status())) {
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务"); throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务");
@@ -291,7 +295,7 @@ public class AgentRunService {
control.cancel(); control.cancel();
} }
}); });
scheduleResume(project, replacement, userId, phase); scheduleResume(project, replacement, userId, phase, current.id());
return replacement; return replacement;
} }
@@ -302,21 +306,22 @@ public class AgentRunService {
ProjectService.ProjectView project, ProjectService.ProjectView project,
RunView run, RunView run,
UUID userId, UUID userId,
String phase) { String phase,
UUID previousRunId) {
switch (phase) { switch (phase) {
case "MATERIAL_CHECK" -> afterCommit( case "MATERIAL_CHECK" -> afterCommit(
run.id(), () -> executeMaterialRun(project, run, true)); run.id(), () -> executeMaterialRun(project, run, true), previousRunId);
case "PLANNING" -> { case "PLANNING" -> {
JsonNode materialResponse = runStore.latestMaterialResponse(project.id()); JsonNode materialResponse = runStore.latestMaterialResponse(project.id());
afterCommit(run.id(), () -> executePlanningRun( afterCommit(run.id(), () -> executePlanningRun(
project, run, userId, materialResponse, true)); project, run, userId, materialResponse, true), previousRunId);
} }
case "WRITING" -> { case "WRITING" -> {
ProjectService.PlanView plan = projectService.currentPlan(project.id()); ProjectService.PlanView plan = projectService.currentPlan(project.id());
if (plan == null || !"CONFIRMED".equals(plan.status())) { if (plan == null || !"CONFIRMED".equals(plan.status())) {
throw new ApiException(HttpStatus.CONFLICT, "PLAN_NOT_CONFIRMED", "无法恢复:建设规划尚未确认"); 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( default -> throw new ApiException(
HttpStatus.CONFLICT, "RUN_PHASE_UNKNOWN", "无法识别中断前的执行阶段"); HttpStatus.CONFLICT, "RUN_PHASE_UNKNOWN", "无法识别中断前的执行阶段");
@@ -383,8 +388,12 @@ public class AgentRunService {
while (true) { while (true) {
try { try {
return outputService.readMaterialCheck(project.id()); return outputService.readMaterialCheck(project.id());
} catch (ApiException | IOException exception) { } catch (ApiException | IOException | tools.jackson.core.JacksonException exception) {
runStore.ensureRunning(run.id()); runStore.ensureRunning(run.id());
if (repairRound >= MAX_OUTPUT_REPAIRS) {
throw new ApiException(HttpStatus.UNPROCESSABLE_ENTITY, "AGENT_OUTPUT_INVALID",
"结构化结果连续修复失败,现有成果已保留,请检查材料或模型后继续");
}
repairRound++; repairRound++;
log.warn("Agent 未生成有效材料检验结果继续同一线程修复runId={}round={}", log.warn("Agent 未生成有效材料检验结果继续同一线程修复runId={}round={}",
run.id(), repairRound, exception); run.id(), repairRound, exception);
@@ -457,8 +466,12 @@ public class AgentRunService {
while (true) { while (true) {
try { try {
return outputService.readProposedPlan(project.id()); return outputService.readProposedPlan(project.id());
} catch (ApiException | IOException exception) { } catch (ApiException | IOException | tools.jackson.core.JacksonException exception) {
runStore.ensureRunning(run.id()); runStore.ensureRunning(run.id());
if (repairRound >= MAX_OUTPUT_REPAIRS) {
throw new ApiException(HttpStatus.UNPROCESSABLE_ENTITY, "AGENT_OUTPUT_INVALID",
"结构化结果连续修复失败,现有成果已保留,请检查材料或模型后继续");
}
repairRound++; repairRound++;
log.warn("Agent 未生成有效建设规划继续同一线程修复runId={}round={}", log.warn("Agent 未生成有效建设规划继续同一线程修复runId={}round={}",
run.id(), repairRound, exception); run.id(), repairRound, exception);
@@ -543,29 +556,57 @@ public class AgentRunService {
* @param task 后台任务 * @param task 后台任务
*/ */
private void afterCommit(UUID runId, Runnable task) { private void afterCommit(UUID runId, Runnable task) {
RunControl control = new RunControl(); 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); activeRuns.put(runId, control);
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override @Override
public void afterCommit() { public void afterCommit() {
try {
executor.submit(() -> { executor.submit(() -> {
try { try {
// 同一项目接续复用名额但须等旧 Agent 关闭并保存快照后再读写工作区
control.previousFinished.join();
task.run(); task.run();
} finally { } finally {
activeRuns.remove(runId, control); release(runId, control);
} }
}); });
} catch (RuntimeException exception) {
release(runId, control);
fail(runStore.require(runId), exception);
}
} }
@Override @Override
public void afterCompletion(int status) { public void afterCompletion(int status) {
if (status != TransactionSynchronization.STATUS_COMMITTED) { 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); int updated = runMapper.failRunning(run.id(), message);
if (updated == 1) { if (updated == 1) {
eventService.append(run.projectId(), run.id(), "RUN_ERROR", Map.of( 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"); projectService.updateStatus(run.projectId(), "FAILED");
} }
}); });
@@ -708,6 +749,15 @@ public class AgentRunService {
private static final class RunControl { private static final class RunControl {
private final Sinks.One<Void> stopSignal = Sinks.one(); 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 流订阅停止继续输出和后续工具调用 * 取消 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.common.ApiException;
import tech.easyflow.manuagent.project.ProjectService; import tech.easyflow.manuagent.agent.project.ProjectService;
import com.fasterxml.jackson.core.JsonProcessingException; import tools.jackson.core.JacksonException;
import com.fasterxml.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import java.util.UUID; import java.util.UUID;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import tech.easyflow.manuagent.entity.AgentRunEntity; import tech.easyflow.manuagent.agent.runtime.AgentRunEntity;
import tech.easyflow.manuagent.entity.ModelConfigEntity; import tech.easyflow.manuagent.agent.model.ModelConfigEntity;
import tech.easyflow.manuagent.mapper.AgentEventMapper; import tech.easyflow.manuagent.agent.runtime.AgentEventMapper;
import tech.easyflow.manuagent.mapper.AgentRunMapper; import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper; import tech.easyflow.manuagent.agent.model.ModelConfigMapper;
/** /**
* 集中读写 Agent Run 持久化状态 * 集中读写 Agent Run 持久化状态
@@ -172,7 +172,7 @@ public class AgentRunStore {
throw new ApiException(HttpStatus.CONFLICT, "ASK_TYPE_MISMATCH", "确认内容与当前阶段不一致"); throw new ApiException(HttpStatus.CONFLICT, "ASK_TYPE_MISMATCH", "确认内容与当前阶段不一致");
} }
return run; return run;
} catch (JsonProcessingException exception) { } catch (JacksonException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ASK_STATE_INVALID", "确认状态无法读取"); throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ASK_STATE_INVALID", "确认状态无法读取");
} }
} }
@@ -228,7 +228,7 @@ public class AgentRunStore {
} }
try { try {
return objectMapper.readTree(value); return objectMapper.readTree(value);
} catch (JsonProcessingException exception) { } catch (JacksonException exception) {
throw new ApiException( throw new ApiException(
HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR,
"MATERIAL_RESPONSE_INVALID", "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 tools.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.message.Base64Source; import io.agentscope.core.message.Base64Source;
import io.agentscope.core.message.ContentBlock; 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.model.WriteResult;
import io.agentscope.harness.agent.filesystem.sandbox.SandboxBackedFilesystem; import io.agentscope.harness.agent.filesystem.sandbox.SandboxBackedFilesystem;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import tech.easyflow.manuagent.agent.project.ProjectFileService;
import tech.easyflow.manuagent.common.ApiException;
/** /**
* 在当前 Harness 沙箱内把指定文档视图渲染为多模态图片 * 在当前 Harness 沙箱内把指定文档视图渲染为多模态图片
@@ -33,6 +36,8 @@ public final class DocumentViewTool {
private static final int TOOL_TIMEOUT_SECONDS = 180; private static final int TOOL_TIMEOUT_SECONDS = 180;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ProjectFileService fileService;
private final UUID projectId;
private volatile HarnessAgent harness; private volatile HarnessAgent harness;
/** /**
@@ -40,8 +45,10 @@ public final class DocumentViewTool {
* *
* @param objectMapper JSON 映射器 * @param objectMapper JSON 映射器
*/ */
public DocumentViewTool(ObjectMapper objectMapper) { public DocumentViewTool(ObjectMapper objectMapper, ProjectFileService fileService, UUID projectId) {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.fileService = fileService;
this.projectId = projectId;
} }
/** /**
@@ -98,7 +105,7 @@ public final class DocumentViewTool {
} }
ExecuteResponse execution = sandbox.execute( ExecuteResponse execution = sandbox.execute(
runtimeContext, runtimeContext,
"python /opt/agent-runtime/document_view.py " + requestPath + " " + resultPath, "python /opt/sandbox/document_view.py " + requestPath + " " + resultPath,
TOOL_TIMEOUT_SECONDS); TOOL_TIMEOUT_SECONDS);
FileDownloadResponse downloaded = filesystem.downloadFiles(runtimeContext, List.of(resultPath)).getFirst(); FileDownloadResponse downloaded = filesystem.downloadFiles(runtimeContext, List.of(resultPath)).getFirst();
if (!downloaded.isSuccess()) { if (!downloaded.isSuccess()) {
@@ -114,14 +121,18 @@ public final class DocumentViewTool {
List<Map<String, Object>> metadata = new ArrayList<>(); List<Map<String, Object>> metadata = new ArrayList<>();
for (JsonNode image : result.path("images")) { for (JsonNode image : result.path("images")) {
String path = image.path("path").asText(); String path = image.path("path").asText();
FileDownloadResponse imageFile = filesystem.downloadFiles(runtimeContext, List.of(path)).getFirst(); byte[] imageBytes;
if (!imageFile.isSuccess()) { try {
imageBytes = readPreview(path, image.path("sizeBytes").asLong(-1));
} catch (IOException | ApiException exception) {
output.add(TextBlock.builder().text("预览图读取失败:"
+ safeError(exception.getMessage(), null)).build());
continue; continue;
} }
output.add(ImageBlock.builder() output.add(ImageBlock.builder()
.source(Base64Source.builder() .source(Base64Source.builder()
.mediaType(image.path("mediaType").asText("image/png")) .mediaType(image.path("mediaType").asText("image/png"))
.data(Base64.getEncoder().encodeToString(imageFile.content())) .data(Base64.getEncoder().encodeToString(imageBytes))
.build()) .build())
.build()); .build());
metadata.add(objectMapper.convertValue(image, new TypeReference<LinkedHashMap<String, Object>>() { })); 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 的诊断信息 * 选择并限制返回给 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 tools.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.tool.Tool; import io.agentscope.core.tool.Tool;
import io.agentscope.core.tool.ToolParam; 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.stereotype.Component;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.manuagent.mapper.AgentRunMapper; import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
/** /**
* 启动时终结因 JVM 中断而遗留的伪运行状态并保留原业务阶段供继续执行 * 启动时终结因 JVM 中断而遗留的伪运行状态并保留原业务阶段供继续执行
*/ */
@Component @Component
@Order(0) public class RunRecoveryService {
public class RunRecoveryService implements ApplicationRunner {
private final AgentRunMapper runMapper; private final AgentRunMapper runMapper;
@@ -28,11 +24,9 @@ public class RunRecoveryService implements ApplicationRunner {
/** /**
* 将仍为 RUNNING 的旧 Run 标记为已中断 * 将仍为 RUNNING 的旧 Run 标记为已中断
* *
* @param args 启动参数
*/ */
@Override
@Transactional @Transactional
public void run(ApplicationArguments args) { public void recoverInterruptedRuns() {
runMapper.interruptRunningAfterRestart(); 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.Column;
import com.mybatisflex.annotation.Id; import com.mybatisflex.annotation.Id;
@@ -6,7 +6,7 @@ import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table; import com.mybatisflex.annotation.Table;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.UUID; import java.util.UUID;
import tech.easyflow.manuagent.typehandler.UuidTypeHandler; import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
/** /**
* 映射应用自管的 {@code app.skill_config} * 映射应用自管的 {@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 com.mybatisflex.core.BaseMapper;
import java.util.List; import java.util.List;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import tech.easyflow.manuagent.entity.SkillConfigEntity; import tech.easyflow.manuagent.agent.skill.SkillConfigEntity;
/** /**
* 提供应用 Skill 配置 CRUD 及对 AgentScope Skill 元数据的只读联查 * 提供应用 Skill 配置 CRUD 及对 AgentScope Skill 元数据的只读联查
*/ */
@org.apache.ibatis.annotations.Mapper
public interface SkillConfigMapper extends BaseMapper<SkillConfigEntity> { public interface SkillConfigMapper extends BaseMapper<SkillConfigEntity> {
/** 列出全部 Skill 联合视图。 */ /** 列出全部 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 tech.easyflow.manuagent.common.ApiException;
import io.agentscope.core.skill.AgentSkill; 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 io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
import javax.sql.DataSource; 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 com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties; import tech.easyflow.manuagent.agent.config.AgentProperties;
import tech.easyflow.manuagent.entity.SkillConfigEntity; import tech.easyflow.manuagent.agent.skill.SkillConfigEntity;
import tech.easyflow.manuagent.mapper.SkillConfigMapper; import tech.easyflow.manuagent.agent.skill.SkillConfigMapper;
import tech.easyflow.manuagent.mapper.SkillViewRow; import tech.easyflow.manuagent.agent.skill.SkillViewRow;
import io.agentscope.core.skill.AgentSkill; import io.agentscope.core.skill.AgentSkill;
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository; import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.Principal;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
@@ -38,8 +36,7 @@ public class SkillService {
private final SkillConfigMapper skillMapper; private final SkillConfigMapper skillMapper;
private final PostgresSkillRepository repository; private final PostgresSkillRepository repository;
private final SkillPackageReader packageReader; private final SkillPackageReader packageReader;
private final UserService userService; private final AgentProperties properties;
private final AppProperties properties;
/** /**
* 创建 Skill 服务 * 创建 Skill 服务
@@ -47,19 +44,16 @@ public class SkillService {
* @param skillMapper 应用 Skill 配置 Mapper * @param skillMapper 应用 Skill 配置 Mapper
* @param repository AgentScope PostgreSQL 仓库 * @param repository AgentScope PostgreSQL 仓库
* @param packageReader Skill 包读取器 * @param packageReader Skill 包读取器
* @param userService 用户服务
* @param properties 应用配置 * @param properties 应用配置
*/ */
public SkillService( public SkillService(
SkillConfigMapper skillMapper, SkillConfigMapper skillMapper,
PostgresSkillRepository repository, PostgresSkillRepository repository,
SkillPackageReader packageReader, SkillPackageReader packageReader,
UserService userService, AgentProperties properties) {
AppProperties properties) {
this.skillMapper = skillMapper; this.skillMapper = skillMapper;
this.repository = repository; this.repository = repository;
this.packageReader = packageReader; this.packageReader = packageReader;
this.userService = userService;
this.properties = properties; this.properties = properties;
} }
@@ -147,11 +141,11 @@ public class SkillService {
* 导入管理员上传的标准 Skill ZIP * 导入管理员上传的标准 Skill ZIP
* *
* @param file ZIP 文件 * @param file ZIP 文件
* @param principal 当前用户 * @param userId 当前操作者 ID
* @return 导入后的 Skill * @return 导入后的 Skill
*/ */
@Transactional @Transactional
public SkillView importZip(MultipartFile file, Principal principal) { public SkillView importZip(MultipartFile file, UUID userId) {
if (file.isEmpty()) { if (file.isEmpty()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_ZIP_EMPTY", "Skill 压缩包为空"); 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 已存在"); throw new ApiException(HttpStatus.CONFLICT, "SKILL_NAME_CONFLICT", "同名 Skill 已存在");
} }
repository.save(List.of(skillPackage.skill()), false); repository.save(List.of(skillPackage.skill()), false);
UUID userId = userService.requireUserId(principal.getName());
SkillConfigEntity config = new SkillConfigEntity(); SkillConfigEntity config = new SkillConfigEntity();
config.setSkillName(name); config.setSkillName(name);
config.setVersion(skillPackage.version()); 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; import java.time.OffsetDateTime;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd"> "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 id="hasRunningRun" resultType="boolean">
SELECT EXISTS( SELECT EXISTS(
SELECT 1 FROM app.agent_run 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' AND status = 'RUNNING'
) )
</select> </select>
@@ -15,28 +15,28 @@
<!-- 以下删除顺序与外键依赖顺序一致,并由 ProjectService 的 Spring 事务统一提交或回滚。 --> <!-- 以下删除顺序与外键依赖顺序一致,并由 ProjectService 的 Spring 事务统一提交或回滚。 -->
<delete id="deleteEvents"> <delete id="deleteEvents">
DELETE FROM app.agent_event 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>
<delete id="deleteArtifacts"> <delete id="deleteArtifacts">
DELETE FROM app.artifact 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>
<delete id="deletePlans"> <delete id="deletePlans">
DELETE FROM app.project_plan 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>
<delete id="deleteFiles"> <delete id="deleteFiles">
DELETE FROM app.project_file 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>
<delete id="deleteRuns"> <delete id="deleteRuns">
DELETE FROM app.agent_run 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> </delete>
<update id="updateStatus"> <update id="updateStatus">
UPDATE app.project UPDATE app.project
SET status = #{status}, version = version + 1, updated_at = CURRENT_TIMESTAMP 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> </update>
</mapper> </mapper>

View File

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

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd"> "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 保存启停、来源和校验状态。 --> <!-- AgentScope 表严格只读;应用只在 app.skill_config 保存启停、来源和校验状态。 -->
<sql id="skillViewColumns"> <sql id="skillViewColumns">
@@ -9,14 +9,14 @@
c.validation_status, c.validation_message, c.updated_at c.validation_status, c.validation_message, c.updated_at
</sql> </sql>
<select id="selectViews" resultType="tech.easyflow.manuagent.mapper.SkillViewRow"> <select id="selectViews" resultType="tech.easyflow.manuagent.agent.skill.SkillViewRow">
SELECT <include refid="skillViewColumns"/> SELECT <include refid="skillViewColumns"/>
FROM agentscope.agentscope_skills s FROM agentscope.agentscope_skills s
JOIN app.skill_config c ON c.skill_name = s.name JOIN app.skill_config c ON c.skill_name = s.name
ORDER BY c.source_type, s.name ORDER BY c.source_type, s.name
</select> </select>
<select id="selectView" resultType="tech.easyflow.manuagent.mapper.SkillViewRow"> <select id="selectView" resultType="tech.easyflow.manuagent.agent.skill.SkillViewRow">
SELECT <include refid="skillViewColumns"/> SELECT <include refid="skillViewColumns"/>
FROM agentscope.agentscope_skills s FROM agentscope.agentscope_skills s
JOIN app.skill_config c ON c.skill_name = s.name 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/`,保留并利用上传目录、原文件名和材料分类之间的语义关系;同名文件必须结合完整相对路径判断来源。 1. 先递归查看 `inputs/`,保留并利用上传目录、原文件名和材料分类之间的语义关系;同名文件必须结合完整相对路径判断来源。
2. 主动选择与文件类型相符的 PDF、PPTX、XLS/XLSX、DOCX 等文档 Skill先读取 Skill 的完整 `SKILL.md`,再按其方法做结构化读取。不得只凭文件名推断正文。 2. 主动选择与文件类型相符的 PDF、PPTX、XLS/XLSX、DOCX 等文档 Skill先读取 Skill 的完整 `SKILL.md`,再按其方法做结构化读取。不得只凭文件名推断正文。
3. `document_view` 是按需视觉补充工具,不是默认步骤。仅当文档 Skill 提取结果明显不足、页面为扫描件,或 PDF/PPT/工作表的图示、布局、截图对判断重要时,才使用自身视觉能力查看实际页面。由你决定页码、幻灯片、工作表与范围;建议每次最多渲染 5 张,可分批调用。大型工作表应主动拆分 range 查看,无需模拟滚动。 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、投资区间、建设周期和版本。 2. 已确认并冻结的建设规划包括建设方向、场景、KPI、投资区间、建设周期和版本。
3. 材料之间的冲突、全部未解决 U 项、Word 批注要求和事实约束。 3. 材料之间的冲突、全部未解决 U 项、Word 批注要求和事实约束。
4. 已调用 Skill、关键工具结果、已生成或已修改的工作区文件及其校验状态。 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -13,9 +13,9 @@ import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.entity.ArtifactEntity; import tech.easyflow.manuagent.agent.artifact.ArtifactEntity;
import tech.easyflow.manuagent.mapper.ArtifactMapper; import tech.easyflow.manuagent.agent.artifact.ArtifactMapper;
import tech.easyflow.manuagent.project.ProjectFileService; import tech.easyflow.manuagent.agent.project.ProjectFileService;
/** /**
* 验证产物查询在 MyBatis-Flex 迁移后保持原 JDBC SQL 的最小字段范围 * 验证产物查询在 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; 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.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
@@ -16,12 +16,11 @@ import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.entity.ModelConfigEntity; import tech.easyflow.manuagent.agent.model.ModelConfigEntity;
import tech.easyflow.manuagent.mapper.AgentRunMapper; import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; import tech.easyflow.manuagent.agent.model.ModelAssignmentMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper; import tech.easyflow.manuagent.agent.model.ModelConfigMapper;
/** /**
* 验证模型草稿连接测试的密钥选择和外部请求边界 * 验证模型草稿连接测试的密钥选择和外部请求边界
@@ -39,9 +38,8 @@ class ModelServiceConnectionTest {
context.registerBean(ModelConfigMapper.class, () -> mock(ModelConfigMapper.class)); context.registerBean(ModelConfigMapper.class, () -> mock(ModelConfigMapper.class));
context.registerBean(ModelAssignmentMapper.class, () -> mock(ModelAssignmentMapper.class)); context.registerBean(ModelAssignmentMapper.class, () -> mock(ModelAssignmentMapper.class));
context.registerBean(AgentRunMapper.class, () -> mock(AgentRunMapper.class)); context.registerBean(AgentRunMapper.class, () -> mock(AgentRunMapper.class));
context.registerBean(UserService.class, () -> mock(UserService.class));
context.registerBean(KeyCipher.class, () -> mock(KeyCipher.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.register(ModelService.class);
context.refresh(); context.refresh();
@@ -164,9 +162,8 @@ class ModelServiceConnectionTest {
modelMapper, modelMapper,
mock(ModelAssignmentMapper.class), mock(ModelAssignmentMapper.class),
mock(AgentRunMapper.class), mock(AgentRunMapper.class),
mock(UserService.class),
keyCipher, keyCipher,
new ObjectMapper(), tools.jackson.databind.json.JsonMapper.builder().build(),
httpClient); 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.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; 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.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
@@ -14,13 +14,11 @@ import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.ApplicationRunner; import org.springframework.boot.ApplicationRunner;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.agent.config.AgentProperties;
import tech.easyflow.manuagent.config.AppProperties; import tech.easyflow.manuagent.agent.model.ModelConfigEntity;
import tech.easyflow.manuagent.entity.ModelConfigEntity; import tech.easyflow.manuagent.agent.model.ModelAssignmentMapper;
import tech.easyflow.manuagent.mapper.AppUserMapper; import tech.easyflow.manuagent.agent.model.ModelConfigMapper;
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
/** /**
* 验证模型管理接口使用最小字段投影不把加密 API Key 读入普通请求内存 * 验证模型管理接口使用最小字段投影不把加密 API Key 读入普通请求内存
@@ -47,9 +45,8 @@ class ModelServiceQueryTest {
modelMapper, modelMapper,
mock(ModelAssignmentMapper.class), mock(ModelAssignmentMapper.class),
mock(AgentRunMapper.class), mock(AgentRunMapper.class),
mock(UserService.class),
mock(KeyCipher.class), mock(KeyCipher.class),
new ObjectMapper()); tools.jackson.databind.json.JsonMapper.builder().build());
List<ModelService.ModelView> models = service.list(); 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import jakarta.validation.Validation; import jakarta.validation.Validation;
import jakarta.validation.Validator; import jakarta.validation.Validator;
import java.util.Map; import java.util.Map;
@@ -13,11 +13,10 @@ import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.mapper.AgentRunMapper; import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; import tech.easyflow.manuagent.agent.model.ModelAssignmentMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper; import tech.easyflow.manuagent.agent.model.ModelConfigMapper;
/** /**
* 验证模型配置在进入数据库和外部 HTTP 客户端之前具有明确可审计的输入边界 * 验证模型配置在进入数据库和外部 HTTP 客户端之前具有明确可审计的输入边界
@@ -67,7 +66,7 @@ class ModelServiceValidationTest {
Map.of(), Map.of(),
Map.of("contextWindow", 8_192)); Map.of("contextWindow", 8_192));
assertThatThrownBy(() -> service.save(null, input, () -> "admin")) assertThatThrownBy(() -> service.save(null, input, UUID.randomUUID()))
.as("地址应被拒绝:%s", baseUrl) .as("地址应被拒绝:%s", baseUrl)
.isInstanceOfSatisfying(ApiException.class, exception -> .isInstanceOfSatisfying(ApiException.class, exception ->
assertThat(exception.code()).isEqualTo("MODEL_BASE_URL_INVALID")); assertThat(exception.code()).isEqualTo("MODEL_BASE_URL_INVALID"));
@@ -80,14 +79,12 @@ class ModelServiceValidationTest {
* @return 配置了当前用户的模型服务 * @return 配置了当前用户的模型服务
*/ */
private ModelService service() { private ModelService service() {
UserService userService = mock(UserService.class);
when(userService.requireUserId("admin")).thenReturn(UUID.randomUUID());
return new ModelService( return new ModelService(
mock(ModelConfigMapper.class), mock(ModelConfigMapper.class),
mock(ModelAssignmentMapper.class), mock(ModelAssignmentMapper.class),
mock(AgentRunMapper.class), mock(AgentRunMapper.class),
userService,
mock(KeyCipher.class), 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; 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 static org.mockito.Mockito.when;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.config.AppProperties; import tech.easyflow.manuagent.agent.config.AgentProperties;
import tech.easyflow.manuagent.entity.ProjectFileEntity; import tech.easyflow.manuagent.agent.project.ProjectFileEntity;
import tech.easyflow.manuagent.mapper.ProjectFileMapper; import tech.easyflow.manuagent.agent.project.ProjectFileMapper;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Duration; import java.time.Duration;
@@ -37,12 +36,11 @@ class ProjectFileServiceTest {
*/ */
@Test @Test
void shouldDeleteProjectWorkspace() throws Exception { void shouldDeleteProjectWorkspace() throws Exception {
AppProperties properties = new AppProperties( AgentProperties properties = new AgentProperties(
temporaryDirectory, Path.of("dashscope"), temporaryDirectory, Path.of("dashscope"),
"test-master", "admin", "admin", "test-master", "runtime:test", "bridge", Duration.ofMinutes(1));
"runtime:test", "bridge", Duration.ofMinutes(1));
ProjectFileService service = new ProjectFileService( 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(); UUID projectId = UUID.randomUUID();
Path file = service.projectRoot(projectId).resolve("inputs/company.txt"); Path file = service.projectRoot(projectId).resolve("inputs/company.txt");
Files.createDirectories(file.getParent()); Files.createDirectories(file.getParent());
@@ -92,7 +90,7 @@ class ProjectFileServiceTest {
file.setSizeBytes(1L); file.setSizeBytes(1L);
when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(file)); when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(file));
ProjectFileService service = new ProjectFileService( ProjectFileService service = new ProjectFileService(
mapper, mock(UserService.class), projectService, properties()); mapper, projectService, properties());
service.list(file.getProjectId()); service.list(file.getProjectId());
@@ -111,7 +109,7 @@ class ProjectFileServiceTest {
*/ */
private ProjectFileService service() { private ProjectFileService service() {
return new ProjectFileService( 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 指向临时数据目录的配置 * @return 指向临时数据目录的配置
*/ */
private AppProperties properties() { private AgentProperties properties() {
return new AppProperties( return new AgentProperties(
temporaryDirectory, Path.of("dashscope"), temporaryDirectory, Path.of("dashscope"),
"test-master", "admin", "admin", "test-master", "runtime:test", "bridge", Duration.ofMinutes(1));
"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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; 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.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.agent.project.ProjectEntity;
import tech.easyflow.manuagent.entity.ProjectEntity; import tech.easyflow.manuagent.agent.project.ProjectPlanEntity;
import tech.easyflow.manuagent.entity.ProjectPlanEntity;
import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.mapper.ProjectMapper; import tech.easyflow.manuagent.agent.project.ProjectMapper;
import tech.easyflow.manuagent.mapper.ProjectPlanMapper; import tech.easyflow.manuagent.agent.project.ProjectPlanMapper;
/** /**
* 验证项目查询在 MyBatis-Flex 迁移后保持原 JDBC SQL 的字段范围 * 验证项目查询在 MyBatis-Flex 迁移后保持原 JDBC SQL 的字段范围
@@ -72,7 +71,7 @@ class ProjectServiceQueryTest {
plan.setPlanJson("{invalid-json"); plan.setPlanJson("{invalid-json");
when(planMapper.selectCurrent(any(UUID.class))).thenReturn(plan); when(planMapper.selectCurrent(any(UUID.class))).thenReturn(plan);
ProjectService service = new ProjectService( 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())) assertThatThrownBy(() -> service.currentPlan(UUID.randomUUID()))
.isInstanceOf(IllegalStateException.class) .isInstanceOf(IllegalStateException.class)
@@ -101,8 +100,7 @@ class ProjectServiceQueryTest {
return new ProjectService( return new ProjectService(
mapper, mapper,
mock(ProjectPlanMapper.class), mock(ProjectPlanMapper.class),
mock(UserService.class), tools.jackson.databind.json.JsonMapper.builder().build());
new ObjectMapper());
} }
/** /**

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.assertThat;
import static org.mockito.ArgumentMatchers.any; 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.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import tech.easyflow.manuagent.entity.AgentEventEntity; import tech.easyflow.manuagent.agent.runtime.AgentEventEntity;
import tech.easyflow.manuagent.mapper.AgentEventMapper; import tech.easyflow.manuagent.agent.runtime.AgentEventMapper;
/** /**
* 验证 Agent 事件回放查询在 ORM 迁移后保持原 JDBC 字段和游标语义 * 验证 Agent 事件回放查询在 ORM 迁移后保持原 JDBC 字段和游标语义
@@ -31,7 +31,7 @@ class AgentEventServiceQueryTest {
event.setProjectId(UUID.randomUUID()); event.setProjectId(UUID.randomUUID());
event.setPayloadJson("{}"); event.setPayloadJson("{}");
when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(event)); 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); 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; 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.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import java.util.UUID; import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; 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.common.ApiException;
import tech.easyflow.manuagent.entity.ModelConfigEntity; import tech.easyflow.manuagent.agent.model.ModelConfigEntity;
import tech.easyflow.manuagent.mapper.AgentEventMapper; import tech.easyflow.manuagent.agent.runtime.AgentEventMapper;
import tech.easyflow.manuagent.mapper.AgentRunMapper; import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper; import tech.easyflow.manuagent.agent.model.ModelConfigMapper;
/** /**
* 验证 Agent 执行热路径只读取判断运行状态所需的最小列 * 验证 Agent 执行热路径只读取判断运行状态所需的最小列
@@ -37,7 +37,7 @@ class AgentRunStoreQueryTest {
runMapper, runMapper,
mock(AgentEventMapper.class), mock(AgentEventMapper.class),
mock(ModelConfigMapper.class), mock(ModelConfigMapper.class),
new ObjectMapper()); tools.jackson.databind.json.JsonMapper.builder().build());
store.ensureRunning(UUID.randomUUID()); store.ensureRunning(UUID.randomUUID());
@@ -61,7 +61,7 @@ class AgentRunStoreQueryTest {
runMapper, runMapper,
mock(AgentEventMapper.class), mock(AgentEventMapper.class),
mock(ModelConfigMapper.class), mock(ModelConfigMapper.class),
new ObjectMapper()); tools.jackson.databind.json.JsonMapper.builder().build());
assertThatThrownBy(() -> store.create(UUID.randomUUID(), "INITIAL", null)) assertThatThrownBy(() -> store.create(UUID.randomUUID(), "INITIAL", null))
.isInstanceOfSatisfying(ApiException.class, exception -> { .isInstanceOfSatisfying(ApiException.class, exception -> {
@@ -99,7 +99,7 @@ class AgentRunStoreQueryTest {
runMapper, runMapper,
mock(AgentEventMapper.class), mock(AgentEventMapper.class),
modelMapper, modelMapper,
new ObjectMapper()); tools.jackson.databind.json.JsonMapper.builder().build());
AgentRunService.RunView run = store.create(projectId, "RESUME", null, modelId); AgentRunService.RunView run = store.create(projectId, "RESUME", null, modelId);
@@ -118,7 +118,7 @@ class AgentRunStoreQueryTest {
mock(AgentRunMapper.class), mock(AgentRunMapper.class),
mock(AgentEventMapper.class), mock(AgentEventMapper.class),
mock(ModelConfigMapper.class), mock(ModelConfigMapper.class),
new ObjectMapper()); tools.jackson.databind.json.JsonMapper.builder().build());
UUID runId = UUID.randomUUID(); UUID runId = UUID.randomUUID();
assertThatThrownBy(() -> store.require(runId)) 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.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; 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.mock;
import static org.mockito.Mockito.when; 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.core.agent.RuntimeContext;
import io.agentscope.harness.agent.filesystem.model.ExecuteResponse; import io.agentscope.harness.agent.filesystem.model.ExecuteResponse;
import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem; import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem;
@@ -35,7 +35,7 @@ class PagedReadFileToolTest {
0, 0,
false)); false));
PagedReadFileTool tool = new PagedReadFileTool( 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); String result = tool.readFile(RuntimeContext.empty(), "/workspace/work/report.txt", 0, 2);
@@ -56,7 +56,7 @@ class PagedReadFileToolTest {
0, 0,
true)); true));
PagedReadFileTool tool = new PagedReadFileTool( 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); 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; 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.CallableStatement;
import java.sql.PreparedStatement; 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.CallableStatement;
import java.sql.PreparedStatement; 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; 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.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -9,7 +9,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
* 智造申报 Agent 服务入口 * 智造申报 Agent 服务入口
*/ */
@SpringBootApplication @SpringBootApplication
@EnableConfigurationProperties(AppProperties.class) @EnableConfigurationProperties({AgentProperties.class, tech.easyflow.manuagent.admin.config.AdminProperties.class})
public class ManuAgentApplication { 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.Valid;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import java.security.Principal; import java.security.Principal;
import tech.easyflow.manuagent.admin.auth.UserService;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -24,6 +28,8 @@ import reactor.core.publisher.Flux;
@RequestMapping("/api/projects/{projectId}") @RequestMapping("/api/projects/{projectId}")
public class AgentController { public class AgentController {
private final UserService userService;
private final AgentRunService runService; private final AgentRunService runService;
private final AgentEventService eventService; private final AgentEventService eventService;
@@ -33,7 +39,8 @@ public class AgentController {
* @param runService Run 服务 * @param runService Run 服务
* @param eventService 事件服务 * @param eventService 事件服务
*/ */
public AgentController(AgentRunService runService, AgentEventService eventService) { public AgentController(UserService userService, AgentRunService runService, AgentEventService eventService) {
this.userService = userService;
this.runService = runService; this.runService = runService;
this.eventService = eventService; this.eventService = eventService;
} }
@@ -47,7 +54,7 @@ public class AgentController {
*/ */
@PostMapping("/runs/material-check") @PostMapping("/runs/material-check")
public AgentRunService.RunView startMaterialCheck(@PathVariable UUID projectId, Principal principal) { 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, @PathVariable UUID projectId,
@RequestBody JsonNode response, @RequestBody JsonNode response,
Principal principal) { 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") @PostMapping("/runs/stop")
public AgentRunService.RunView stop(@PathVariable UUID projectId, Principal principal) { 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, @PathVariable UUID projectId,
@Valid @RequestBody(required = false) ResumeInput input, @Valid @RequestBody(required = false) ResumeInput input,
Principal principal) { 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, @PathVariable UUID projectId,
@Valid @RequestBody ResumeInput input, @Valid @RequestBody ResumeInput input,
Principal principal) { 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.net.URLEncoder;
import java.nio.charset.StandardCharsets; 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.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;

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