feat: 完善 Agent 标准交互与安全运行时
- 接入 AG-UI 运行投影、Turn 时间线和审批隔离 - 增加 Agent Skill 冻结绑定与运行时消费闭环 - 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -36,6 +36,7 @@ build/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
/.logs/
|
/.logs/
|
||||||
/logs/
|
/logs/
|
||||||
|
/agent-workspaces/
|
||||||
/.idea/
|
/.idea/
|
||||||
.logs
|
.logs
|
||||||
.idea
|
.idea
|
||||||
|
|||||||
96
Dockerfile
96
Dockerfile
@@ -1,5 +1,5 @@
|
|||||||
# 后端构建脚本
|
# 后端构建脚本
|
||||||
FROM --platform=linux/amd64 swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/eclipse-temurin:17-jre
|
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/eclipse-temurin:17-jre
|
||||||
|
|
||||||
ENV LANG=C.UTF-8
|
ENV LANG=C.UTF-8
|
||||||
ENV LC_ALL=C.UTF-8
|
ENV LC_ALL=C.UTF-8
|
||||||
@@ -9,12 +9,15 @@ ENV EASYFLOW_JAR_PATH=/app/artifacts/easyflow.jar
|
|||||||
ENV EASYFLOW_CONFIG_PATH=file:/app/application.yml
|
ENV EASYFLOW_CONFIG_PATH=file:/app/application.yml
|
||||||
ENV EASYFLOW_LOG_FILE=/app/logs/app.log
|
ENV EASYFLOW_LOG_FILE=/app/logs/app.log
|
||||||
ENV EASYFLOW_JAR_RESTART_GRACE_SECONDS=30
|
ENV EASYFLOW_JAR_RESTART_GRACE_SECONDS=30
|
||||||
ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
|
ENV NPM_CONFIG_REGISTRY=https://registry.npmjs.org
|
||||||
ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
ENV PIP_INDEX_URL=https://pypi.org/simple
|
||||||
ENV PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
|
ENV PYTHONPATH=/opt/easyflow/python-packages
|
||||||
|
ENV NODE_PATH=/app/node_modules
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
RUN useradd --system --create-home easyflow && \
|
RUN useradd --system --create-home easyflow && \
|
||||||
apt-get update && \
|
apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
@@ -29,21 +32,100 @@ RUN useradd --system --create-home easyflow && \
|
|||||||
rm -f /tmp/nodesource.gpg.key && \
|
rm -f /tmp/nodesource.gpg.key && \
|
||||||
apt-get update && \
|
apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
|
coreutils \
|
||||||
|
diffutils \
|
||||||
|
file \
|
||||||
|
findutils \
|
||||||
|
fontconfig \
|
||||||
|
fonts-liberation2 \
|
||||||
|
fonts-noto-cjk \
|
||||||
|
gawk \
|
||||||
|
grep \
|
||||||
|
gzip \
|
||||||
inotify-tools \
|
inotify-tools \
|
||||||
|
jq \
|
||||||
|
libdigest-sha-perl \
|
||||||
|
libreoffice-calc \
|
||||||
|
libreoffice-impress \
|
||||||
|
libreoffice-writer \
|
||||||
nodejs \
|
nodejs \
|
||||||
|
pandoc \
|
||||||
|
poppler-utils \
|
||||||
|
procps \
|
||||||
python3 \
|
python3 \
|
||||||
python3-pip \
|
python3-pip \
|
||||||
python3-venv \
|
python3-venv \
|
||||||
|
qpdf \
|
||||||
|
ripgrep \
|
||||||
|
sed \
|
||||||
|
tar \
|
||||||
|
tree \
|
||||||
|
unzip \
|
||||||
|
util-linux \
|
||||||
|
zip \
|
||||||
tini && \
|
tini && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN mkdir -p /etc/pip "${PYTHONPATH}" /opt/easyflow/node-runtime && \
|
||||||
ln -sf /usr/bin/python3 /usr/local/bin/python && \
|
ln -sf /usr/bin/python3 /usr/local/bin/python && \
|
||||||
ln -sf /usr/bin/pip3 /usr/local/bin/pip && \
|
ln -sf /usr/bin/pip3 /usr/local/bin/pip && \
|
||||||
npm config set registry "${NPM_CONFIG_REGISTRY}" && \
|
npm config set registry "${NPM_CONFIG_REGISTRY}" && \
|
||||||
printf "registry=%s\n" "${NPM_CONFIG_REGISTRY}" > /etc/npmrc && \
|
printf "registry=%s\n" "${NPM_CONFIG_REGISTRY}" > /etc/npmrc && \
|
||||||
npm install -g pnpm@10.17.1 && \
|
npm install -g pnpm@10.17.1 && \
|
||||||
pnpm config set registry "${NPM_CONFIG_REGISTRY}" && \
|
pnpm config set registry "${NPM_CONFIG_REGISTRY}" && \
|
||||||
mkdir -p /etc/pip && \
|
printf "[global]\nindex-url = %s\n" "${PIP_INDEX_URL}" > /etc/pip.conf
|
||||||
printf "[global]\nindex-url = %s\ntrusted-host = %s\n" "${PIP_INDEX_URL}" "${PIP_TRUSTED_HOST}" > /etc/pip.conf && \
|
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
RUN python3 -m pip install --no-cache-dir --target "${PYTHONPATH}" \
|
||||||
|
python-docx==1.2.0 \
|
||||||
|
python-pptx==1.0.2 \
|
||||||
|
openpyxl==3.1.5 \
|
||||||
|
xlsxwriter==3.2.9 \
|
||||||
|
lxml==6.1.1 \
|
||||||
|
defusedxml==0.7.1 \
|
||||||
|
pillow==12.3.0 \
|
||||||
|
pypdf==6.16.1 \
|
||||||
|
pdfplumber==0.11.10 \
|
||||||
|
pdf2image==1.17.0 \
|
||||||
|
reportlab==5.0.0 \
|
||||||
|
numpy==2.5.2 \
|
||||||
|
pandas==3.0.5 \
|
||||||
|
matplotlib==3.11.1 \
|
||||||
|
seaborn==0.13.2 \
|
||||||
|
pyyaml==6.0.3 \
|
||||||
|
jsonschema==4.26.0 \
|
||||||
|
jinja2==3.1.6 \
|
||||||
|
beautifulsoup4==4.15.0 \
|
||||||
|
pydantic==2.13.4 \
|
||||||
|
python-dateutil==2.9.0.post0 \
|
||||||
|
tabulate==0.10.0 \
|
||||||
|
markdown==3.10.3 \
|
||||||
|
charset-normalizer==3.5.1 \
|
||||||
|
tenacity==9.1.4 && \
|
||||||
|
PYTHONPATH="${PYTHONPATH}" python3 -c "import bs4, defusedxml, docx, jsonschema, lxml, matplotlib, numpy, openpyxl, pandas, pdfplumber, PIL, pptx, pydantic, pypdf, reportlab, seaborn, yaml"
|
||||||
|
|
||||||
|
RUN npm install --prefix /opt/easyflow/node-runtime --omit=dev --no-audit --no-fund --save-exact \
|
||||||
|
docx@9.7.1 \
|
||||||
|
pptxgenjs@4.0.1 \
|
||||||
|
sharp@0.35.3 \
|
||||||
|
pdf-lib@1.17.1 \
|
||||||
|
pdfjs-dist@6.2.108 \
|
||||||
|
zod@4.4.3 \
|
||||||
|
ajv@8.20.0 \
|
||||||
|
yaml@2.9.0 \
|
||||||
|
csv-parse@7.0.2 \
|
||||||
|
csv-stringify@6.8.3 \
|
||||||
|
fast-xml-parser@5.10.1 \
|
||||||
|
marked@18.0.9 \
|
||||||
|
sanitize-html@2.17.7 \
|
||||||
|
cheerio@1.2.0 \
|
||||||
|
dayjs@1.11.21 \
|
||||||
|
handlebars@4.7.9 \
|
||||||
|
jszip@3.10.1 && \
|
||||||
|
ln -s /opt/easyflow/node-runtime/node_modules /app/node_modules && \
|
||||||
|
node -e "for (const name of ['docx','pptxgenjs','sharp','pdf-lib','pdfjs-dist/package.json','zod','ajv','yaml','csv-parse','csv-stringify','fast-xml-parser','marked','sanitize-html','cheerio','dayjs','handlebars','jszip']) require.resolve(name)" && \
|
||||||
|
npm cache clean --force
|
||||||
|
|
||||||
|
RUN fc-cache -f && \
|
||||||
mkdir -p /app/logs /app/artifacts /app/data && \
|
mkdir -p /app/logs /app/artifacts /app/data && \
|
||||||
chown -R easyflow:easyflow /app
|
chown -R easyflow:easyflow /app
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package tech.easyflow.admin.controller.agent;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
|
import cn.dev33.satoken.annotation.SaMode;
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
|
import org.springframework.http.ContentDisposition;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||||
|
import tech.easyflow.agent.entity.AgentArtifact;
|
||||||
|
import tech.easyflow.agent.runtime.artifact.AgentArtifactService;
|
||||||
|
import tech.easyflow.agent.runtime.artifact.AgentArtifactView;
|
||||||
|
import tech.easyflow.common.domain.Result;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent Artifact 安全元数据与鉴权下载控制器。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/agent/artifacts")
|
||||||
|
public class AgentArtifactController {
|
||||||
|
|
||||||
|
private final AgentArtifactService artifactService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Artifact 控制器。
|
||||||
|
*
|
||||||
|
* @param artifactService Artifact 服务
|
||||||
|
*/
|
||||||
|
public AgentArtifactController(AgentArtifactService artifactService) {
|
||||||
|
this.artifactService = artifactService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询一个已鉴权 Artifact 的安全元数据。
|
||||||
|
*
|
||||||
|
* @param artifactId 稳定 Artifact ID
|
||||||
|
* @return 安全元数据
|
||||||
|
*/
|
||||||
|
@GetMapping("/{artifactId}")
|
||||||
|
@SaCheckPermission(value = {"/api/v1/agent/session/query", "/api/v1/agent/save"}, mode = SaMode.OR)
|
||||||
|
public Result<AgentArtifactView> metadata(@PathVariable String artifactId,
|
||||||
|
@RequestParam BigInteger agentId,
|
||||||
|
@RequestParam String mode,
|
||||||
|
@RequestParam(required = false) BigInteger sessionId,
|
||||||
|
@RequestParam(required = false) String runtimeSessionId) {
|
||||||
|
AgentArtifact artifact = artifactService.requireDownload(
|
||||||
|
artifactId, requireAccount(), agentId, mode, sessionId, runtimeSessionId);
|
||||||
|
return Result.ok(artifactService.toView(artifact));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过后端鉴权代理流式下载私有 Artifact。
|
||||||
|
*
|
||||||
|
* @param artifactId 稳定 Artifact ID
|
||||||
|
* @return 私有流式响应
|
||||||
|
*/
|
||||||
|
@GetMapping("/{artifactId}/content")
|
||||||
|
@SaCheckPermission(value = {"/api/v1/agent/session/query", "/api/v1/agent/save"}, mode = SaMode.OR)
|
||||||
|
public ResponseEntity<StreamingResponseBody> content(@PathVariable String artifactId,
|
||||||
|
@RequestParam BigInteger agentId,
|
||||||
|
@RequestParam String mode,
|
||||||
|
@RequestParam(required = false) BigInteger sessionId,
|
||||||
|
@RequestParam(required = false) String runtimeSessionId) {
|
||||||
|
AgentArtifact artifact = artifactService.requireDownload(
|
||||||
|
artifactId, requireAccount(), agentId, mode, sessionId, runtimeSessionId);
|
||||||
|
StreamingResponseBody body = output -> {
|
||||||
|
try (InputStream input = artifactService.openDownload(artifact)) {
|
||||||
|
input.transferTo(output);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
String mimeType = artifact.getMimeType() == null
|
||||||
|
? MediaType.APPLICATION_OCTET_STREAM_VALUE : artifact.getMimeType();
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.cacheControl(CacheControl.noStore())
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
|
||||||
|
.filename(artifact.getFileName(), StandardCharsets.UTF_8).build().toString())
|
||||||
|
.header("X-Content-Type-Options", "nosniff")
|
||||||
|
.contentType(MediaType.parseMediaType(mimeType))
|
||||||
|
.contentLength(artifact.getSizeBytes() == null ? 0L : artifact.getSizeBytes())
|
||||||
|
.body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LoginAccount requireAccount() {
|
||||||
|
try {
|
||||||
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
|
if (account == null || account.getId() == null || account.getTenantId() == null) {
|
||||||
|
throw new BusinessException("当前登录状态失效,请重新登录后再试");
|
||||||
|
}
|
||||||
|
return account;
|
||||||
|
} catch (BusinessException error) {
|
||||||
|
throw error;
|
||||||
|
} catch (Exception error) {
|
||||||
|
throw new BusinessException("当前登录状态失效,请重新登录后再试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
|
|||||||
import cn.dev33.satoken.annotation.SaMode;
|
import cn.dev33.satoken.annotation.SaMode;
|
||||||
import com.mybatisflex.core.paginate.Page;
|
import com.mybatisflex.core.paginate.Page;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import io.agentscope.core.agui.model.RunAgentInput;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.springframework.http.ContentDisposition;
|
import org.springframework.http.ContentDisposition;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
@@ -12,7 +13,9 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.springframework.web.context.request.RequestContextHolder;
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
@@ -27,6 +30,7 @@ import tech.easyflow.agent.publish.AgentPublishAppService;
|
|||||||
import tech.easyflow.agent.runtime.AgentChatRequest;
|
import tech.easyflow.agent.runtime.AgentChatRequest;
|
||||||
import tech.easyflow.agent.runtime.AgentDraftChatRequest;
|
import tech.easyflow.agent.runtime.AgentDraftChatRequest;
|
||||||
import tech.easyflow.agent.runtime.AgentRunService;
|
import tech.easyflow.agent.runtime.AgentRunService;
|
||||||
|
import tech.easyflow.agent.runtime.agui.AgentAguiHitlResolveRequest;
|
||||||
import tech.easyflow.agent.runtime.composer.AgentComposerDraft;
|
import tech.easyflow.agent.runtime.composer.AgentComposerDraft;
|
||||||
import tech.easyflow.agent.runtime.composer.AgentComposerDraftService;
|
import tech.easyflow.agent.runtime.composer.AgentComposerDraftService;
|
||||||
import tech.easyflow.agent.runtime.composer.AgentComposerSession;
|
import tech.easyflow.agent.runtime.composer.AgentComposerSession;
|
||||||
@@ -41,6 +45,7 @@ import tech.easyflow.agent.service.AgentApprovalStateService;
|
|||||||
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
|
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
|
||||||
import tech.easyflow.agent.service.AgentOptionQueryService;
|
import tech.easyflow.agent.service.AgentOptionQueryService;
|
||||||
import tech.easyflow.agent.service.AgentService;
|
import tech.easyflow.agent.service.AgentService;
|
||||||
|
import tech.easyflow.agent.service.AgentSkillBindingService;
|
||||||
import tech.easyflow.agent.service.AgentToolBindingService;
|
import tech.easyflow.agent.service.AgentToolBindingService;
|
||||||
import tech.easyflow.agent.vo.AgentOptionView;
|
import tech.easyflow.agent.vo.AgentOptionView;
|
||||||
import tech.easyflow.agent.vo.AgentResourceOptionsView;
|
import tech.easyflow.agent.vo.AgentResourceOptionsView;
|
||||||
@@ -74,6 +79,8 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
|||||||
@Resource
|
@Resource
|
||||||
private AgentKnowledgeBindingService agentKnowledgeBindingService;
|
private AgentKnowledgeBindingService agentKnowledgeBindingService;
|
||||||
@Resource
|
@Resource
|
||||||
|
private AgentSkillBindingService agentSkillBindingService;
|
||||||
|
@Resource
|
||||||
private AgentRunService agentRunService;
|
private AgentRunService agentRunService;
|
||||||
@Resource
|
@Resource
|
||||||
private AgentPublishAppService agentPublishAppService;
|
private AgentPublishAppService agentPublishAppService;
|
||||||
@@ -118,10 +125,11 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
|||||||
* @return Agent 详情
|
* @return Agent 详情
|
||||||
*/
|
*/
|
||||||
@GetMapping("/getDetail")
|
@GetMapping("/getDetail")
|
||||||
public Result<Agent> getDetail(BigInteger id) {
|
public Result<AgentDetailView> getDetail(BigInteger id) {
|
||||||
Agent agent = service.getDetail(id);
|
Agent agent = service.getDetail(id);
|
||||||
agentApprovalStateService.fillAgentApprovalState(agent);
|
agentApprovalStateService.fillAgentApprovalState(agent);
|
||||||
return Result.ok(agent);
|
aiResourceCreatorNameSupport.fillAgentCreatorNames(List.of(agent));
|
||||||
|
return Result.ok(AgentDetailView.from(agent));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,7 +141,8 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
|||||||
@Override
|
@Override
|
||||||
@PostMapping("save")
|
@PostMapping("save")
|
||||||
public Result<?> save(@JsonBody Agent agent) {
|
public Result<?> save(@JsonBody Agent agent) {
|
||||||
return Result.ok(service.saveDraft(agent));
|
Agent saved = service.saveDraft(agent);
|
||||||
|
return Result.ok(AgentDetailView.from(service.getDetail(saved.getId())));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,7 +154,32 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
|||||||
@Override
|
@Override
|
||||||
@PostMapping("update")
|
@PostMapping("update")
|
||||||
public Result<?> update(@JsonBody Agent agent) {
|
public Result<?> update(@JsonBody Agent agent) {
|
||||||
return Result.ok(service.updateDraft(agent));
|
Agent saved = service.updateDraft(agent);
|
||||||
|
return Result.ok(AgentDetailView.from(service.getDetail(saved.getId())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子保存 Agent 草稿及本次发生变化的绑定组。
|
||||||
|
*
|
||||||
|
* @param request 设计器保存请求
|
||||||
|
* @return 保存后的 Agent 与本次替换的绑定
|
||||||
|
*/
|
||||||
|
@PostMapping("/draft/save")
|
||||||
|
@SaCheckPermission("/api/v1/agent/save")
|
||||||
|
public Result<AgentDetailView> saveDraft(@JsonBody(required = true, skipConvertError = false)
|
||||||
|
AgentDraftSaveRequest request) {
|
||||||
|
if (request == null || request.getAgent() == null) {
|
||||||
|
throw new BusinessException("Agent 草稿不能为空");
|
||||||
|
}
|
||||||
|
Agent saved = service.saveDraftGraph(
|
||||||
|
request.getAgent(),
|
||||||
|
request.getToolBindings(),
|
||||||
|
request.isReplaceToolBindings(),
|
||||||
|
request.getKnowledgeBindings(),
|
||||||
|
request.isReplaceKnowledgeBindings(),
|
||||||
|
request.toSkillBindings(),
|
||||||
|
request.isReplaceSkillBindings());
|
||||||
|
return Result.ok(AgentDetailView.from(saved));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -156,8 +190,9 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
|||||||
*/
|
*/
|
||||||
@PostMapping("visibilityScope/update")
|
@PostMapping("visibilityScope/update")
|
||||||
@SaCheckPermission("/api/v1/agent/save")
|
@SaCheckPermission("/api/v1/agent/save")
|
||||||
public Result<Agent> updateVisibilityScope(@JsonBody Agent agent) {
|
public Result<AgentDetailView> updateVisibilityScope(@JsonBody Agent agent) {
|
||||||
return Result.ok(service.updateVisibilityScope(agent.getId(), agent.getVisibilityScope()));
|
return Result.ok(AgentDetailView.from(
|
||||||
|
service.updateVisibilityScope(agent.getId(), agent.getVisibilityScope())));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -258,6 +293,47 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
|||||||
return agentRunService.chatDraft(request);
|
return agentRunService.chatDraft(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 AG-UI 协议运行正式 Agent 聊天。
|
||||||
|
*
|
||||||
|
* @param agentId URL 中的 Agent ID
|
||||||
|
* @param input AG-UI 运行输入
|
||||||
|
* @return 原生 AG-UI SSE
|
||||||
|
*/
|
||||||
|
@PostMapping("/{agentId}/agui/run")
|
||||||
|
@SaCheckPermission("/api/v1/agent/session/query")
|
||||||
|
public SseEmitter chatAgui(@PathVariable BigInteger agentId,
|
||||||
|
@RequestBody RunAgentInput input) {
|
||||||
|
return agentRunService.chatAgui(agentId, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 AG-UI 协议运行草稿 Agent 试用。
|
||||||
|
*
|
||||||
|
* @param input AG-UI 运行输入
|
||||||
|
* @return 原生 AG-UI SSE
|
||||||
|
*/
|
||||||
|
@PostMapping("/agui/run/draft")
|
||||||
|
@SaCheckPermission("/api/v1/agent/save")
|
||||||
|
public SseEmitter chatDraftAgui(@RequestBody RunAgentInput input) {
|
||||||
|
return agentRunService.chatDraftAgui(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 AG-UI 自定义 HITL 兼容桥审批。
|
||||||
|
*
|
||||||
|
* @param request 审批请求
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/agui/hitl/resolve")
|
||||||
|
@SaCheckPermission(value = {
|
||||||
|
"/api/v1/agent/session/query", "/api/v1/agent/save"
|
||||||
|
}, mode = SaMode.OR)
|
||||||
|
public Result<Void> resolveAguiApproval(@RequestBody AgentAguiHitlResolveRequest request) {
|
||||||
|
agentRunService.resolveAguiApproval(request);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传一张 Agent 聊天临时图片。
|
* 上传一张 Agent 聊天临时图片。
|
||||||
*
|
*
|
||||||
@@ -565,6 +641,26 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
|||||||
return Result.ok(agentKnowledgeBindingService.replaceBindings(agentId, bindings));
|
return Result.ok(agentKnowledgeBindingService.replaceBindings(agentId, bindings));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子替换 Agent 的全部 Skill 草稿绑定。
|
||||||
|
*
|
||||||
|
* @param request 白名单 Skill 引用请求
|
||||||
|
* @return 服务端生成的安全 Skill 摘要
|
||||||
|
*/
|
||||||
|
@PostMapping("/skillBinding/update")
|
||||||
|
@SaCheckPermission("/api/v1/agent/save")
|
||||||
|
public Result<List<AgentDetailView.SkillBindingView>> updateSkillBinding(
|
||||||
|
@JsonBody(required = true, skipConvertError = false) AgentSkillBindingUpdateRequest request) {
|
||||||
|
if (request == null || request.getAgentId() == null) {
|
||||||
|
throw new BusinessException("Agent ID 不能为空");
|
||||||
|
}
|
||||||
|
List<tech.easyflow.agent.entity.AgentSkillBinding> bindings = request.getBindings() == null
|
||||||
|
? List.of()
|
||||||
|
: request.getBindings().stream().map(AgentSkillBindingUpdateRequest.Binding::toEntity).toList();
|
||||||
|
return Result.ok(agentSkillBindingService.replaceBindings(request.getAgentId(), bindings)
|
||||||
|
.stream().map(AgentDetailView.SkillBindingView::from).toList());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提交发布审批。
|
* 提交发布审批。
|
||||||
*
|
*
|
||||||
@@ -650,6 +746,7 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
|||||||
agent.setPublishedSnapshotJson(Collections.emptyMap());
|
agent.setPublishedSnapshotJson(Collections.emptyMap());
|
||||||
agent.setToolBindings(null);
|
agent.setToolBindings(null);
|
||||||
agent.setKnowledgeBindings(null);
|
agent.setKnowledgeBindings(null);
|
||||||
|
agent.setSkillBindings(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package tech.easyflow.admin.controller.agent;
|
||||||
|
|
||||||
|
import tech.easyflow.agent.entity.Agent;
|
||||||
|
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||||
|
import tech.easyflow.agent.entity.AgentSkillBinding;
|
||||||
|
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理端 Agent 草稿安全详情。
|
||||||
|
*
|
||||||
|
* <p>该视图明确排除发布快照以及各绑定的内部资源快照。</p>
|
||||||
|
*/
|
||||||
|
public record AgentDetailView(
|
||||||
|
BigInteger id,
|
||||||
|
BigInteger deptId,
|
||||||
|
String name,
|
||||||
|
String description,
|
||||||
|
String avatar,
|
||||||
|
BigInteger categoryId,
|
||||||
|
BigInteger modelId,
|
||||||
|
Map<String, Object> modelConfigJson,
|
||||||
|
Map<String, Object> generationConfigJson,
|
||||||
|
Map<String, Object> promptConfigJson,
|
||||||
|
Map<String, Object> memoryConfigJson,
|
||||||
|
Map<String, Object> executionConfigJson,
|
||||||
|
Map<String, Object> interactionConfigJson,
|
||||||
|
Integer status,
|
||||||
|
String visibilityScope,
|
||||||
|
String publishStatus,
|
||||||
|
BigInteger currentApprovalInstanceId,
|
||||||
|
Date publishedAt,
|
||||||
|
BigInteger publishedBy,
|
||||||
|
Date created,
|
||||||
|
BigInteger createdBy,
|
||||||
|
Date modified,
|
||||||
|
BigInteger modifiedBy,
|
||||||
|
Boolean approvalPending,
|
||||||
|
String currentApprovalActionType,
|
||||||
|
String displayPublishStatus,
|
||||||
|
String createdByName,
|
||||||
|
List<ToolBindingView> toolBindings,
|
||||||
|
List<KnowledgeBindingView> knowledgeBindings,
|
||||||
|
List<SkillBindingView> skillBindings) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从领域实体构造安全详情。
|
||||||
|
*
|
||||||
|
* @param agent Agent 领域实体
|
||||||
|
* @return 安全详情
|
||||||
|
*/
|
||||||
|
public static AgentDetailView from(Agent agent) {
|
||||||
|
return new AgentDetailView(agent.getId(), agent.getDeptId(), agent.getName(), agent.getDescription(),
|
||||||
|
agent.getAvatar(), agent.getCategoryId(), agent.getModelId(), agent.getModelConfigJson(),
|
||||||
|
agent.getGenerationConfigJson(), agent.getPromptConfigJson(), agent.getMemoryConfigJson(),
|
||||||
|
agent.getExecutionConfigJson(), agent.getInteractionConfigJson(), agent.getStatus(),
|
||||||
|
agent.getVisibilityScope(), agent.getPublishStatus(), agent.getCurrentApprovalInstanceId(),
|
||||||
|
agent.getPublishedAt(), agent.getPublishedBy(), agent.getCreated(), agent.getCreatedBy(),
|
||||||
|
agent.getModified(), agent.getModifiedBy(), agent.getApprovalPending(),
|
||||||
|
agent.getCurrentApprovalActionType(), agent.getDisplayPublishStatus(), agent.getCreatedByName(),
|
||||||
|
mapTools(agent.getToolBindings()), mapKnowledges(agent.getKnowledgeBindings()),
|
||||||
|
mapSkills(agent.getSkillBindings()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ToolBindingView> mapTools(List<AgentToolBinding> bindings) {
|
||||||
|
return bindings == null ? List.of() : bindings.stream().map(ToolBindingView::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<KnowledgeBindingView> mapKnowledges(List<AgentKnowledgeBinding> bindings) {
|
||||||
|
return bindings == null ? List.of() : bindings.stream().map(KnowledgeBindingView::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<SkillBindingView> mapSkills(List<AgentSkillBinding> bindings) {
|
||||||
|
return bindings == null ? List.of() : bindings.stream().map(SkillBindingView::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Agent 直接 Tool 草稿绑定。 */
|
||||||
|
public record ToolBindingView(BigInteger id, String toolType, BigInteger targetId, String toolName,
|
||||||
|
Boolean enabled, Boolean hitlEnabled, Map<String, Object> hitlConfigJson,
|
||||||
|
Map<String, Object> optionsJson, Integer sortNo,
|
||||||
|
Map<String, Object> resourceSummary) {
|
||||||
|
/** @param value 实体 @return 安全绑定 */
|
||||||
|
static ToolBindingView from(AgentToolBinding value) {
|
||||||
|
return new ToolBindingView(value.getId(), value.getToolType(), value.getTargetId(), value.getToolName(),
|
||||||
|
value.getEnabled(), value.getHitlEnabled(), value.getHitlConfigJson(), value.getOptionsJson(),
|
||||||
|
value.getSortNo(), value.getResourceSummary());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Agent 知识库草稿绑定。 */
|
||||||
|
public record KnowledgeBindingView(BigInteger id, BigInteger knowledgeId, String retrievalMode,
|
||||||
|
Boolean enabled, Map<String, Object> optionsJson, Integer sortNo,
|
||||||
|
Map<String, Object> resourceSummary) {
|
||||||
|
/** @param value 实体 @return 安全绑定 */
|
||||||
|
static KnowledgeBindingView from(AgentKnowledgeBinding value) {
|
||||||
|
return new KnowledgeBindingView(value.getId(), value.getKnowledgeId(), value.getRetrievalMode(),
|
||||||
|
value.getEnabled(), value.getOptionsJson(), value.getSortNo(), value.getResourceSummary());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Agent Skill 草稿绑定。 */
|
||||||
|
public record SkillBindingView(BigInteger id, BigInteger skillId, Integer sortNo,
|
||||||
|
Map<String, Object> resourceSummary) {
|
||||||
|
/** @param value 实体 @return 安全绑定 */
|
||||||
|
static SkillBindingView from(AgentSkillBinding value) {
|
||||||
|
return new SkillBindingView(value.getId(), value.getSkillId(), value.getSortNo(),
|
||||||
|
value.getResourceSummary());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package tech.easyflow.admin.controller.agent;
|
||||||
|
|
||||||
|
import tech.easyflow.agent.entity.Agent;
|
||||||
|
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||||
|
import tech.easyflow.agent.entity.AgentSkillBinding;
|
||||||
|
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 设计器原子保存请求。
|
||||||
|
*
|
||||||
|
* <p>绑定变更标记由设计器基于加载后的稳定业务字段计算。服务端仍会执行权限、状态与幂等比较,
|
||||||
|
* 标记为未变化的绑定不会进入查询、外部资源校验或整组重写流程。</p>
|
||||||
|
*/
|
||||||
|
public class AgentDraftSaveRequest {
|
||||||
|
|
||||||
|
private Agent agent;
|
||||||
|
private List<AgentToolBinding> toolBindings;
|
||||||
|
private boolean replaceToolBindings;
|
||||||
|
private List<AgentKnowledgeBinding> knowledgeBindings;
|
||||||
|
private boolean replaceKnowledgeBindings;
|
||||||
|
private List<AgentSkillBindingUpdateRequest.Binding> skillBindings;
|
||||||
|
private boolean replaceSkillBindings;
|
||||||
|
|
||||||
|
/** 创建空请求。 */
|
||||||
|
public AgentDraftSaveRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Agent 草稿 */
|
||||||
|
public Agent getAgent() {
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param agent Agent 草稿 */
|
||||||
|
public void setAgent(Agent agent) {
|
||||||
|
this.agent = agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 工具绑定 */
|
||||||
|
public List<AgentToolBinding> getToolBindings() {
|
||||||
|
return toolBindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param toolBindings 工具绑定 */
|
||||||
|
public void setToolBindings(List<AgentToolBinding> toolBindings) {
|
||||||
|
this.toolBindings = toolBindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 是否替换工具绑定 */
|
||||||
|
public boolean isReplaceToolBindings() {
|
||||||
|
return replaceToolBindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param replaceToolBindings 是否替换工具绑定 */
|
||||||
|
public void setReplaceToolBindings(boolean replaceToolBindings) {
|
||||||
|
this.replaceToolBindings = replaceToolBindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 知识库绑定 */
|
||||||
|
public List<AgentKnowledgeBinding> getKnowledgeBindings() {
|
||||||
|
return knowledgeBindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param knowledgeBindings 知识库绑定 */
|
||||||
|
public void setKnowledgeBindings(List<AgentKnowledgeBinding> knowledgeBindings) {
|
||||||
|
this.knowledgeBindings = knowledgeBindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 是否替换知识库绑定 */
|
||||||
|
public boolean isReplaceKnowledgeBindings() {
|
||||||
|
return replaceKnowledgeBindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param replaceKnowledgeBindings 是否替换知识库绑定 */
|
||||||
|
public void setReplaceKnowledgeBindings(boolean replaceKnowledgeBindings) {
|
||||||
|
this.replaceKnowledgeBindings = replaceKnowledgeBindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Skill 绑定 */
|
||||||
|
public List<AgentSkillBindingUpdateRequest.Binding> getSkillBindings() {
|
||||||
|
return skillBindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param skillBindings Skill 绑定 */
|
||||||
|
public void setSkillBindings(List<AgentSkillBindingUpdateRequest.Binding> skillBindings) {
|
||||||
|
this.skillBindings = skillBindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 是否替换 Skill 绑定 */
|
||||||
|
public boolean isReplaceSkillBindings() {
|
||||||
|
return replaceSkillBindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param replaceSkillBindings 是否替换 Skill 绑定 */
|
||||||
|
public void setReplaceSkillBindings(boolean replaceSkillBindings) {
|
||||||
|
this.replaceSkillBindings = replaceSkillBindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Skill 白名单引用转换为领域绑定。
|
||||||
|
*
|
||||||
|
* @return 最小 Skill 绑定列表
|
||||||
|
*/
|
||||||
|
public List<AgentSkillBinding> toSkillBindings() {
|
||||||
|
return skillBindings == null
|
||||||
|
? List.of() : skillBindings.stream().map(AgentSkillBindingUpdateRequest.Binding::toEntity).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package tech.easyflow.admin.controller.agent;
|
||||||
|
|
||||||
|
import tech.easyflow.agent.entity.AgentSkillBinding;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent Skill 整组替换请求。
|
||||||
|
*
|
||||||
|
* <p>使用标准 JavaBean 以兼容 {@code @JsonBody} 的 Fastjson 1 嵌套列表转换。</p>
|
||||||
|
*/
|
||||||
|
public class AgentSkillBindingUpdateRequest {
|
||||||
|
|
||||||
|
private BigInteger agentId;
|
||||||
|
private List<Binding> bindings;
|
||||||
|
|
||||||
|
/** 创建空请求。 */
|
||||||
|
public AgentSkillBindingUpdateRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Agent Skill 绑定请求。
|
||||||
|
*
|
||||||
|
* @param agentId Agent ID
|
||||||
|
* @param bindings Skill 引用
|
||||||
|
*/
|
||||||
|
public AgentSkillBindingUpdateRequest(BigInteger agentId, List<Binding> bindings) {
|
||||||
|
this.agentId = agentId;
|
||||||
|
this.bindings = bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Agent ID */
|
||||||
|
public BigInteger getAgentId() { return agentId; }
|
||||||
|
/** @param agentId Agent ID */
|
||||||
|
public void setAgentId(BigInteger agentId) { this.agentId = agentId; }
|
||||||
|
/** @return Skill 引用 */
|
||||||
|
public List<Binding> getBindings() { return bindings; }
|
||||||
|
/** @param bindings Skill 引用 */
|
||||||
|
public void setBindings(List<Binding> bindings) { this.bindings = bindings; }
|
||||||
|
|
||||||
|
/** 客户端允许提交的最小 Skill 引用。 */
|
||||||
|
public static class Binding {
|
||||||
|
|
||||||
|
private BigInteger skillId;
|
||||||
|
private Integer sortNo;
|
||||||
|
|
||||||
|
/** 创建空绑定。 */
|
||||||
|
public Binding() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建最小 Skill 绑定。
|
||||||
|
*
|
||||||
|
* @param skillId Skill ID
|
||||||
|
* @param sortNo 排序号
|
||||||
|
*/
|
||||||
|
public Binding(BigInteger skillId, Integer sortNo) {
|
||||||
|
this.skillId = skillId;
|
||||||
|
this.sortNo = sortNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Skill ID */
|
||||||
|
public BigInteger getSkillId() { return skillId; }
|
||||||
|
/** @param skillId Skill ID */
|
||||||
|
public void setSkillId(BigInteger skillId) { this.skillId = skillId; }
|
||||||
|
/** @return 排序号 */
|
||||||
|
public Integer getSortNo() { return sortNo; }
|
||||||
|
/** @param sortNo 排序号 */
|
||||||
|
public void setSortNo(Integer sortNo) { this.sortNo = sortNo; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换为不含任何服务端快照的领域引用。
|
||||||
|
*
|
||||||
|
* @return 最小 Skill 绑定
|
||||||
|
*/
|
||||||
|
public AgentSkillBinding toEntity() {
|
||||||
|
AgentSkillBinding value = new AgentSkillBinding();
|
||||||
|
value.setSkillId(skillId);
|
||||||
|
value.setSortNo(sortNo);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -324,7 +324,12 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
)
|
)
|
||||||
public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId,
|
public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId,
|
||||||
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
|
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
|
||||||
chainExecutor.resumeAsync(executeId, confirmParams);
|
if (!chainExecutor.resumeAsyncIfSuspended(executeId, confirmParams)) {
|
||||||
|
throw new BusinessException(
|
||||||
|
409,
|
||||||
|
40901,
|
||||||
|
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
|
||||||
|
}
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package tech.easyflow.admin.controller.skill;
|
package tech.easyflow.admin.controller.skill;
|
||||||
|
|
||||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
|
import cn.dev33.satoken.annotation.SaMode;
|
||||||
import com.mybatisflex.core.paginate.Page;
|
import com.mybatisflex.core.paginate.Page;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
@@ -17,6 +18,7 @@ import tech.easyflow.admin.controller.skill.vo.SkillCopyRequest;
|
|||||||
import tech.easyflow.admin.controller.skill.vo.SkillDraftRequest;
|
import tech.easyflow.admin.controller.skill.vo.SkillDraftRequest;
|
||||||
import tech.easyflow.admin.controller.skill.vo.SkillImportBatchResultView;
|
import tech.easyflow.admin.controller.skill.vo.SkillImportBatchResultView;
|
||||||
import tech.easyflow.admin.controller.skill.vo.SkillView;
|
import tech.easyflow.admin.controller.skill.vo.SkillView;
|
||||||
|
import tech.easyflow.admin.controller.skill.vo.SkillToolBindingUpdateRequest;
|
||||||
import tech.easyflow.admin.controller.skill.vo.SkillPublishStatusView;
|
import tech.easyflow.admin.controller.skill.vo.SkillPublishStatusView;
|
||||||
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
|
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
@@ -41,6 +43,10 @@ import tech.easyflow.skill.publish.SkillPublishAppService;
|
|||||||
import tech.easyflow.skill.security.SkillVisibilityQueryHelper;
|
import tech.easyflow.skill.security.SkillVisibilityQueryHelper;
|
||||||
import tech.easyflow.skill.service.SkillApprovalStateService;
|
import tech.easyflow.skill.service.SkillApprovalStateService;
|
||||||
import tech.easyflow.skill.service.SkillService;
|
import tech.easyflow.skill.service.SkillService;
|
||||||
|
import tech.easyflow.skill.service.SkillToolBindingService;
|
||||||
|
import tech.easyflow.skill.service.SkillToolOptionQueryService;
|
||||||
|
import tech.easyflow.skill.vo.SkillMcpToolManifestView;
|
||||||
|
import tech.easyflow.skill.vo.SkillToolOptionPage;
|
||||||
import tech.easyflow.skill.validation.SkillValidationResult;
|
import tech.easyflow.skill.validation.SkillValidationResult;
|
||||||
import tech.easyflow.system.enums.CategoryResourceType;
|
import tech.easyflow.system.enums.CategoryResourceType;
|
||||||
import tech.easyflow.system.enums.ResourceAction;
|
import tech.easyflow.system.enums.ResourceAction;
|
||||||
@@ -73,6 +79,8 @@ public class SkillController {
|
|||||||
private final SkillImportService skillImportService;
|
private final SkillImportService skillImportService;
|
||||||
private final SkillExportService skillExportService;
|
private final SkillExportService skillExportService;
|
||||||
private final SkillFileService skillFileService;
|
private final SkillFileService skillFileService;
|
||||||
|
private final SkillToolBindingService skillToolBindingService;
|
||||||
|
private final SkillToolOptionQueryService skillToolOptionQueryService;
|
||||||
private final ResourceAccessService resourceAccessService;
|
private final ResourceAccessService resourceAccessService;
|
||||||
private final CategoryPermissionService categoryPermissionService;
|
private final CategoryPermissionService categoryPermissionService;
|
||||||
private final SkillVisibilityQueryHelper visibilityQueryHelper;
|
private final SkillVisibilityQueryHelper visibilityQueryHelper;
|
||||||
@@ -87,6 +95,8 @@ public class SkillController {
|
|||||||
* @param skillImportService 导入服务
|
* @param skillImportService 导入服务
|
||||||
* @param skillExportService 导出服务
|
* @param skillExportService 导出服务
|
||||||
* @param skillFileService 文件服务
|
* @param skillFileService 文件服务
|
||||||
|
* @param skillToolBindingService Skill Tool 绑定服务
|
||||||
|
* @param skillToolOptionQueryService Skill Tool 候选查询服务
|
||||||
* @param resourceAccessService 资源权限服务
|
* @param resourceAccessService 资源权限服务
|
||||||
* @param categoryPermissionService 分类权限服务
|
* @param categoryPermissionService 分类权限服务
|
||||||
* @param visibilityQueryHelper 可见性查询助手
|
* @param visibilityQueryHelper 可见性查询助手
|
||||||
@@ -98,6 +108,8 @@ public class SkillController {
|
|||||||
SkillImportService skillImportService,
|
SkillImportService skillImportService,
|
||||||
SkillExportService skillExportService,
|
SkillExportService skillExportService,
|
||||||
SkillFileService skillFileService,
|
SkillFileService skillFileService,
|
||||||
|
SkillToolBindingService skillToolBindingService,
|
||||||
|
SkillToolOptionQueryService skillToolOptionQueryService,
|
||||||
ResourceAccessService resourceAccessService,
|
ResourceAccessService resourceAccessService,
|
||||||
CategoryPermissionService categoryPermissionService,
|
CategoryPermissionService categoryPermissionService,
|
||||||
SkillVisibilityQueryHelper visibilityQueryHelper,
|
SkillVisibilityQueryHelper visibilityQueryHelper,
|
||||||
@@ -108,6 +120,8 @@ public class SkillController {
|
|||||||
this.skillImportService = skillImportService;
|
this.skillImportService = skillImportService;
|
||||||
this.skillExportService = skillExportService;
|
this.skillExportService = skillExportService;
|
||||||
this.skillFileService = skillFileService;
|
this.skillFileService = skillFileService;
|
||||||
|
this.skillToolBindingService = skillToolBindingService;
|
||||||
|
this.skillToolOptionQueryService = skillToolOptionQueryService;
|
||||||
this.resourceAccessService = resourceAccessService;
|
this.resourceAccessService = resourceAccessService;
|
||||||
this.categoryPermissionService = categoryPermissionService;
|
this.categoryPermissionService = categoryPermissionService;
|
||||||
this.visibilityQueryHelper = visibilityQueryHelper;
|
this.visibilityQueryHelper = visibilityQueryHelper;
|
||||||
@@ -179,6 +193,54 @@ public class SkillController {
|
|||||||
return Result.ok(toView(skill));
|
return Result.ok(toView(skill));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询 Skill 可绑定的 Tool 候选。
|
||||||
|
*
|
||||||
|
* @param keyword 名称或描述关键词
|
||||||
|
* @param toolType 类型过滤
|
||||||
|
* @param pageNum 页码
|
||||||
|
* @param pageSize 每页数量
|
||||||
|
* @return 安全候选分页
|
||||||
|
*/
|
||||||
|
@GetMapping("/toolOptions")
|
||||||
|
@SaCheckPermission(value = {"/api/v1/skill/save", "/api/v1/skill/update"}, mode = SaMode.OR)
|
||||||
|
public Result<SkillToolOptionPage> toolOptions(String keyword, String toolType,
|
||||||
|
Long pageNum, Long pageSize) {
|
||||||
|
return Result.ok(skillToolOptionQueryService.page(keyword, toolType,
|
||||||
|
pageNum == null ? 1 : pageNum, pageSize == null ? 20 : pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按需读取指定 MCP 的脱敏 Tool 清单。
|
||||||
|
*
|
||||||
|
* @param mcpId MCP ID
|
||||||
|
* @return MCP Tool 清单
|
||||||
|
*/
|
||||||
|
@GetMapping("/mcpTools")
|
||||||
|
@SaCheckPermission(value = {"/api/v1/skill/save", "/api/v1/skill/update"}, mode = SaMode.OR)
|
||||||
|
public Result<SkillMcpToolManifestView> mcpTools(BigInteger mcpId) {
|
||||||
|
return Result.ok(skillToolOptionQueryService.mcpTools(mcpId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子替换 Skill 的全部平台 Tool 草稿绑定。
|
||||||
|
*
|
||||||
|
* @param request 白名单绑定请求
|
||||||
|
* @return 服务端规范化的安全绑定摘要
|
||||||
|
*/
|
||||||
|
@PostMapping("/toolBinding/update")
|
||||||
|
@SaCheckPermission(value = {"/api/v1/skill/save", "/api/v1/skill/update"}, mode = SaMode.OR)
|
||||||
|
public Result<List<SkillView.ToolBindingView>> updateToolBindings(
|
||||||
|
@JsonBody(required = true, skipConvertError = false) SkillToolBindingUpdateRequest request) {
|
||||||
|
if (request == null || request.getSkillId() == null) {
|
||||||
|
throw new BusinessException("Skill ID 不能为空");
|
||||||
|
}
|
||||||
|
List<tech.easyflow.skill.entity.SkillToolBinding> bindings = request.getBindings() == null
|
||||||
|
? List.of() : request.getBindings().stream().map(SkillToolBindingUpdateRequest.Binding::toEntity).toList();
|
||||||
|
return Result.ok(skillToolBindingService.replaceBindings(request.getSkillId(), bindings)
|
||||||
|
.stream().map(SkillView.ToolBindingView::from).toList());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 Skill 草稿。
|
* 创建 Skill 草稿。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package tech.easyflow.admin.controller.skill.vo;
|
||||||
|
|
||||||
|
import tech.easyflow.skill.entity.SkillToolBinding;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill Tool 整组替换请求。
|
||||||
|
*
|
||||||
|
* <p>{@code @JsonBody} 当前由 Fastjson 1 完成转换,使用标准 JavaBean 可确保嵌套列表元素
|
||||||
|
* 按声明类型转换,避免嵌套 record 被保留为 {@code JSONObject}。</p>
|
||||||
|
*/
|
||||||
|
public class SkillToolBindingUpdateRequest {
|
||||||
|
|
||||||
|
private BigInteger skillId;
|
||||||
|
private List<Binding> bindings;
|
||||||
|
|
||||||
|
/** 创建空请求。 */
|
||||||
|
public SkillToolBindingUpdateRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Skill Tool 绑定请求。
|
||||||
|
*
|
||||||
|
* @param skillId Skill ID
|
||||||
|
* @param bindings 绑定引用
|
||||||
|
*/
|
||||||
|
public SkillToolBindingUpdateRequest(BigInteger skillId, List<Binding> bindings) {
|
||||||
|
this.skillId = skillId;
|
||||||
|
this.bindings = bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Skill ID */
|
||||||
|
public BigInteger getSkillId() { return skillId; }
|
||||||
|
/** @param skillId Skill ID */
|
||||||
|
public void setSkillId(BigInteger skillId) { this.skillId = skillId; }
|
||||||
|
/** @return 绑定引用 */
|
||||||
|
public List<Binding> getBindings() { return bindings; }
|
||||||
|
/** @param bindings 绑定引用 */
|
||||||
|
public void setBindings(List<Binding> bindings) { this.bindings = bindings; }
|
||||||
|
|
||||||
|
/** 客户端允许提交的最小绑定字段。 */
|
||||||
|
public static class Binding {
|
||||||
|
|
||||||
|
private String toolType;
|
||||||
|
private BigInteger targetId;
|
||||||
|
private Boolean hitlEnabled;
|
||||||
|
private Integer sortNo;
|
||||||
|
private String mcpToolManifestHash;
|
||||||
|
|
||||||
|
/** 创建空绑定。 */
|
||||||
|
public Binding() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建最小 Tool 绑定。
|
||||||
|
*
|
||||||
|
* @param toolType Tool 类型
|
||||||
|
* @param targetId 目标资源 ID
|
||||||
|
* @param hitlEnabled 是否调用前确认
|
||||||
|
* @param sortNo 排序号
|
||||||
|
* @param mcpToolManifestHash MCP Tool 清单 hash
|
||||||
|
*/
|
||||||
|
public Binding(String toolType, BigInteger targetId, Boolean hitlEnabled,
|
||||||
|
Integer sortNo, String mcpToolManifestHash) {
|
||||||
|
this.toolType = toolType;
|
||||||
|
this.targetId = targetId;
|
||||||
|
this.hitlEnabled = hitlEnabled;
|
||||||
|
this.sortNo = sortNo;
|
||||||
|
this.mcpToolManifestHash = mcpToolManifestHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Tool 类型 */
|
||||||
|
public String getToolType() { return toolType; }
|
||||||
|
/** @param toolType Tool 类型 */
|
||||||
|
public void setToolType(String toolType) { this.toolType = toolType; }
|
||||||
|
/** @return 目标资源 ID */
|
||||||
|
public BigInteger getTargetId() { return targetId; }
|
||||||
|
/** @param targetId 目标资源 ID */
|
||||||
|
public void setTargetId(BigInteger targetId) { this.targetId = targetId; }
|
||||||
|
/** @return 是否调用前确认 */
|
||||||
|
public Boolean getHitlEnabled() { return hitlEnabled; }
|
||||||
|
/** @param hitlEnabled 是否调用前确认 */
|
||||||
|
public void setHitlEnabled(Boolean hitlEnabled) { this.hitlEnabled = hitlEnabled; }
|
||||||
|
/** @return 排序号 */
|
||||||
|
public Integer getSortNo() { return sortNo; }
|
||||||
|
/** @param sortNo 排序号 */
|
||||||
|
public void setSortNo(Integer sortNo) { this.sortNo = sortNo; }
|
||||||
|
/** @return MCP Tool 清单 hash */
|
||||||
|
public String getMcpToolManifestHash() { return mcpToolManifestHash; }
|
||||||
|
/** @param mcpToolManifestHash MCP Tool 清单 hash */
|
||||||
|
public void setMcpToolManifestHash(String mcpToolManifestHash) {
|
||||||
|
this.mcpToolManifestHash = mcpToolManifestHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换为领域绑定引用。
|
||||||
|
*
|
||||||
|
* @return 最小 Tool 绑定
|
||||||
|
*/
|
||||||
|
public SkillToolBinding toEntity() {
|
||||||
|
SkillToolBinding value = new SkillToolBinding();
|
||||||
|
value.setToolType(toolType);
|
||||||
|
value.setTargetId(targetId);
|
||||||
|
value.setHitlEnabled(hitlEnabled);
|
||||||
|
value.setSortNo(sortNo);
|
||||||
|
value.setMcpToolManifestHash(mcpToolManifestHash);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package tech.easyflow.admin.controller.skill.vo;
|
|||||||
import com.easyagents.skill.util.SkillResources;
|
import com.easyagents.skill.util.SkillResources;
|
||||||
import tech.easyflow.skill.entity.Skill;
|
import tech.easyflow.skill.entity.Skill;
|
||||||
import tech.easyflow.skill.entity.SkillResource;
|
import tech.easyflow.skill.entity.SkillResource;
|
||||||
|
import tech.easyflow.skill.entity.SkillToolBinding;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
@@ -16,7 +17,6 @@ import java.util.List;
|
|||||||
* @param name 标准名称
|
* @param name 标准名称
|
||||||
* @param displayName 展示名称
|
* @param displayName 展示名称
|
||||||
* @param description 用途描述
|
* @param description 用途描述
|
||||||
* @param skillContent SKILL.md 内容
|
|
||||||
* @param visibilityScope 使用范围
|
* @param visibilityScope 使用范围
|
||||||
* @param packageHash 标准包哈希
|
* @param packageHash 标准包哈希
|
||||||
* @param snapshotHash 发布快照哈希
|
* @param snapshotHash 发布快照哈希
|
||||||
@@ -31,13 +31,15 @@ import java.util.List;
|
|||||||
* @param readable 是否可读
|
* @param readable 是否可读
|
||||||
* @param manageable 是否可管理
|
* @param manageable 是否可管理
|
||||||
* @param resources 资源摘要
|
* @param resources 资源摘要
|
||||||
|
* @param toolBindings 平台 Tool 草稿绑定摘要
|
||||||
|
* @param toolCount 实际 Tool 数
|
||||||
|
* @param hasToolUpdate Tool 草稿是否与线上快照不同
|
||||||
*/
|
*/
|
||||||
public record SkillView(BigInteger id,
|
public record SkillView(BigInteger id,
|
||||||
BigInteger categoryId,
|
BigInteger categoryId,
|
||||||
String name,
|
String name,
|
||||||
String displayName,
|
String displayName,
|
||||||
String description,
|
String description,
|
||||||
String skillContent,
|
|
||||||
String visibilityScope,
|
String visibilityScope,
|
||||||
String packageHash,
|
String packageHash,
|
||||||
String snapshotHash,
|
String snapshotHash,
|
||||||
@@ -51,7 +53,10 @@ public record SkillView(BigInteger id,
|
|||||||
String createdByName,
|
String createdByName,
|
||||||
boolean readable,
|
boolean readable,
|
||||||
boolean manageable,
|
boolean manageable,
|
||||||
List<ResourceView> resources) {
|
List<ResourceView> resources,
|
||||||
|
List<ToolBindingView> toolBindings,
|
||||||
|
int toolCount,
|
||||||
|
boolean hasToolUpdate) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从领域实体构造管理端视图。
|
* 从领域实体构造管理端视图。
|
||||||
@@ -64,11 +69,18 @@ public record SkillView(BigInteger id,
|
|||||||
public static SkillView from(Skill skill, boolean readable, boolean manageable) {
|
public static SkillView from(Skill skill, boolean readable, boolean manageable) {
|
||||||
List<ResourceView> resources = skill.getResources() == null ? null
|
List<ResourceView> resources = skill.getResources() == null ? null
|
||||||
: skill.getResources().stream().map(ResourceView::from).toList();
|
: skill.getResources().stream().map(ResourceView::from).toList();
|
||||||
|
List<ToolBindingView> toolBindings = skill.getToolBindings() == null ? null
|
||||||
|
: skill.getToolBindings().stream().map(ToolBindingView::from).toList();
|
||||||
|
int toolCount = skill.getToolBindings() == null ? 0 : skill.getToolBindings().stream()
|
||||||
|
.mapToInt(binding -> "MCP".equalsIgnoreCase(binding.getToolType())
|
||||||
|
? Math.max(0, binding.getMcpToolCount() == null ? 0 : binding.getMcpToolCount()) : 1)
|
||||||
|
.sum();
|
||||||
return new SkillView(skill.getId(), skill.getCategoryId(), skill.getName(), skill.getDisplayName(),
|
return new SkillView(skill.getId(), skill.getCategoryId(), skill.getName(), skill.getDisplayName(),
|
||||||
skill.getDescription(), skill.getSkillContent(), skill.getVisibilityScope(), skill.getPackageHash(),
|
skill.getDescription(), skill.getVisibilityScope(), skill.getPackageHash(),
|
||||||
skill.getSnapshotHash(), skill.getPublishStatus(), skill.getCurrentApprovalInstanceId(),
|
skill.getSnapshotHash(), skill.getPublishStatus(), skill.getCurrentApprovalInstanceId(),
|
||||||
skill.getApprovalPending(), skill.getCurrentApprovalActionType(), skill.getDisplayPublishStatus(),
|
skill.getApprovalPending(), skill.getCurrentApprovalActionType(), skill.getDisplayPublishStatus(),
|
||||||
skill.getCreated(), skill.getModified(), skill.getCreatedByName(), readable, manageable, resources);
|
skill.getCreated(), skill.getModified(), skill.getCreatedByName(), readable, manageable, resources,
|
||||||
|
toolBindings, toolCount, hasToolUpdate(skill));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,4 +109,44 @@ public record SkillView(BigInteger id,
|
|||||||
resource.getMediaType(), resource.getIsText(), resource.getContentHash(), resource.getSize());
|
resource.getMediaType(), resource.getIsText(), resource.getContentHash(), resource.getSize());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean hasToolUpdate(Skill skill) {
|
||||||
|
if (skill.getToolBindings() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Object published = skill.getPublishedToolBindingsJson() == null
|
||||||
|
? null : skill.getPublishedToolBindingsJson().get("bindings");
|
||||||
|
List<String> currentKeys = skill.getToolBindings().stream().map(SkillView::bindingKey).toList();
|
||||||
|
if (!(published instanceof List<?> list)) {
|
||||||
|
return !currentKeys.isEmpty();
|
||||||
|
}
|
||||||
|
List<String> publishedKeys = list.stream().map(item -> {
|
||||||
|
if (!(item instanceof java.util.Map<?, ?> map)) {
|
||||||
|
return "INVALID";
|
||||||
|
}
|
||||||
|
return String.valueOf(map.get("toolType")) + ":" + map.get("targetId") + ":"
|
||||||
|
+ Boolean.TRUE.equals(map.get("hitlEnabled")) + ":" + map.get("mcpToolManifestHash");
|
||||||
|
}).toList();
|
||||||
|
return !currentKeys.equals(publishedKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String bindingKey(SkillToolBinding binding) {
|
||||||
|
return binding.getToolType() + ":" + binding.getTargetId() + ":"
|
||||||
|
+ Boolean.TRUE.equals(binding.getHitlEnabled()) + ":" + binding.getMcpToolManifestHash();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill 平台 Tool 草稿绑定安全摘要。
|
||||||
|
*/
|
||||||
|
public record ToolBindingView(BigInteger id, String toolType, BigInteger targetId,
|
||||||
|
Boolean hitlEnabled, Integer mcpToolCount,
|
||||||
|
String mcpToolManifestHash, Integer sortNo,
|
||||||
|
java.util.Map<String, Object> resourceSummary) {
|
||||||
|
/** @param binding 绑定实体 @return 安全摘要 */
|
||||||
|
public static ToolBindingView from(SkillToolBinding binding) {
|
||||||
|
return new ToolBindingView(binding.getId(), binding.getToolType(), binding.getTargetId(),
|
||||||
|
binding.getHitlEnabled(), binding.getMcpToolCount(), binding.getMcpToolManifestHash(),
|
||||||
|
binding.getSortNo(), binding.getResourceSummary());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,6 +266,9 @@ public class AgentSessionService {
|
|||||||
if (!Objects.equals(summary.getUserId(), account.getId())) {
|
if (!Objects.equals(summary.getUserId(), account.getId())) {
|
||||||
throw new BusinessException("无权访问该 Agent 会话");
|
throw new BusinessException("无权访问该 Agent 会话");
|
||||||
}
|
}
|
||||||
|
if (!Objects.equals(summary.getTenantId(), account.getTenantId())) {
|
||||||
|
throw new BusinessException("无权访问该 Agent 会话");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<BigInteger, AgentAvailability> resolveAgentAvailability(List<ChatSessionSummary> sessions) {
|
private Map<BigInteger, AgentAvailability> resolveAgentAvailability(List<ChatSessionSummary> sessions) {
|
||||||
|
|||||||
@@ -166,8 +166,14 @@ public class ChatWorkspaceService {
|
|||||||
roundIds.add(record.getRoundId());
|
roundIds.add(record.getRoundId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
List<ChatMessageRecord> allVariants = new ArrayList<>();
|
||||||
for (BigInteger roundId : roundIds) {
|
for (BigInteger roundId : roundIds) {
|
||||||
variantsByRound.put(roundId.toString(), chatRoundOperateService.listVariants(sessionId, roundId));
|
List<ChatMessageRecord> variants = chatRoundOperateService.listVariantsUnprojected(sessionId, roundId);
|
||||||
|
variantsByRound.put(roundId.toString(), variants);
|
||||||
|
allVariants.addAll(variants);
|
||||||
|
}
|
||||||
|
if (!allVariants.isEmpty()) {
|
||||||
|
chatRoundOperateService.projectVariants(sessionId, allVariants);
|
||||||
}
|
}
|
||||||
ChatWorkspaceConversationView view = new ChatWorkspaceConversationView();
|
ChatWorkspaceConversationView view = new ChatWorkspaceConversationView();
|
||||||
view.setRecords(records);
|
view.setRecords(records);
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package tech.easyflow.admin.controller.agent;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson.JSON;
|
||||||
|
import com.alibaba.fastjson.JSONObject;
|
||||||
|
import org.testng.Assert;
|
||||||
|
import org.testng.annotations.Test;
|
||||||
|
import tech.easyflow.common.domain.Result;
|
||||||
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
import tech.easyflow.common.web.jsonbody.JsonBodyParser;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.lang.reflect.Modifier;
|
||||||
|
import java.lang.reflect.ParameterizedType;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent Skill 绑定管理端 API 安全契约测试。
|
||||||
|
*/
|
||||||
|
public class AgentSkillBindingContractTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证请求 DTO 仅暴露 Agent ID、Skill ID 与排序号。
|
||||||
|
*
|
||||||
|
* @throws Exception 反序列化失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void requestUsesWhitelistFieldsAndDropsServerSnapshot() throws Exception {
|
||||||
|
JSONObject json = JSON.parseObject("""
|
||||||
|
{
|
||||||
|
"agentId": 10,
|
||||||
|
"bindings": [{
|
||||||
|
"skillId": 101,
|
||||||
|
"sortNo": 2,
|
||||||
|
"resourceSnapshot": {"skillContent": "forged"},
|
||||||
|
"resourceSummary": {"displayName": "forged"}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
AgentSkillBindingUpdateRequest request = (AgentSkillBindingUpdateRequest) JsonBodyParser.parseJsonBody(
|
||||||
|
json, AgentSkillBindingUpdateRequest.class, AgentSkillBindingUpdateRequest.class, "");
|
||||||
|
|
||||||
|
Assert.assertEquals(request.getAgentId(), BigInteger.TEN);
|
||||||
|
Assert.assertEquals(request.getBindings().get(0).getSkillId(), BigInteger.valueOf(101));
|
||||||
|
Assert.assertEquals(request.getBindings().get(0).getSortNo(), Integer.valueOf(2));
|
||||||
|
Assert.assertTrue(request.getBindings().get(0).toEntity().getResourceSnapshot().isEmpty());
|
||||||
|
Assert.assertTrue(request.getBindings().get(0).toEntity().getResourceSummary().isEmpty());
|
||||||
|
Assert.assertEquals(
|
||||||
|
Arrays.stream(AgentSkillBindingUpdateRequest.Binding.class.getDeclaredFields())
|
||||||
|
.filter(field -> !Modifier.isStatic(field.getModifiers()))
|
||||||
|
.map(field -> field.getName()).toList(),
|
||||||
|
List.of("skillId", "sortNo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证更新入口使用白名单 DTO 并返回脱敏视图。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void updateEndpointReturnsSafeSkillBindingViews() throws Exception {
|
||||||
|
Method method = AgentController.class.getMethod(
|
||||||
|
"updateSkillBinding", AgentSkillBindingUpdateRequest.class);
|
||||||
|
JsonBody jsonBody = method.getParameters()[0].getAnnotation(JsonBody.class);
|
||||||
|
ParameterizedType resultType = (ParameterizedType) method.getGenericReturnType();
|
||||||
|
ParameterizedType listType = (ParameterizedType) resultType.getActualTypeArguments()[0];
|
||||||
|
|
||||||
|
Assert.assertNotNull(jsonBody);
|
||||||
|
Assert.assertEquals(resultType.getRawType(), Result.class);
|
||||||
|
Assert.assertEquals(listType.getRawType(), List.class);
|
||||||
|
Assert.assertEquals(listType.getActualTypeArguments()[0], AgentDetailView.SkillBindingView.class);
|
||||||
|
Assert.assertFalse(Arrays.stream(AgentDetailView.SkillBindingView.class.getRecordComponents())
|
||||||
|
.anyMatch(component -> "resourceSnapshot".equals(component.getName())));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
|
|||||||
import tech.easyflow.admin.controller.skill.vo.SkillCopyRequest;
|
import tech.easyflow.admin.controller.skill.vo.SkillCopyRequest;
|
||||||
import tech.easyflow.admin.controller.skill.vo.SkillDraftRequest;
|
import tech.easyflow.admin.controller.skill.vo.SkillDraftRequest;
|
||||||
import tech.easyflow.admin.controller.skill.vo.SkillImportBatchResultView;
|
import tech.easyflow.admin.controller.skill.vo.SkillImportBatchResultView;
|
||||||
|
import tech.easyflow.admin.controller.skill.vo.SkillToolBindingUpdateRequest;
|
||||||
import tech.easyflow.admin.controller.skill.vo.SkillView;
|
import tech.easyflow.admin.controller.skill.vo.SkillView;
|
||||||
import tech.easyflow.ai.enums.PublishStatus;
|
import tech.easyflow.ai.enums.PublishStatus;
|
||||||
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
|
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
|
||||||
@@ -25,11 +26,14 @@ import tech.easyflow.skill.publish.SkillPublishAppService;
|
|||||||
import tech.easyflow.skill.security.SkillVisibilityQueryHelper;
|
import tech.easyflow.skill.security.SkillVisibilityQueryHelper;
|
||||||
import tech.easyflow.skill.service.SkillApprovalStateService;
|
import tech.easyflow.skill.service.SkillApprovalStateService;
|
||||||
import tech.easyflow.skill.service.SkillService;
|
import tech.easyflow.skill.service.SkillService;
|
||||||
|
import tech.easyflow.skill.service.SkillToolBindingService;
|
||||||
|
import tech.easyflow.skill.service.SkillToolOptionQueryService;
|
||||||
import tech.easyflow.system.service.CategoryPermissionService;
|
import tech.easyflow.system.service.CategoryPermissionService;
|
||||||
import tech.easyflow.system.service.ResourceAccessService;
|
import tech.easyflow.system.service.ResourceAccessService;
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
import java.lang.reflect.ParameterizedType;
|
import java.lang.reflect.ParameterizedType;
|
||||||
|
import java.lang.reflect.Modifier;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -44,6 +48,40 @@ import static org.mockito.Mockito.when;
|
|||||||
*/
|
*/
|
||||||
public class SkillControllerContractTest {
|
public class SkillControllerContractTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool 绑定请求能够把嵌套列表转换为白名单 JavaBean。
|
||||||
|
*
|
||||||
|
* @throws Exception 反序列化失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void jsonBodyParserDeserializesNestedToolBindings() throws Exception {
|
||||||
|
JSONObject json = JSON.parseObject("""
|
||||||
|
{
|
||||||
|
"skillId": 101,
|
||||||
|
"bindings": [{
|
||||||
|
"toolType": "WORKFLOW",
|
||||||
|
"targetId": 202,
|
||||||
|
"hitlEnabled": true,
|
||||||
|
"sortNo": 0,
|
||||||
|
"resourceSnapshot": {"forged": true}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
SkillToolBindingUpdateRequest request = (SkillToolBindingUpdateRequest) JsonBodyParser.parseJsonBody(
|
||||||
|
json, SkillToolBindingUpdateRequest.class, SkillToolBindingUpdateRequest.class, "");
|
||||||
|
|
||||||
|
Assert.assertEquals(request.getSkillId(), BigInteger.valueOf(101));
|
||||||
|
Assert.assertEquals(request.getBindings().get(0).getTargetId(), BigInteger.valueOf(202));
|
||||||
|
Assert.assertEquals(request.getBindings().get(0).getToolType(), "WORKFLOW");
|
||||||
|
Assert.assertTrue(request.getBindings().get(0).getHitlEnabled());
|
||||||
|
Assert.assertEquals(
|
||||||
|
Arrays.stream(SkillToolBindingUpdateRequest.Binding.class.getDeclaredFields())
|
||||||
|
.filter(field -> !Modifier.isStatic(field.getModifiers()))
|
||||||
|
.map(field -> field.getName()).toList(),
|
||||||
|
List.of("toolType", "targetId", "hitlEnabled", "sortNo", "mcpToolManifestHash"));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 草稿白名单 DTO 只接受标准包治理字段。
|
* 草稿白名单 DTO 只接受标准包治理字段。
|
||||||
*
|
*
|
||||||
@@ -158,6 +196,7 @@ public class SkillControllerContractTest {
|
|||||||
when(accessService.canAccess(any(), any(), any())).thenReturn(true);
|
when(accessService.canAccess(any(), any(), any())).thenReturn(true);
|
||||||
return new SkillController(mock(SkillService.class), mock(SkillApprovalStateService.class),
|
return new SkillController(mock(SkillService.class), mock(SkillApprovalStateService.class),
|
||||||
publishService, importService, mock(SkillExportService.class), mock(SkillFileService.class),
|
publishService, importService, mock(SkillExportService.class), mock(SkillFileService.class),
|
||||||
|
mock(SkillToolBindingService.class), mock(SkillToolOptionQueryService.class),
|
||||||
accessService, mock(CategoryPermissionService.class), mock(SkillVisibilityQueryHelper.class),
|
accessService, mock(CategoryPermissionService.class), mock(SkillVisibilityQueryHelper.class),
|
||||||
mock(AiResourceCreatorNameSupport.class));
|
mock(AiResourceCreatorNameSupport.class));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import tech.easyflow.agent.runtime.composer.AgentComposerDraftService;
|
|||||||
import tech.easyflow.agent.runtime.media.AgentMediaService;
|
import tech.easyflow.agent.runtime.media.AgentMediaService;
|
||||||
import tech.easyflow.agent.service.AgentService;
|
import tech.easyflow.agent.service.AgentService;
|
||||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||||
|
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
||||||
import tech.easyflow.chatlog.service.ChatSessionCommandService;
|
import tech.easyflow.chatlog.service.ChatSessionCommandService;
|
||||||
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
||||||
import tech.easyflow.chatlog.support.ChatJsonSupport;
|
import tech.easyflow.chatlog.support.ChatJsonSupport;
|
||||||
@@ -18,6 +21,7 @@ import tech.easyflow.common.web.exceptions.BusinessException;
|
|||||||
import tech.easyflow.system.service.ResourceAccessService;
|
import tech.easyflow.system.service.ResourceAccessService;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
@@ -30,6 +34,7 @@ import static org.mockito.Mockito.when;
|
|||||||
public class AgentSessionServiceTest {
|
public class AgentSessionServiceTest {
|
||||||
|
|
||||||
private static final BigInteger ACCOUNT_ID = BigInteger.valueOf(7);
|
private static final BigInteger ACCOUNT_ID = BigInteger.valueOf(7);
|
||||||
|
private static final BigInteger TENANT_ID = BigInteger.valueOf(3);
|
||||||
private static final BigInteger SESSION_ID = BigInteger.valueOf(101);
|
private static final BigInteger SESSION_ID = BigInteger.valueOf(101);
|
||||||
|
|
||||||
private ChatSessionQueryService chatSessionQueryService;
|
private ChatSessionQueryService chatSessionQueryService;
|
||||||
@@ -63,6 +68,7 @@ public class AgentSessionServiceTest {
|
|||||||
);
|
);
|
||||||
account = new LoginAccount();
|
account = new LoginAccount();
|
||||||
account.setId(ACCOUNT_ID);
|
account.setId(ACCOUNT_ID);
|
||||||
|
account.setTenantId(TENANT_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -143,6 +149,7 @@ public class AgentSessionServiceTest {
|
|||||||
private ChatSessionSummary buildSession(BigInteger userId, Integer isDeleted, String assistantCode) {
|
private ChatSessionSummary buildSession(BigInteger userId, Integer isDeleted, String assistantCode) {
|
||||||
ChatSessionSummary summary = new ChatSessionSummary();
|
ChatSessionSummary summary = new ChatSessionSummary();
|
||||||
summary.setId(SESSION_ID);
|
summary.setId(SESSION_ID);
|
||||||
|
summary.setTenantId(TENANT_ID);
|
||||||
summary.setUserId(userId);
|
summary.setUserId(userId);
|
||||||
summary.setIsDeleted(isDeleted);
|
summary.setIsDeleted(isDeleted);
|
||||||
summary.setAssistantCode(assistantCode);
|
summary.setAssistantCode(assistantCode);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import tech.easyflow.common.constant.Constants;
|
|||||||
import tech.easyflow.common.domain.Result;
|
import tech.easyflow.common.domain.Result;
|
||||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
import tech.easyflow.system.enums.CategoryResourceType;
|
import tech.easyflow.system.enums.CategoryResourceType;
|
||||||
import tech.easyflow.system.enums.ResourceAction;
|
import tech.easyflow.system.enums.ResourceAction;
|
||||||
@@ -162,7 +163,12 @@ public class UcWorkflowController extends BaseCurdController<WorkflowService, Wo
|
|||||||
)
|
)
|
||||||
public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId,
|
public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId,
|
||||||
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
|
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
|
||||||
chainExecutor.resumeAsync(executeId, confirmParams);
|
if (!chainExecutor.resumeAsyncIfSuspended(executeId, confirmParams)) {
|
||||||
|
throw new BusinessException(
|
||||||
|
409,
|
||||||
|
40901,
|
||||||
|
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
|
||||||
|
}
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,39 @@ public class ChatSseEmitter {
|
|||||||
return send("needSaveMessage", envelope);
|
return send("needSaveMessage", envelope);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送不带私有事件包装的 SSE data 数据。
|
||||||
|
*
|
||||||
|
* @param data 已完成协议序列化的数据
|
||||||
|
* @return 发送成功时为 true
|
||||||
|
*/
|
||||||
|
public boolean sendData(String data) {
|
||||||
|
if (closed.get()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().data(data));
|
||||||
|
return true;
|
||||||
|
} catch (IOException exception) {
|
||||||
|
markDisconnected("data", exception);
|
||||||
|
return false;
|
||||||
|
} catch (IllegalStateException exception) {
|
||||||
|
closed.compareAndSet(false, true);
|
||||||
|
LOG.warn("ChatSseEmitter data send failed, message={}, exception={}",
|
||||||
|
exception.getMessage(), exception.toString());
|
||||||
|
return false;
|
||||||
|
} catch (Exception exception) {
|
||||||
|
if (isClientDisconnected(exception)) {
|
||||||
|
markDisconnected("data", exception);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
LOG.error("ChatSseEmitter data send unexpected failed, message={}, exception={}",
|
||||||
|
exception.getMessage(), exception.toString(), exception);
|
||||||
|
safeCompleteWithError(exception);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** SSE 底层发送 */
|
/** SSE 底层发送 */
|
||||||
private boolean send(String event, ChatEnvelope<?> envelope) {
|
private boolean send(String event, ChatEnvelope<?> envelope) {
|
||||||
if (closed.get()) {
|
if (closed.get()) {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ public class ChatAssistantAccumulator {
|
|||||||
private final List<Map<String, Object>> chains = new ArrayList<>();
|
private final List<Map<String, Object>> chains = new ArrayList<>();
|
||||||
private final List<Map<String, Object>> messageChain = new ArrayList<>();
|
private final List<Map<String, Object>> messageChain = new ArrayList<>();
|
||||||
private final List<Map<String, Object>> toolMessages = new ArrayList<>();
|
private final List<Map<String, Object>> toolMessages = new ArrayList<>();
|
||||||
|
private final Map<String, Map<String, Object>> skillInvocationStatuses = new LinkedHashMap<>();
|
||||||
|
private final Map<String, Map<String, Object>> artifacts = new LinkedHashMap<>();
|
||||||
private Map<String, Object> latestToolCallAssistant;
|
private Map<String, Object> latestToolCallAssistant;
|
||||||
private boolean toolCallBatchOpen;
|
private boolean toolCallBatchOpen;
|
||||||
|
|
||||||
@@ -113,6 +115,63 @@ public class ChatAssistantAccumulator {
|
|||||||
toolCallBatchOpen = false;
|
toolCallBatchOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录可安全回放的 Skill 调用状态,并按稳定状态键原位覆盖。
|
||||||
|
*
|
||||||
|
* @param status 仅含展示白名单字段的状态
|
||||||
|
*/
|
||||||
|
public void appendSkillInvocationStatus(Map<String, Object> status) {
|
||||||
|
if (status == null || status.get("statusKey") == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String key = String.valueOf(status.get("statusKey"));
|
||||||
|
Map<String, Object> safe = new LinkedHashMap<>();
|
||||||
|
for (String field : List.of("statusKey", "status", "skillId", "skillName",
|
||||||
|
"skillDisplayName", "toolCallId", "message")) {
|
||||||
|
if (status.get(field) != null) {
|
||||||
|
safe.put(field, status.get(field));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
skillInvocationStatuses.put(key, safe);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录可安全持久化并回放的 Agent 产物投影。
|
||||||
|
*
|
||||||
|
* @param artifact 产物安全字段
|
||||||
|
*/
|
||||||
|
public void appendArtifact(Map<String, Object> artifact) {
|
||||||
|
if (artifact == null || artifact.get("artifactId") == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, Object> safe = new LinkedHashMap<>();
|
||||||
|
for (String field : List.of("schemaVersion", "artifactId", "fileName", "mimeType",
|
||||||
|
"size", "sha256", "downloadUrl", "status")) {
|
||||||
|
if (artifact.get(field) != null) {
|
||||||
|
safe.put(field, artifact.get(field));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artifacts.put(String.valueOf(artifact.get("artifactId")), safe);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将仍在运行的 Skill 状态收口为指定终态。
|
||||||
|
*
|
||||||
|
* @param terminalStatus FAILED 或 CANCELLED
|
||||||
|
* @param message 可恢复提示
|
||||||
|
*/
|
||||||
|
public void finalizePendingSkillInvocations(String terminalStatus, String message) {
|
||||||
|
for (Map<String, Object> status : skillInvocationStatuses.values()) {
|
||||||
|
if (!"RUNNING".equals(String.valueOf(status.get("status")))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
status.put("status", terminalStatus);
|
||||||
|
if (message != null && !message.isBlank()) {
|
||||||
|
status.put("message", message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前 assistant 片段的文本内容。
|
* 获取当前 assistant 片段的文本内容。
|
||||||
*
|
*
|
||||||
@@ -161,7 +220,24 @@ public class ChatAssistantAccumulator {
|
|||||||
if (!finalAssistantMessage.isEmpty()) {
|
if (!finalAssistantMessage.isEmpty()) {
|
||||||
payloadMessageChain.add(finalAssistantMessage);
|
payloadMessageChain.add(finalAssistantMessage);
|
||||||
}
|
}
|
||||||
return ChatRuntimeHistoryPayloadHelper.buildPayload(payloadMessageChain, toolMessages, payloadChains);
|
Map<String, Object> payload = ChatRuntimeHistoryPayloadHelper.buildPayload(
|
||||||
|
payloadMessageChain, toolMessages, payloadChains);
|
||||||
|
if (!skillInvocationStatuses.isEmpty()) {
|
||||||
|
List<Map<String, Object>> statuses = new ArrayList<>();
|
||||||
|
for (Map<String, Object> current : skillInvocationStatuses.values()) {
|
||||||
|
Map<String, Object> copy = new LinkedHashMap<>(current);
|
||||||
|
if ("RUNNING".equals(String.valueOf(copy.get("status")))) {
|
||||||
|
copy.put("status", "INCOMPLETE");
|
||||||
|
copy.putIfAbsent("message", "技能调用未完成");
|
||||||
|
}
|
||||||
|
statuses.add(copy);
|
||||||
|
}
|
||||||
|
payload.put("skillInvocationStatuses", statuses);
|
||||||
|
}
|
||||||
|
if (!artifacts.isEmpty()) {
|
||||||
|
payload.put("artifacts", new ArrayList<>(artifacts.values()));
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> findToolChain(String id, String name) {
|
private Map<String, Object> findToolChain(String id, String name) {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package tech.easyflow.core.runtime;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assistant 历史 payload 的 Artifact 安全投影测试。
|
||||||
|
*/
|
||||||
|
public class ChatAssistantAccumulatorArtifactTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证只持久化安全字段,并按 artifactId 原位去重。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void buildPayloadShouldPersistOnlySafeArtifactFields() {
|
||||||
|
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
|
||||||
|
accumulator.appendArtifact(Map.of(
|
||||||
|
"schemaVersion", 1,
|
||||||
|
"artifactId", "a1",
|
||||||
|
"fileName", "report.csv",
|
||||||
|
"mimeType", "text/csv",
|
||||||
|
"size", 12L,
|
||||||
|
"sha256", "abc",
|
||||||
|
"downloadUrl", "/api/v1/agent/artifacts/a1/content",
|
||||||
|
"status", "AVAILABLE",
|
||||||
|
"objectKey", "private/object/key"));
|
||||||
|
|
||||||
|
Map<String, Object> payload = accumulator.buildPayload("done");
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> artifacts = (List<Map<String, Object>>) payload.get("artifacts");
|
||||||
|
|
||||||
|
Assert.assertEquals(1, artifacts.size());
|
||||||
|
Assert.assertEquals("a1", artifacts.get(0).get("artifactId"));
|
||||||
|
Assert.assertFalse(artifacts.get(0).containsKey("objectKey"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,34 @@ public class XFIleStorageServiceImpl implements FileStorageService {
|
|||||||
return fileInfo.getUrl();
|
return fileInfo.getUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用指定前置目录上传后端本地文件。
|
||||||
|
*
|
||||||
|
* @param file 后端本地文件
|
||||||
|
* @param prePath 前置目录
|
||||||
|
* @return 文件 URL
|
||||||
|
* @throws IllegalArgumentException 文件不存在或不是普通文件时抛出
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String save(File file, String prePath) {
|
||||||
|
if (file == null || !file.isFile()) {
|
||||||
|
throw new IllegalArgumentException("待上传的本地文件不存在");
|
||||||
|
}
|
||||||
|
String uploadPath = PathGeneratorUtil.generateUserPath("");
|
||||||
|
if (StringUtils.hasText(prePath)) {
|
||||||
|
String normalized = prePath.replaceAll("^/+", "").replaceAll("/+$", "");
|
||||||
|
uploadPath = "/" + normalized + uploadPath;
|
||||||
|
}
|
||||||
|
FileInfo fileInfo = fileStorageService.of(file)
|
||||||
|
.setPath(uploadPath)
|
||||||
|
.setSaveFilename(file.getName())
|
||||||
|
.upload();
|
||||||
|
if (fileInfo == null || !StringUtils.hasText(fileInfo.getUrl())) {
|
||||||
|
throw new RuntimeException("文件上传失败");
|
||||||
|
}
|
||||||
|
return fileInfo.getUrl();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 幂等删除指定文件;物理文件已不存在时同步清理残留记录。
|
* 幂等删除指定文件;物理文件已不存在时同步清理残留记录。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -35,6 +35,26 @@ import static org.junit.Assert.assertTrue;
|
|||||||
*/
|
*/
|
||||||
public class XFIleStorageServiceImplTest {
|
public class XFIleStorageServiceImplTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证后端生成的本地文件可以通过统一 x-file-storage 路由上传。
|
||||||
|
*
|
||||||
|
* @throws Exception 创建临时文件或注入测试替身失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void localFileSaveUsesRequestedPathAndFilename() throws Exception {
|
||||||
|
RecoverablePlatform platform = new RecoverablePlatform("minio-main", "attachment", "https://files/");
|
||||||
|
RecoverableStorageService delegate = new RecoverableStorageService(platform);
|
||||||
|
XFIleStorageServiceImpl service = createService(delegate);
|
||||||
|
File file = Files.createTempFile("generated-skill-", ".zip").toFile();
|
||||||
|
|
||||||
|
String url = service.save(file, "skill-imports/tenant-1");
|
||||||
|
|
||||||
|
assertEquals(file.getName(), delegate.uploadFilename);
|
||||||
|
assertTrue(delegate.uploadPath.startsWith("/skill-imports/tenant-1/"));
|
||||||
|
assertEquals("https://files/attachment" + delegate.uploadPath + file.getName(), url);
|
||||||
|
assertTrue(platform.exists);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证底层明确返回 false 时抛出带有效消息的异常。
|
* 验证底层明确返回 false 时抛出带有效消息的异常。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
<groupId>tech.easyflow</groupId>
|
<groupId>tech.easyflow</groupId>
|
||||||
<artifactId>easyflow-module-system</artifactId>
|
<artifactId>easyflow-module-system</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>tech.easyflow</groupId>
|
||||||
|
<artifactId>easyflow-module-skill</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>tech.easyflow</groupId>
|
<groupId>tech.easyflow</groupId>
|
||||||
<artifactId>easyflow-common-chat-protocol</artifactId>
|
<artifactId>easyflow-common-chat-protocol</artifactId>
|
||||||
@@ -65,6 +69,10 @@
|
|||||||
<groupId>com.easyagents</groupId>
|
<groupId>com.easyagents</groupId>
|
||||||
<artifactId>easy-agents-agent-runtime</artifactId>
|
<artifactId>easy-agents-agent-runtime</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.easyagents</groupId>
|
||||||
|
<artifactId>easy-agents-agui</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package tech.easyflow.agent.config;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 五类产品级内置工具的类型化配置。
|
||||||
|
*/
|
||||||
|
public final class AgentBuiltinToolsConfig {
|
||||||
|
|
||||||
|
/** 当前配置结构版本。 */
|
||||||
|
public static final int SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
|
private final ToolSwitch read;
|
||||||
|
private final ToolSwitch write;
|
||||||
|
private final ToolSwitch patch;
|
||||||
|
private final ToolSwitch shell;
|
||||||
|
private final ToolSwitch artifactPublish;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建内置工具配置。
|
||||||
|
*
|
||||||
|
* @param read 读取工具配置
|
||||||
|
* @param write 写入工具配置
|
||||||
|
* @param patch 补丁工具配置
|
||||||
|
* @param shell Shell 工具配置
|
||||||
|
* @param artifactPublish 产物发布工具配置
|
||||||
|
*/
|
||||||
|
public AgentBuiltinToolsConfig(ToolSwitch read,
|
||||||
|
ToolSwitch write,
|
||||||
|
ToolSwitch patch,
|
||||||
|
ToolSwitch shell,
|
||||||
|
ToolSwitch artifactPublish) {
|
||||||
|
this.read = read;
|
||||||
|
this.write = write;
|
||||||
|
this.patch = patch;
|
||||||
|
this.shell = shell;
|
||||||
|
this.artifactPublish = artifactPublish;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回新 Agent 的安全默认配置。
|
||||||
|
*
|
||||||
|
* @return 五项启用且仅 Shell 要求审批的配置
|
||||||
|
*/
|
||||||
|
public static AgentBuiltinToolsConfig newAgentDefaults() {
|
||||||
|
return new AgentBuiltinToolsConfig(
|
||||||
|
new ToolSwitch(true, false),
|
||||||
|
new ToolSwitch(true, false),
|
||||||
|
new ToolSwitch(true, false),
|
||||||
|
new ToolSwitch(true, true),
|
||||||
|
new ToolSwitch(true, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回旧发布快照的无扩权兼容配置。
|
||||||
|
*
|
||||||
|
* @return 五项全部禁用的配置
|
||||||
|
*/
|
||||||
|
public static AgentBuiltinToolsConfig allDisabled() {
|
||||||
|
ToolSwitch disabled = new ToolSwitch(false, false);
|
||||||
|
return new AgentBuiltinToolsConfig(disabled, disabled, disabled, disabled, disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 读取工具配置 */
|
||||||
|
public ToolSwitch read() { return read; }
|
||||||
|
/** @return 写入工具配置 */
|
||||||
|
public ToolSwitch write() { return write; }
|
||||||
|
/** @return 补丁工具配置 */
|
||||||
|
public ToolSwitch patch() { return patch; }
|
||||||
|
/** @return Shell 工具配置 */
|
||||||
|
public ToolSwitch shell() { return shell; }
|
||||||
|
/** @return 产物发布工具配置 */
|
||||||
|
public ToolSwitch artifactPublish() { return artifactPublish; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换为可写入 executionConfigJson 的稳定结构。
|
||||||
|
*
|
||||||
|
* @return 不含权限确认临时字段的安全 Map
|
||||||
|
*/
|
||||||
|
public Map<String, Object> toMap() {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("schemaVersion", SCHEMA_VERSION);
|
||||||
|
result.put("read", read.toMap());
|
||||||
|
result.put("write", write.toMap());
|
||||||
|
result.put("patch", patch.toMap());
|
||||||
|
result.put("shell", shell.toMap());
|
||||||
|
result.put("artifactPublish", artifactPublish.toMap());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个内置工具的启用与审批配置。
|
||||||
|
*
|
||||||
|
* @param enabled 是否启用
|
||||||
|
* @param approvalRequired 是否要求调用前审批
|
||||||
|
*/
|
||||||
|
public record ToolSwitch(boolean enabled, boolean approvalRequired) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换为持久化结构。
|
||||||
|
*
|
||||||
|
* @return 工具开关 Map
|
||||||
|
*/
|
||||||
|
public Map<String, Object> toMap() {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("enabled", enabled);
|
||||||
|
result.put("approvalRequired", approvalRequired);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package tech.easyflow.agent.config;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.system.service.CategoryPermissionService;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 内置工具配置的默认值、兼容和权限统一解析器。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AgentBuiltinToolsConfigResolver {
|
||||||
|
|
||||||
|
/** executionConfigJson 中的内置工具字段。 */
|
||||||
|
public static final String BUILTIN_TOOLS_KEY = "builtinTools";
|
||||||
|
/** 超级管理员关闭 Shell 审批时提交的一次性确认字段。 */
|
||||||
|
public static final String SHELL_RISK_CONFIRMATION_KEY = "shellApprovalRiskConfirmed";
|
||||||
|
|
||||||
|
private final CategoryPermissionService categoryPermissionService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建解析器。
|
||||||
|
*
|
||||||
|
* @param categoryPermissionService 平台超级管理员判定服务
|
||||||
|
*/
|
||||||
|
public AgentBuiltinToolsConfigResolver(CategoryPermissionService categoryPermissionService) {
|
||||||
|
this.categoryPermissionService = categoryPermissionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为草稿详情补齐展示默认值,但不直接写回数据库。
|
||||||
|
*
|
||||||
|
* @param source 原执行配置
|
||||||
|
* @return 带完整五项配置的副本
|
||||||
|
*/
|
||||||
|
public Map<String, Object> normalizeForDraftRead(Map<String, Object> source) {
|
||||||
|
return replaceBuiltinTools(source, parse(source, AgentBuiltinToolsConfig.newAgentDefaults()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化一次显式草稿保存,并校验关闭 Shell 审批的权限与风险确认。
|
||||||
|
*
|
||||||
|
* @param source 客户端提交的执行配置
|
||||||
|
* @param existingSource 更新前执行配置;新建时为 null
|
||||||
|
* @param account 当前账号
|
||||||
|
* @return 可持久化且已移除一次性确认字段的配置
|
||||||
|
*/
|
||||||
|
public Map<String, Object> normalizeForDraftSave(Map<String, Object> source,
|
||||||
|
Map<String, Object> existingSource,
|
||||||
|
LoginAccount account) {
|
||||||
|
AgentBuiltinToolsConfig incoming = parse(source, AgentBuiltinToolsConfig.newAgentDefaults());
|
||||||
|
AgentBuiltinToolsConfig existing = existingSource == null
|
||||||
|
? AgentBuiltinToolsConfig.newAgentDefaults()
|
||||||
|
: parse(existingSource, AgentBuiltinToolsConfig.newAgentDefaults());
|
||||||
|
boolean disablesShellApproval = disablesShellApproval(incoming, existing);
|
||||||
|
if (disablesShellApproval) {
|
||||||
|
if (!categoryPermissionService.isSuperAdmin(account)) {
|
||||||
|
throw new BusinessException(403, 403, "仅平台超级管理员可以关闭 Shell 调用前确认");
|
||||||
|
}
|
||||||
|
if (!riskConfirmed(source)) {
|
||||||
|
throw new BusinessException(400, 400, "关闭 Shell 调用前确认前必须完成高风险确认");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return replaceBuiltinTools(source, incoming);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断保存前后是否真实发生了 Shell 审批关闭变更。
|
||||||
|
*
|
||||||
|
* @param source 已规范化或待保存的执行配置
|
||||||
|
* @param existingSource 保存前执行配置;新建时为 null
|
||||||
|
* @return 从需审批或禁用状态切换到启用且免审批时为 true
|
||||||
|
*/
|
||||||
|
public boolean isShellApprovalDisableTransition(Map<String, Object> source,
|
||||||
|
Map<String, Object> existingSource) {
|
||||||
|
AgentBuiltinToolsConfig incoming = parse(source, AgentBuiltinToolsConfig.newAgentDefaults());
|
||||||
|
AgentBuiltinToolsConfig existing = existingSource == null
|
||||||
|
? AgentBuiltinToolsConfig.newAgentDefaults()
|
||||||
|
: parse(existingSource, AgentBuiltinToolsConfig.newAgentDefaults());
|
||||||
|
return disablesShellApproval(incoming, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析草稿运行配置;缺失时使用新 Agent 默认值。
|
||||||
|
*
|
||||||
|
* @param source 执行配置
|
||||||
|
* @return 类型化配置
|
||||||
|
*/
|
||||||
|
public AgentBuiltinToolsConfig resolveDraftRuntime(Map<String, Object> source) {
|
||||||
|
return parse(source, AgentBuiltinToolsConfig.newAgentDefaults());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析发布快照;旧快照缺失内置工具字段时全部禁用。
|
||||||
|
*
|
||||||
|
* @param source 发布快照中的执行配置
|
||||||
|
* @return 类型化配置
|
||||||
|
*/
|
||||||
|
public AgentBuiltinToolsConfig resolvePublishedRuntime(Map<String, Object> source) {
|
||||||
|
return parse(source, AgentBuiltinToolsConfig.allDisabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化发布运行配置;旧快照缺失字段时显式写入五项禁用结果。
|
||||||
|
*
|
||||||
|
* @param source 发布快照执行配置
|
||||||
|
* @return 无静默扩权的完整配置副本
|
||||||
|
*/
|
||||||
|
public Map<String, Object> normalizeForPublishedRuntime(Map<String, Object> source) {
|
||||||
|
return replaceBuiltinTools(source, resolvePublishedRuntime(source));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断执行配置是否显式包含内置工具结构。
|
||||||
|
*
|
||||||
|
* @param source 执行配置
|
||||||
|
* @return 包含时为 true
|
||||||
|
*/
|
||||||
|
public boolean hasBuiltinTools(Map<String, Object> source) {
|
||||||
|
return source != null && source.containsKey(BUILTIN_TOOLS_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> replaceBuiltinTools(Map<String, Object> source,
|
||||||
|
AgentBuiltinToolsConfig config) {
|
||||||
|
Map<String, Object> result = source == null ? new LinkedHashMap<>() : new LinkedHashMap<>(source);
|
||||||
|
result.put(BUILTIN_TOOLS_KEY, config.toMap());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentBuiltinToolsConfig parse(Map<String, Object> source, AgentBuiltinToolsConfig fallback) {
|
||||||
|
if (source == null || !source.containsKey(BUILTIN_TOOLS_KEY)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
Map<String, Object> raw = requireMap(source.get(BUILTIN_TOOLS_KEY), "builtinTools 必须为对象");
|
||||||
|
validateSchemaVersion(raw.get("schemaVersion"));
|
||||||
|
return new AgentBuiltinToolsConfig(
|
||||||
|
tool(raw, "read", fallback.read()),
|
||||||
|
tool(raw, "write", fallback.write()),
|
||||||
|
tool(raw, "patch", fallback.patch()),
|
||||||
|
tool(raw, "shell", fallback.shell()),
|
||||||
|
tool(raw, "artifactPublish", fallback.artifactPublish()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean disablesShellApproval(AgentBuiltinToolsConfig incoming,
|
||||||
|
AgentBuiltinToolsConfig existing) {
|
||||||
|
return incoming.shell().enabled()
|
||||||
|
&& !incoming.shell().approvalRequired()
|
||||||
|
&& (existing.shell().approvalRequired() || !existing.shell().enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateSchemaVersion(Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(value instanceof Number number)
|
||||||
|
|| number.doubleValue() != number.intValue()
|
||||||
|
|| number.intValue() != AgentBuiltinToolsConfig.SCHEMA_VERSION) {
|
||||||
|
throw new BusinessException("不支持的 Agent 内置工具配置版本");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentBuiltinToolsConfig.ToolSwitch tool(Map<String, Object> source,
|
||||||
|
String key,
|
||||||
|
AgentBuiltinToolsConfig.ToolSwitch fallback) {
|
||||||
|
if (!source.containsKey(key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
Map<String, Object> raw = requireMap(source.get(key), "Agent 内置工具项必须为对象: " + key);
|
||||||
|
return new AgentBuiltinToolsConfig.ToolSwitch(
|
||||||
|
booleanValue(raw, "enabled", fallback.enabled()),
|
||||||
|
booleanValue(raw, "approvalRequired", fallback.approvalRequired()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean riskConfirmed(Map<String, Object> source) {
|
||||||
|
Map<String, Object> raw = mapValue(source == null ? null : source.get(BUILTIN_TOOLS_KEY));
|
||||||
|
return raw != null && Boolean.TRUE.equals(raw.get(SHELL_RISK_CONFIRMATION_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean booleanValue(Map<String, Object> source, String key, boolean fallback) {
|
||||||
|
if (!source.containsKey(key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
Object value = source.get(key);
|
||||||
|
if (value instanceof Boolean bool) {
|
||||||
|
return bool;
|
||||||
|
}
|
||||||
|
throw new BusinessException("Agent 内置工具开关必须为布尔值");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> requireMap(Object value, String message) {
|
||||||
|
Map<String, Object> mapped = mapValue(value);
|
||||||
|
if (mapped == null) {
|
||||||
|
throw new BusinessException(message);
|
||||||
|
}
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> mapValue(Object value) {
|
||||||
|
if (!(value instanceof Map<?, ?> raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
raw.forEach((key, item) -> result.put(String.valueOf(key), item));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,9 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
|||||||
@EnableConfigurationProperties({
|
@EnableConfigurationProperties({
|
||||||
AgentRuntimeProperties.class,
|
AgentRuntimeProperties.class,
|
||||||
AgentMediaProperties.class,
|
AgentMediaProperties.class,
|
||||||
AgentDocumentProperties.class
|
AgentDocumentProperties.class,
|
||||||
|
AgentWorkspaceProperties.class,
|
||||||
|
AgentShellProperties.class
|
||||||
})
|
})
|
||||||
public class AgentModuleConfig {
|
public class AgentModuleConfig {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package tech.easyflow.agent.config;
|
||||||
|
|
||||||
|
import com.easyagents.agent.runtime.tool.operate.ControlledShellTool;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.InvalidPathException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在应用启动后报告受控 Shell 固定白名单命令的实际可用性。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AgentShellCommandAvailabilityReporter {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(AgentShellCommandAvailabilityReporter.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查当前进程 PATH,并报告已安装与缺失的白名单命令。
|
||||||
|
*
|
||||||
|
* @param event 应用就绪事件
|
||||||
|
*/
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void report(ApplicationReadyEvent event) {
|
||||||
|
List<String> available = new ArrayList<>();
|
||||||
|
List<String> missing = new ArrayList<>();
|
||||||
|
for (String command : ControlledShellTool.DEFAULT_ALLOWED_COMMANDS.stream().sorted().toList()) {
|
||||||
|
(isAvailable(command) ? available : missing).add(command);
|
||||||
|
}
|
||||||
|
LOG.info("Agent controlled Shell allowlist check completed, available={}", available);
|
||||||
|
if (!missing.isEmpty()) {
|
||||||
|
LOG.warn("Agent controlled Shell commands are unavailable in this runtime: {}", missing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断一个不含路径分隔符的固定命令是否存在于当前 PATH。
|
||||||
|
*
|
||||||
|
* @param command 固定白名单命令
|
||||||
|
* @return 存在可执行普通文件时为 true
|
||||||
|
*/
|
||||||
|
private boolean isAvailable(String command) {
|
||||||
|
String pathValue = System.getenv("PATH");
|
||||||
|
if (pathValue == null || pathValue.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (String directory : pathValue.split(java.io.File.pathSeparator)) {
|
||||||
|
if (directory == null || directory.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Path executable = Path.of(directory).resolve(command);
|
||||||
|
if (Files.isRegularFile(executable) && Files.isExecutable(executable)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (InvalidPathException ignored) {
|
||||||
|
// PATH 中的无效目录仅视为不可用,避免把宿主路径写入普通日志。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package tech.easyflow.agent.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.InitializingBean;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.util.unit.DataSize;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 受控 Shell 的统一平台限制。
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "easyflow.agent.shell")
|
||||||
|
public class AgentShellProperties implements InitializingBean {
|
||||||
|
|
||||||
|
private Duration defaultTimeout = Duration.ofSeconds(60);
|
||||||
|
private Duration maxTimeout = Duration.ofSeconds(300);
|
||||||
|
private int maxCommandLength = 4_096;
|
||||||
|
private DataSize maxOutputSize = DataSize.ofMegabytes(1);
|
||||||
|
private int maxConcurrentPerInstance = 2;
|
||||||
|
|
||||||
|
/** @return 默认超时 */
|
||||||
|
public Duration getDefaultTimeout() { return defaultTimeout; }
|
||||||
|
/** @param defaultTimeout 默认超时 */
|
||||||
|
public void setDefaultTimeout(Duration defaultTimeout) { this.defaultTimeout = defaultTimeout; }
|
||||||
|
/** @return 最大超时 */
|
||||||
|
public Duration getMaxTimeout() { return maxTimeout; }
|
||||||
|
/** @param maxTimeout 最大超时 */
|
||||||
|
public void setMaxTimeout(Duration maxTimeout) { this.maxTimeout = maxTimeout; }
|
||||||
|
/** @return 命令最大字符数 */
|
||||||
|
public int getMaxCommandLength() { return maxCommandLength; }
|
||||||
|
/** @param maxCommandLength 命令最大字符数 */
|
||||||
|
public void setMaxCommandLength(int maxCommandLength) { this.maxCommandLength = maxCommandLength; }
|
||||||
|
/** @return 输出最大字节数 */
|
||||||
|
public DataSize getMaxOutputSize() { return maxOutputSize; }
|
||||||
|
/** @param maxOutputSize 输出最大字节数 */
|
||||||
|
public void setMaxOutputSize(DataSize maxOutputSize) { this.maxOutputSize = maxOutputSize; }
|
||||||
|
/** @return 单实例最大并发数 */
|
||||||
|
public int getMaxConcurrentPerInstance() { return maxConcurrentPerInstance; }
|
||||||
|
/** @param maxConcurrentPerInstance 单实例最大并发数 */
|
||||||
|
public void setMaxConcurrentPerInstance(int maxConcurrentPerInstance) {
|
||||||
|
this.maxConcurrentPerInstance = maxConcurrentPerInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动期校验 Shell 限制。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void afterPropertiesSet() {
|
||||||
|
if (!positive(defaultTimeout) || !positive(maxTimeout)
|
||||||
|
|| maxTimeout.compareTo(defaultTimeout) < 0) {
|
||||||
|
throw new IllegalStateException("Shell 最大超时必须大于等于正值默认超时");
|
||||||
|
}
|
||||||
|
if (maxCommandLength <= 0 || maxOutputSize == null || maxOutputSize.toBytes() <= 0
|
||||||
|
|| maxConcurrentPerInstance <= 0) {
|
||||||
|
throw new IllegalStateException("Shell 命令长度、输出大小和并发数必须为正值");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean positive(Duration value) {
|
||||||
|
return value != null && !value.isZero() && !value.isNegative();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package tech.easyflow.agent.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.InitializingBean;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.util.unit.DataSize;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 单会话工作区的路径、配额与保留配置。
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "easyflow.agent.workspace")
|
||||||
|
public class AgentWorkspaceProperties implements InitializingBean {
|
||||||
|
|
||||||
|
private String root = "./agent-workspaces";
|
||||||
|
private DataSize maxTotalSize = DataSize.ofMegabytes(512);
|
||||||
|
private DataSize maxSingleFileSize = DataSize.ofMegabytes(100);
|
||||||
|
private int maxFileCount = 2_000;
|
||||||
|
private DataSize maxReadSize = DataSize.ofMegabytes(2);
|
||||||
|
private Duration retention = Duration.ofHours(24);
|
||||||
|
private Duration cleanupInterval = Duration.ofMinutes(30);
|
||||||
|
|
||||||
|
/** @return 工作区根目录 */
|
||||||
|
public String getRoot() { return root; }
|
||||||
|
/** @param root 工作区根目录 */
|
||||||
|
public void setRoot(String root) { this.root = root; }
|
||||||
|
/** @return 单会话工作区总量上限 */
|
||||||
|
public DataSize getMaxTotalSize() { return maxTotalSize; }
|
||||||
|
/** @param maxTotalSize 单会话工作区总量上限 */
|
||||||
|
public void setMaxTotalSize(DataSize maxTotalSize) { this.maxTotalSize = maxTotalSize; }
|
||||||
|
/** @return 单文件大小上限 */
|
||||||
|
public DataSize getMaxSingleFileSize() { return maxSingleFileSize; }
|
||||||
|
/** @param maxSingleFileSize 单文件大小上限 */
|
||||||
|
public void setMaxSingleFileSize(DataSize maxSingleFileSize) { this.maxSingleFileSize = maxSingleFileSize; }
|
||||||
|
/** @return 文件数量上限 */
|
||||||
|
public int getMaxFileCount() { return maxFileCount; }
|
||||||
|
/** @param maxFileCount 文件数量上限 */
|
||||||
|
public void setMaxFileCount(int maxFileCount) { this.maxFileCount = maxFileCount; }
|
||||||
|
/** @return 单次读取大小上限 */
|
||||||
|
public DataSize getMaxReadSize() { return maxReadSize; }
|
||||||
|
/** @param maxReadSize 单次读取大小上限 */
|
||||||
|
public void setMaxReadSize(DataSize maxReadSize) { this.maxReadSize = maxReadSize; }
|
||||||
|
/** @return 工作区保留期 */
|
||||||
|
public Duration getRetention() { return retention; }
|
||||||
|
/** @param retention 工作区保留期 */
|
||||||
|
public void setRetention(Duration retention) { this.retention = retention; }
|
||||||
|
/** @return 清理周期 */
|
||||||
|
public Duration getCleanupInterval() { return cleanupInterval; }
|
||||||
|
/** @param cleanupInterval 清理周期 */
|
||||||
|
public void setCleanupInterval(Duration cleanupInterval) { this.cleanupInterval = cleanupInterval; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动期校验工作区配置,避免以无界或互相矛盾的限制启动。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void afterPropertiesSet() {
|
||||||
|
if (!StringUtils.hasText(root)) {
|
||||||
|
throw new IllegalStateException("easyflow.agent.workspace.root 不能为空");
|
||||||
|
}
|
||||||
|
requirePositive(maxTotalSize, "max-total-size");
|
||||||
|
requirePositive(maxSingleFileSize, "max-single-file-size");
|
||||||
|
requirePositive(maxReadSize, "max-read-size");
|
||||||
|
if (maxTotalSize.toBytes() < maxSingleFileSize.toBytes()) {
|
||||||
|
throw new IllegalStateException("工作区总量上限不能小于单文件大小上限");
|
||||||
|
}
|
||||||
|
if (maxReadSize.toBytes() > maxSingleFileSize.toBytes()) {
|
||||||
|
throw new IllegalStateException("工作区读取上限不能大于单文件大小上限");
|
||||||
|
}
|
||||||
|
if (maxFileCount <= 0 || !positive(retention) || !positive(cleanupInterval)) {
|
||||||
|
throw new IllegalStateException("工作区文件数量、保留期和清理周期必须为正值");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requirePositive(DataSize value, String name) {
|
||||||
|
if (value == null || value.toBytes() <= 0) {
|
||||||
|
throw new IllegalStateException("easyflow.agent.workspace." + name + " 必须为正值");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean positive(Duration value) {
|
||||||
|
return value != null && !value.isZero() && !value.isNegative();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package tech.easyflow.agent.distributed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 不透明审批 ID 对应的内部恢复路由。
|
||||||
|
*/
|
||||||
|
public class AgentApprovalRoute {
|
||||||
|
|
||||||
|
private String requestId;
|
||||||
|
private String resumeToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取内部请求 ID。
|
||||||
|
*
|
||||||
|
* @return 请求 ID
|
||||||
|
*/
|
||||||
|
public String getRequestId() {
|
||||||
|
return requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置内部请求 ID。
|
||||||
|
*
|
||||||
|
* @param requestId 请求 ID
|
||||||
|
*/
|
||||||
|
public void setRequestId(String requestId) {
|
||||||
|
this.requestId = requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取内部恢复令牌。
|
||||||
|
*
|
||||||
|
* @return 恢复令牌
|
||||||
|
*/
|
||||||
|
public String getResumeToken() {
|
||||||
|
return resumeToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置内部恢复令牌。
|
||||||
|
*
|
||||||
|
* @param resumeToken 恢复令牌
|
||||||
|
*/
|
||||||
|
public void setResumeToken(String resumeToken) {
|
||||||
|
this.resumeToken = resumeToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,12 +84,25 @@ public class AgentRuntimeCommandConsumer implements MQConsumerHandler {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (command.getAction() == AgentRuntimeCommandAction.APPROVE) {
|
if (command.getAction() == AgentRuntimeCommandAction.APPROVE) {
|
||||||
|
if (command.getApprovalId() == null || command.getApprovalId().isBlank()) {
|
||||||
agentRunService.approveRuntimeLocal(
|
agentRunService.approveRuntimeLocal(
|
||||||
command.getRequestId(), command.getResumeToken(), command.getOperatorId(), command.getUserId());
|
command.getRequestId(), command.getResumeToken(),
|
||||||
|
command.getOperatorId(), command.getUserId());
|
||||||
|
} else {
|
||||||
|
agentRunService.approveAguiRuntimeLocal(
|
||||||
|
command.getRequestId(), command.getResumeToken(), command.getApprovalId(),
|
||||||
|
command.getOperatorId(), command.getUserId());
|
||||||
|
}
|
||||||
} else if (command.getAction() == AgentRuntimeCommandAction.REJECT) {
|
} else if (command.getAction() == AgentRuntimeCommandAction.REJECT) {
|
||||||
|
if (command.getApprovalId() == null || command.getApprovalId().isBlank()) {
|
||||||
agentRunService.rejectRuntimeLocal(
|
agentRunService.rejectRuntimeLocal(
|
||||||
command.getRequestId(), command.getResumeToken(), command.getReason(),
|
command.getRequestId(), command.getResumeToken(), command.getReason(),
|
||||||
command.getOperatorId(), command.getUserId());
|
command.getOperatorId(), command.getUserId());
|
||||||
|
} else {
|
||||||
|
agentRunService.rejectAguiRuntimeLocal(
|
||||||
|
command.getRequestId(), command.getResumeToken(), command.getApprovalId(), command.getReason(),
|
||||||
|
command.getOperatorId(), command.getUserId());
|
||||||
|
}
|
||||||
} else if (command.getAction() == AgentRuntimeCommandAction.EXPIRE) {
|
} else if (command.getAction() == AgentRuntimeCommandAction.EXPIRE) {
|
||||||
agentRunService.expireApprovalLocal(
|
agentRunService.expireApprovalLocal(
|
||||||
command.getRequestId(), command.getResumeToken(), command.getReason());
|
command.getRequestId(), command.getResumeToken(), command.getReason());
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public class AgentRuntimeCommandMessage {
|
|||||||
private String commandId;
|
private String commandId;
|
||||||
private String requestId;
|
private String requestId;
|
||||||
private String resumeToken;
|
private String resumeToken;
|
||||||
|
private String approvalId;
|
||||||
private AgentRuntimeCommandAction action;
|
private AgentRuntimeCommandAction action;
|
||||||
private String reason;
|
private String reason;
|
||||||
private BigInteger operatorId;
|
private BigInteger operatorId;
|
||||||
@@ -43,6 +44,24 @@ public class AgentRuntimeCommandMessage {
|
|||||||
this.resumeToken = resumeToken;
|
this.resumeToken = resumeToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 AG-UI 不透明审批 ID。
|
||||||
|
*
|
||||||
|
* @return 审批 ID
|
||||||
|
*/
|
||||||
|
public String getApprovalId() {
|
||||||
|
return approvalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 AG-UI 不透明审批 ID。
|
||||||
|
*
|
||||||
|
* @param approvalId 审批 ID
|
||||||
|
*/
|
||||||
|
public void setApprovalId(String approvalId) {
|
||||||
|
this.approvalId = approvalId;
|
||||||
|
}
|
||||||
|
|
||||||
public AgentRuntimeCommandAction getAction() {
|
public AgentRuntimeCommandAction getAction() {
|
||||||
return action;
|
return action;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,29 @@ public class AgentRuntimeCommandProducer {
|
|||||||
BigInteger operatorId,
|
BigInteger operatorId,
|
||||||
String userId) {
|
String userId) {
|
||||||
sendAndWait(
|
sendAndWait(
|
||||||
targetNodeId, requestId, resumeToken, null,
|
targetNodeId, requestId, resumeToken, null, null,
|
||||||
|
AgentRuntimeCommandAction.APPROVE, null, operatorId, userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 投递携带 AG-UI 审批 ID 的远程批准命令。
|
||||||
|
*
|
||||||
|
* @param targetNodeId 目标节点 ID
|
||||||
|
* @param requestId 请求 ID
|
||||||
|
* @param resumeToken 恢复令牌
|
||||||
|
* @param approvalId 不透明审批 ID
|
||||||
|
* @param operatorId 操作人 ID
|
||||||
|
* @param userId 用户 ID
|
||||||
|
*/
|
||||||
|
public void sendApprove(String targetNodeId,
|
||||||
|
String requestId,
|
||||||
|
String resumeToken,
|
||||||
|
String approvalId,
|
||||||
|
BigInteger operatorId,
|
||||||
|
String userId) {
|
||||||
|
sendAndWait(
|
||||||
|
targetNodeId, requestId, resumeToken, null, approvalId,
|
||||||
AgentRuntimeCommandAction.APPROVE, null, operatorId, userId
|
AgentRuntimeCommandAction.APPROVE, null, operatorId, userId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,7 +114,31 @@ public class AgentRuntimeCommandProducer {
|
|||||||
BigInteger operatorId,
|
BigInteger operatorId,
|
||||||
String userId) {
|
String userId) {
|
||||||
sendAndWait(
|
sendAndWait(
|
||||||
targetNodeId, requestId, resumeToken, null,
|
targetNodeId, requestId, resumeToken, null, null,
|
||||||
|
AgentRuntimeCommandAction.REJECT, reason, operatorId, userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 投递携带 AG-UI 审批 ID 的远程拒绝命令。
|
||||||
|
*
|
||||||
|
* @param targetNodeId 目标节点 ID
|
||||||
|
* @param requestId 请求 ID
|
||||||
|
* @param resumeToken 恢复令牌
|
||||||
|
* @param approvalId 不透明审批 ID
|
||||||
|
* @param reason 拒绝原因
|
||||||
|
* @param operatorId 操作人 ID
|
||||||
|
* @param userId 用户 ID
|
||||||
|
*/
|
||||||
|
public void sendReject(String targetNodeId,
|
||||||
|
String requestId,
|
||||||
|
String resumeToken,
|
||||||
|
String approvalId,
|
||||||
|
String reason,
|
||||||
|
BigInteger operatorId,
|
||||||
|
String userId) {
|
||||||
|
sendAndWait(
|
||||||
|
targetNodeId, requestId, resumeToken, null, approvalId,
|
||||||
AgentRuntimeCommandAction.REJECT, reason, operatorId, userId
|
AgentRuntimeCommandAction.REJECT, reason, operatorId, userId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -110,7 +156,7 @@ public class AgentRuntimeCommandProducer {
|
|||||||
String resumeToken,
|
String resumeToken,
|
||||||
String reason) {
|
String reason) {
|
||||||
sendAndWait(
|
sendAndWait(
|
||||||
targetNodeId, requestId, resumeToken, null,
|
targetNodeId, requestId, resumeToken, null, null,
|
||||||
AgentRuntimeCommandAction.EXPIRE, reason, null, null
|
AgentRuntimeCommandAction.EXPIRE, reason, null, null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -124,7 +170,7 @@ public class AgentRuntimeCommandProducer {
|
|||||||
*/
|
*/
|
||||||
public void sendCancelAgent(String targetNodeId, String agentId, String reason) {
|
public void sendCancelAgent(String targetNodeId, String agentId, String reason) {
|
||||||
sendAndWait(
|
sendAndWait(
|
||||||
targetNodeId, null, null, agentId,
|
targetNodeId, null, null, agentId, null,
|
||||||
AgentRuntimeCommandAction.CANCEL_AGENT, reason, null, null
|
AgentRuntimeCommandAction.CANCEL_AGENT, reason, null, null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -136,6 +182,7 @@ public class AgentRuntimeCommandProducer {
|
|||||||
* @param requestId 请求 ID
|
* @param requestId 请求 ID
|
||||||
* @param resumeToken 恢复令牌
|
* @param resumeToken 恢复令牌
|
||||||
* @param agentId Agent ID
|
* @param agentId Agent ID
|
||||||
|
* @param approvalId AG-UI 不透明审批 ID
|
||||||
* @param action 命令动作
|
* @param action 命令动作
|
||||||
* @param reason 操作原因
|
* @param reason 操作原因
|
||||||
* @param operatorId 操作人 ID
|
* @param operatorId 操作人 ID
|
||||||
@@ -146,6 +193,7 @@ public class AgentRuntimeCommandProducer {
|
|||||||
String requestId,
|
String requestId,
|
||||||
String resumeToken,
|
String resumeToken,
|
||||||
String agentId,
|
String agentId,
|
||||||
|
String approvalId,
|
||||||
AgentRuntimeCommandAction action,
|
AgentRuntimeCommandAction action,
|
||||||
String reason,
|
String reason,
|
||||||
BigInteger operatorId,
|
BigInteger operatorId,
|
||||||
@@ -158,6 +206,7 @@ public class AgentRuntimeCommandProducer {
|
|||||||
command.setRequestId(requestId);
|
command.setRequestId(requestId);
|
||||||
command.setResumeToken(resumeToken);
|
command.setResumeToken(resumeToken);
|
||||||
command.setAgentId(agentId);
|
command.setAgentId(agentId);
|
||||||
|
command.setApprovalId(approvalId);
|
||||||
command.setAction(action);
|
command.setAction(action);
|
||||||
command.setReason(reason);
|
command.setReason(reason);
|
||||||
command.setOperatorId(operatorId);
|
command.setOperatorId(operatorId);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public class AgentRuntimeRouteRegistry {
|
|||||||
|
|
||||||
private static final String REQUEST_ROUTE_PREFIX = "easyflow:agent:runtime:request:";
|
private static final String REQUEST_ROUTE_PREFIX = "easyflow:agent:runtime:request:";
|
||||||
private static final String TOKEN_ROUTE_PREFIX = "easyflow:agent:runtime:resume-token:";
|
private static final String TOKEN_ROUTE_PREFIX = "easyflow:agent:runtime:resume-token:";
|
||||||
|
private static final String APPROVAL_ROUTE_PREFIX = "easyflow:agent:runtime:approval:";
|
||||||
private static final String NODE_HEARTBEAT_PREFIX = "easyflow:agent:runtime:node:";
|
private static final String NODE_HEARTBEAT_PREFIX = "easyflow:agent:runtime:node:";
|
||||||
private static final String AGENT_RUNS_PREFIX = "easyflow:agent:runtime:agent:";
|
private static final String AGENT_RUNS_PREFIX = "easyflow:agent:runtime:agent:";
|
||||||
|
|
||||||
@@ -89,6 +90,30 @@ public class AgentRuntimeRouteRegistry {
|
|||||||
stringRedisTemplate.opsForValue().set(tokenKey(resumeToken), requestId, properties.getRouteTtl());
|
stringRedisTemplate.opsForValue().set(tokenKey(resumeToken), requestId, properties.getRouteTtl());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册不透明审批 ID 与内部恢复目标的关系。
|
||||||
|
*
|
||||||
|
* @param approvalId 公开审批 ID
|
||||||
|
* @param requestId 内部请求 ID
|
||||||
|
* @param resumeToken 内部恢复令牌
|
||||||
|
*/
|
||||||
|
public void registerApproval(String approvalId, String requestId, String resumeToken) {
|
||||||
|
if (approvalId == null || approvalId.isBlank()
|
||||||
|
|| requestId == null || requestId.isBlank()
|
||||||
|
|| resumeToken == null || resumeToken.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AgentApprovalRoute route = new AgentApprovalRoute();
|
||||||
|
route.setRequestId(requestId);
|
||||||
|
route.setResumeToken(resumeToken);
|
||||||
|
try {
|
||||||
|
stringRedisTemplate.opsForValue().set(
|
||||||
|
approvalKey(approvalId), objectMapper.writeValueAsString(route), properties.getRouteTtl());
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw new IllegalStateException("Agent 审批路由序列化失败", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询请求 ID 所属节点。
|
* 查询请求 ID 所属节点。
|
||||||
*
|
*
|
||||||
@@ -130,6 +155,27 @@ public class AgentRuntimeRouteRegistry {
|
|||||||
return stringRedisTemplate.opsForValue().get(tokenKey(resumeToken));
|
return stringRedisTemplate.opsForValue().get(tokenKey(resumeToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据公开审批 ID 查询内部恢复目标。
|
||||||
|
*
|
||||||
|
* @param approvalId 公开审批 ID
|
||||||
|
* @return 审批恢复目标,不存在时返回 null
|
||||||
|
*/
|
||||||
|
public AgentApprovalRoute findApproval(String approvalId) {
|
||||||
|
if (approvalId == null || approvalId.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String value = stringRedisTemplate.opsForValue().get(approvalKey(approvalId));
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(value, AgentApprovalRoute.class);
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw new IllegalStateException("Agent 审批路由反序列化失败", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询指定 Agent 当前活跃运行所在的节点。
|
* 查询指定 Agent 当前活跃运行所在的节点。
|
||||||
*
|
*
|
||||||
@@ -192,6 +238,18 @@ public class AgentRuntimeRouteRegistry {
|
|||||||
deleteQuietly(tokenKey(resumeToken));
|
deleteQuietly(tokenKey(resumeToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除公开审批 ID 的内部路由。
|
||||||
|
*
|
||||||
|
* @param approvalId 公开审批 ID
|
||||||
|
*/
|
||||||
|
public void removeApproval(String approvalId) {
|
||||||
|
if (approvalId == null || approvalId.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteQuietly(approvalKey(approvalId));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前节点 ID。
|
* 获取当前节点 ID。
|
||||||
*
|
*
|
||||||
@@ -241,6 +299,10 @@ public class AgentRuntimeRouteRegistry {
|
|||||||
return TOKEN_ROUTE_PREFIX + resumeToken;
|
return TOKEN_ROUTE_PREFIX + resumeToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String approvalKey(String approvalId) {
|
||||||
|
return APPROVAL_ROUTE_PREFIX + approvalId;
|
||||||
|
}
|
||||||
|
|
||||||
private String nodeKey(String nodeId) {
|
private String nodeKey(String nodeId) {
|
||||||
return NODE_HEARTBEAT_PREFIX + nodeId;
|
return NODE_HEARTBEAT_PREFIX + nodeId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ public class Agent extends DateEntity implements VisibilityResource, Serializabl
|
|||||||
private List<AgentToolBinding> toolBindings;
|
private List<AgentToolBinding> toolBindings;
|
||||||
@Column(ignore = true)
|
@Column(ignore = true)
|
||||||
private List<AgentKnowledgeBinding> knowledgeBindings;
|
private List<AgentKnowledgeBinding> knowledgeBindings;
|
||||||
|
@Column(ignore = true)
|
||||||
|
private List<AgentSkillBinding> skillBindings;
|
||||||
|
|
||||||
public BigInteger getId() { return id; }
|
public BigInteger getId() { return id; }
|
||||||
public void setId(BigInteger id) { this.id = id; }
|
public void setId(BigInteger id) { this.id = id; }
|
||||||
@@ -144,4 +146,8 @@ public class Agent extends DateEntity implements VisibilityResource, Serializabl
|
|||||||
public void setToolBindings(List<AgentToolBinding> toolBindings) { this.toolBindings = toolBindings; }
|
public void setToolBindings(List<AgentToolBinding> toolBindings) { this.toolBindings = toolBindings; }
|
||||||
public List<AgentKnowledgeBinding> getKnowledgeBindings() { return knowledgeBindings; }
|
public List<AgentKnowledgeBinding> getKnowledgeBindings() { return knowledgeBindings; }
|
||||||
public void setKnowledgeBindings(List<AgentKnowledgeBinding> knowledgeBindings) { this.knowledgeBindings = knowledgeBindings; }
|
public void setKnowledgeBindings(List<AgentKnowledgeBinding> knowledgeBindings) { this.knowledgeBindings = knowledgeBindings; }
|
||||||
|
/** @return Skill 绑定 */
|
||||||
|
public List<AgentSkillBinding> getSkillBindings() { return skillBindings; }
|
||||||
|
/** @param skillBindings Skill 绑定 */
|
||||||
|
public void setSkillBindings(List<AgentSkillBinding> skillBindings) { this.skillBindings = skillBindings; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package tech.easyflow.agent.entity;
|
||||||
|
|
||||||
|
import com.mybatisflex.annotation.Column;
|
||||||
|
import com.mybatisflex.annotation.Id;
|
||||||
|
import com.mybatisflex.annotation.KeyType;
|
||||||
|
import com.mybatisflex.annotation.Table;
|
||||||
|
import tech.easyflow.common.entity.DateEntity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 正式产物的对象存储状态账本。
|
||||||
|
*/
|
||||||
|
@Table("tb_agent_artifact")
|
||||||
|
public class AgentArtifact extends DateEntity implements Serializable {
|
||||||
|
|
||||||
|
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
|
||||||
|
private BigInteger id;
|
||||||
|
private String artifactId;
|
||||||
|
@Column(tenantId = true)
|
||||||
|
private BigInteger tenantId;
|
||||||
|
private BigInteger agentId;
|
||||||
|
private BigInteger ownerUserId;
|
||||||
|
private String chatMode;
|
||||||
|
private BigInteger chatSessionId;
|
||||||
|
private String runtimeSessionId;
|
||||||
|
private String requestId;
|
||||||
|
private BigInteger roundId;
|
||||||
|
private Integer variantIndex;
|
||||||
|
private String toolCallId;
|
||||||
|
private String fileName;
|
||||||
|
private String mimeType;
|
||||||
|
private Long sizeBytes;
|
||||||
|
private String sha256;
|
||||||
|
private String storagePlatform;
|
||||||
|
private String objectKey;
|
||||||
|
private String storageEtag;
|
||||||
|
private String status;
|
||||||
|
private Date expiresAt;
|
||||||
|
private Integer retryCount;
|
||||||
|
private Date nextRetryAt;
|
||||||
|
private String lastErrorCode;
|
||||||
|
private Date created;
|
||||||
|
private BigInteger createdBy;
|
||||||
|
private Date modified;
|
||||||
|
private BigInteger modifiedBy;
|
||||||
|
@Column(isLogicDelete = true)
|
||||||
|
private Integer isDeleted;
|
||||||
|
|
||||||
|
/** @return 内部主键 */
|
||||||
|
public BigInteger getId() { return id; }
|
||||||
|
/** @param id 内部主键 */
|
||||||
|
public void setId(BigInteger id) { this.id = id; }
|
||||||
|
/** @return 对外稳定产物 ID */
|
||||||
|
public String getArtifactId() { return artifactId; }
|
||||||
|
/** @param artifactId 对外稳定产物 ID */
|
||||||
|
public void setArtifactId(String artifactId) { this.artifactId = artifactId; }
|
||||||
|
/** @return 租户 ID */
|
||||||
|
public BigInteger getTenantId() { return tenantId; }
|
||||||
|
/** @param tenantId 租户 ID */
|
||||||
|
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
|
||||||
|
/** @return Agent ID */
|
||||||
|
public BigInteger getAgentId() { return agentId; }
|
||||||
|
/** @param agentId Agent ID */
|
||||||
|
public void setAgentId(BigInteger agentId) { this.agentId = agentId; }
|
||||||
|
/** @return 所有者用户 ID */
|
||||||
|
public BigInteger getOwnerUserId() { return ownerUserId; }
|
||||||
|
/** @param ownerUserId 所有者用户 ID */
|
||||||
|
public void setOwnerUserId(BigInteger ownerUserId) { this.ownerUserId = ownerUserId; }
|
||||||
|
/** @return 聊天模式 */
|
||||||
|
public String getChatMode() { return chatMode; }
|
||||||
|
/** @param chatMode 聊天模式 */
|
||||||
|
public void setChatMode(String chatMode) { this.chatMode = chatMode; }
|
||||||
|
/** @return 正式聊天会话 ID */
|
||||||
|
public BigInteger getChatSessionId() { return chatSessionId; }
|
||||||
|
/** @param chatSessionId 正式聊天会话 ID */
|
||||||
|
public void setChatSessionId(BigInteger chatSessionId) { this.chatSessionId = chatSessionId; }
|
||||||
|
/** @return Runtime 会话 ID */
|
||||||
|
public String getRuntimeSessionId() { return runtimeSessionId; }
|
||||||
|
/** @param runtimeSessionId Runtime 会话 ID */
|
||||||
|
public void setRuntimeSessionId(String runtimeSessionId) { this.runtimeSessionId = runtimeSessionId; }
|
||||||
|
/** @return 运行请求 ID */
|
||||||
|
public String getRequestId() { return requestId; }
|
||||||
|
/** @param requestId 运行请求 ID */
|
||||||
|
public void setRequestId(String requestId) { this.requestId = requestId; }
|
||||||
|
/** @return 聊天轮次 ID */
|
||||||
|
public BigInteger getRoundId() { return roundId; }
|
||||||
|
/** @param roundId 聊天轮次 ID */
|
||||||
|
public void setRoundId(BigInteger roundId) { this.roundId = roundId; }
|
||||||
|
/** @return 正式聊天答案版本序号 */
|
||||||
|
public Integer getVariantIndex() { return variantIndex; }
|
||||||
|
/** @param variantIndex 正式聊天答案版本序号 */
|
||||||
|
public void setVariantIndex(Integer variantIndex) { this.variantIndex = variantIndex; }
|
||||||
|
/** @return 工具调用 ID */
|
||||||
|
public String getToolCallId() { return toolCallId; }
|
||||||
|
/** @param toolCallId 工具调用 ID */
|
||||||
|
public void setToolCallId(String toolCallId) { this.toolCallId = toolCallId; }
|
||||||
|
/** @return 安全展示文件名 */
|
||||||
|
public String getFileName() { return fileName; }
|
||||||
|
/** @param fileName 安全展示文件名 */
|
||||||
|
public void setFileName(String fileName) { this.fileName = fileName; }
|
||||||
|
/** @return MIME 类型 */
|
||||||
|
public String getMimeType() { return mimeType; }
|
||||||
|
/** @param mimeType MIME 类型 */
|
||||||
|
public void setMimeType(String mimeType) { this.mimeType = mimeType; }
|
||||||
|
/** @return 字节数 */
|
||||||
|
public Long getSizeBytes() { return sizeBytes; }
|
||||||
|
/** @param sizeBytes 字节数 */
|
||||||
|
public void setSizeBytes(Long sizeBytes) { this.sizeBytes = sizeBytes; }
|
||||||
|
/** @return SHA-256 */
|
||||||
|
public String getSha256() { return sha256; }
|
||||||
|
/** @param sha256 SHA-256 */
|
||||||
|
public void setSha256(String sha256) { this.sha256 = sha256; }
|
||||||
|
/** @return 内部存储平台 */
|
||||||
|
public String getStoragePlatform() { return storagePlatform; }
|
||||||
|
/** @param storagePlatform 内部存储平台 */
|
||||||
|
public void setStoragePlatform(String storagePlatform) { this.storagePlatform = storagePlatform; }
|
||||||
|
/** @return 内部对象键 */
|
||||||
|
public String getObjectKey() { return objectKey; }
|
||||||
|
/** @param objectKey 内部对象键 */
|
||||||
|
public void setObjectKey(String objectKey) { this.objectKey = objectKey; }
|
||||||
|
/** @return 对象 ETag */
|
||||||
|
public String getStorageEtag() { return storageEtag; }
|
||||||
|
/** @param storageEtag 对象 ETag */
|
||||||
|
public void setStorageEtag(String storageEtag) { this.storageEtag = storageEtag; }
|
||||||
|
/** @return 账本状态 */
|
||||||
|
public String getStatus() { return status; }
|
||||||
|
/** @param status 账本状态 */
|
||||||
|
public void setStatus(String status) { this.status = status; }
|
||||||
|
/** @return 过期时间 */
|
||||||
|
public Date getExpiresAt() { return expiresAt; }
|
||||||
|
/** @param expiresAt 过期时间 */
|
||||||
|
public void setExpiresAt(Date expiresAt) { this.expiresAt = expiresAt; }
|
||||||
|
/** @return 重试次数 */
|
||||||
|
public Integer getRetryCount() { return retryCount; }
|
||||||
|
/** @param retryCount 重试次数 */
|
||||||
|
public void setRetryCount(Integer retryCount) { this.retryCount = retryCount; }
|
||||||
|
/** @return 下次重试时间 */
|
||||||
|
public Date getNextRetryAt() { return nextRetryAt; }
|
||||||
|
/** @param nextRetryAt 下次重试时间 */
|
||||||
|
public void setNextRetryAt(Date nextRetryAt) { this.nextRetryAt = nextRetryAt; }
|
||||||
|
/** @return 最近错误码 */
|
||||||
|
public String getLastErrorCode() { return lastErrorCode; }
|
||||||
|
/** @param lastErrorCode 最近错误码 */
|
||||||
|
public void setLastErrorCode(String lastErrorCode) { this.lastErrorCode = lastErrorCode; }
|
||||||
|
/** @return 创建时间 */
|
||||||
|
@Override public Date getCreated() { return created; }
|
||||||
|
/** @param created 创建时间 */
|
||||||
|
@Override public void setCreated(Date created) { this.created = created; }
|
||||||
|
/** @return 创建人 */
|
||||||
|
public BigInteger getCreatedBy() { return createdBy; }
|
||||||
|
/** @param createdBy 创建人 */
|
||||||
|
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
|
||||||
|
/** @return 修改时间 */
|
||||||
|
@Override public Date getModified() { return modified; }
|
||||||
|
/** @param modified 修改时间 */
|
||||||
|
@Override public void setModified(Date modified) { this.modified = modified; }
|
||||||
|
/** @return 修改人 */
|
||||||
|
public BigInteger getModifiedBy() { return modifiedBy; }
|
||||||
|
/** @param modifiedBy 修改人 */
|
||||||
|
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
|
||||||
|
/** @return 逻辑删除标记 */
|
||||||
|
public Integer getIsDeleted() { return isDeleted; }
|
||||||
|
/** @param isDeleted 逻辑删除标记 */
|
||||||
|
public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package tech.easyflow.agent.entity;
|
||||||
|
|
||||||
|
import com.mybatisflex.annotation.Column;
|
||||||
|
import com.mybatisflex.annotation.Id;
|
||||||
|
import com.mybatisflex.annotation.KeyType;
|
||||||
|
import com.mybatisflex.annotation.Table;
|
||||||
|
import com.mybatisflex.core.handler.FastjsonTypeHandler;
|
||||||
|
import tech.easyflow.common.entity.DateEntity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 与已发布 Skill 的原子草稿绑定。
|
||||||
|
*/
|
||||||
|
@Table("tb_agent_skill_binding")
|
||||||
|
public class AgentSkillBinding extends DateEntity implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
|
||||||
|
private BigInteger id;
|
||||||
|
@Column(tenantId = true)
|
||||||
|
private BigInteger tenantId;
|
||||||
|
private BigInteger agentId;
|
||||||
|
private BigInteger skillId;
|
||||||
|
private Integer sortNo;
|
||||||
|
private Date created;
|
||||||
|
private BigInteger createdBy;
|
||||||
|
private Date modified;
|
||||||
|
private BigInteger modifiedBy;
|
||||||
|
@Column(ignore = true, typeHandler = FastjsonTypeHandler.class)
|
||||||
|
private Map<String, Object> resourceSnapshot = new LinkedHashMap<>();
|
||||||
|
@Column(ignore = true, typeHandler = FastjsonTypeHandler.class)
|
||||||
|
private Map<String, Object> resourceSummary = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
/** @return 绑定 ID */
|
||||||
|
public BigInteger getId() { return id; }
|
||||||
|
/** @param id 绑定 ID */
|
||||||
|
public void setId(BigInteger id) { this.id = id; }
|
||||||
|
/** @return 租户 ID */
|
||||||
|
public BigInteger getTenantId() { return tenantId; }
|
||||||
|
/** @param tenantId 租户 ID */
|
||||||
|
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
|
||||||
|
/** @return Agent ID */
|
||||||
|
public BigInteger getAgentId() { return agentId; }
|
||||||
|
/** @param agentId Agent ID */
|
||||||
|
public void setAgentId(BigInteger agentId) { this.agentId = agentId; }
|
||||||
|
/** @return Skill ID */
|
||||||
|
public BigInteger getSkillId() { return skillId; }
|
||||||
|
/** @param skillId Skill ID */
|
||||||
|
public void setSkillId(BigInteger skillId) { this.skillId = skillId; }
|
||||||
|
/** @return 排序号 */
|
||||||
|
public Integer getSortNo() { return sortNo; }
|
||||||
|
/** @param sortNo 排序号 */
|
||||||
|
public void setSortNo(Integer sortNo) { this.sortNo = sortNo; }
|
||||||
|
/** @return 创建时间 */
|
||||||
|
@Override public Date getCreated() { return created; }
|
||||||
|
/** @param created 创建时间 */
|
||||||
|
@Override public void setCreated(Date created) { this.created = created; }
|
||||||
|
/** @return 创建人 */
|
||||||
|
public BigInteger getCreatedBy() { return createdBy; }
|
||||||
|
/** @param createdBy 创建人 */
|
||||||
|
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
|
||||||
|
/** @return 修改时间 */
|
||||||
|
@Override public Date getModified() { return modified; }
|
||||||
|
/** @param modified 修改时间 */
|
||||||
|
@Override public void setModified(Date modified) { this.modified = modified; }
|
||||||
|
/** @return 修改人 */
|
||||||
|
public BigInteger getModifiedBy() { return modifiedBy; }
|
||||||
|
/** @param modifiedBy 修改人 */
|
||||||
|
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
|
||||||
|
/** @return Agent 内部冻结 Skill 运行快照 */
|
||||||
|
public Map<String, Object> getResourceSnapshot() { return resourceSnapshot; }
|
||||||
|
/** @param resourceSnapshot Agent 内部冻结 Skill 运行快照 */
|
||||||
|
public void setResourceSnapshot(Map<String, Object> resourceSnapshot) {
|
||||||
|
this.resourceSnapshot = resourceSnapshot == null ? new LinkedHashMap<>() : resourceSnapshot;
|
||||||
|
}
|
||||||
|
/** @return 脱敏 Skill 摘要 */
|
||||||
|
public Map<String, Object> getResourceSummary() { return resourceSummary; }
|
||||||
|
/** @param resourceSummary 脱敏 Skill 摘要 */
|
||||||
|
public void setResourceSummary(Map<String, Object> resourceSummary) {
|
||||||
|
this.resourceSummary = resourceSummary == null ? new LinkedHashMap<>() : resourceSummary;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package tech.easyflow.agent.mapper;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import tech.easyflow.agent.entity.AgentArtifact;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent Artifact 状态账本 Mapper。
|
||||||
|
*/
|
||||||
|
public interface AgentArtifactMapper extends BaseMapper<AgentArtifact> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有界查询正式会话已删除、缺失或归属不一致的 Artifact。
|
||||||
|
*
|
||||||
|
* @param limit 最大返回数量
|
||||||
|
* @return 待补偿删除的 Artifact
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
SELECT artifact.*
|
||||||
|
FROM tb_agent_artifact artifact
|
||||||
|
WHERE artifact.is_deleted = 0
|
||||||
|
AND artifact.chat_mode = 'FORMAL'
|
||||||
|
AND artifact.status IN ('PUBLISHING', 'AVAILABLE', 'FAILED', 'DELETE_FAILED')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM chat_session chat
|
||||||
|
WHERE chat.id = artifact.chat_session_id
|
||||||
|
AND chat.is_deleted = 0
|
||||||
|
AND chat.assistant_code = 'AGENT'
|
||||||
|
AND chat.tenant_id = artifact.tenant_id
|
||||||
|
AND chat.user_id = artifact.owner_user_id
|
||||||
|
AND chat.assistant_id = artifact.agent_id
|
||||||
|
)
|
||||||
|
ORDER BY artifact.id
|
||||||
|
LIMIT #{limit}
|
||||||
|
""")
|
||||||
|
List<AgentArtifact> selectOrphanedFormalArtifacts(@Param("limit") int limit);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package tech.easyflow.agent.mapper;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.BaseMapper;
|
||||||
|
import tech.easyflow.agent.entity.AgentSkillBinding;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent Skill 绑定 Mapper。
|
||||||
|
*/
|
||||||
|
public interface AgentSkillBindingMapper extends BaseMapper<AgentSkillBinding> {
|
||||||
|
}
|
||||||
@@ -9,11 +9,13 @@ import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
|
|||||||
import tech.easyflow.agent.entity.Agent;
|
import tech.easyflow.agent.entity.Agent;
|
||||||
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||||
import tech.easyflow.agent.entity.AgentToolBinding;
|
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||||
|
import tech.easyflow.agent.entity.AgentSkillBinding;
|
||||||
import tech.easyflow.agent.runtime.AgentRunRegistry;
|
import tech.easyflow.agent.runtime.AgentRunRegistry;
|
||||||
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
|
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
|
||||||
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
|
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
|
||||||
import tech.easyflow.agent.service.AgentService;
|
import tech.easyflow.agent.service.AgentService;
|
||||||
import tech.easyflow.agent.service.AgentToolBindingService;
|
import tech.easyflow.agent.service.AgentToolBindingService;
|
||||||
|
import tech.easyflow.agent.service.AgentSkillBindingService;
|
||||||
import tech.easyflow.agent.support.AgentBindingLockExecutor;
|
import tech.easyflow.agent.support.AgentBindingLockExecutor;
|
||||||
import tech.easyflow.ai.enums.PublishStatus;
|
import tech.easyflow.ai.enums.PublishStatus;
|
||||||
import tech.easyflow.ai.publish.AbstractAiResourceLifecycleHandler;
|
import tech.easyflow.ai.publish.AbstractAiResourceLifecycleHandler;
|
||||||
@@ -38,6 +40,7 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
|
|||||||
private final AgentService agentService;
|
private final AgentService agentService;
|
||||||
private final AgentToolBindingService agentToolBindingService;
|
private final AgentToolBindingService agentToolBindingService;
|
||||||
private final AgentKnowledgeBindingService agentKnowledgeBindingService;
|
private final AgentKnowledgeBindingService agentKnowledgeBindingService;
|
||||||
|
private final AgentSkillBindingService agentSkillBindingService;
|
||||||
private final ResourceAccessService resourceAccessService;
|
private final ResourceAccessService resourceAccessService;
|
||||||
private final AgentBindingLockExecutor agentBindingLockExecutor;
|
private final AgentBindingLockExecutor agentBindingLockExecutor;
|
||||||
private final AgentRunRegistry agentRunRegistry;
|
private final AgentRunRegistry agentRunRegistry;
|
||||||
@@ -53,6 +56,7 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
|
|||||||
* @param agentService Agent 服务
|
* @param agentService Agent 服务
|
||||||
* @param agentToolBindingService Agent 工具绑定服务
|
* @param agentToolBindingService Agent 工具绑定服务
|
||||||
* @param agentKnowledgeBindingService Agent 知识库绑定服务
|
* @param agentKnowledgeBindingService Agent 知识库绑定服务
|
||||||
|
* @param agentSkillBindingService Agent Skill 绑定服务
|
||||||
* @param resourceAccessService 资源访问服务
|
* @param resourceAccessService 资源访问服务
|
||||||
* @param agentBindingLockExecutor Agent 配置锁执行器
|
* @param agentBindingLockExecutor Agent 配置锁执行器
|
||||||
* @param agentRunRegistry Agent 运行态注册表
|
* @param agentRunRegistry Agent 运行态注册表
|
||||||
@@ -65,6 +69,7 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
|
|||||||
AgentService agentService,
|
AgentService agentService,
|
||||||
AgentToolBindingService agentToolBindingService,
|
AgentToolBindingService agentToolBindingService,
|
||||||
AgentKnowledgeBindingService agentKnowledgeBindingService,
|
AgentKnowledgeBindingService agentKnowledgeBindingService,
|
||||||
|
AgentSkillBindingService agentSkillBindingService,
|
||||||
ResourceAccessService resourceAccessService,
|
ResourceAccessService resourceAccessService,
|
||||||
AgentBindingLockExecutor agentBindingLockExecutor,
|
AgentBindingLockExecutor agentBindingLockExecutor,
|
||||||
AgentRunRegistry agentRunRegistry,
|
AgentRunRegistry agentRunRegistry,
|
||||||
@@ -75,6 +80,7 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
|
|||||||
this.agentService = agentService;
|
this.agentService = agentService;
|
||||||
this.agentToolBindingService = agentToolBindingService;
|
this.agentToolBindingService = agentToolBindingService;
|
||||||
this.agentKnowledgeBindingService = agentKnowledgeBindingService;
|
this.agentKnowledgeBindingService = agentKnowledgeBindingService;
|
||||||
|
this.agentSkillBindingService = agentSkillBindingService;
|
||||||
this.resourceAccessService = resourceAccessService;
|
this.resourceAccessService = resourceAccessService;
|
||||||
this.agentBindingLockExecutor = agentBindingLockExecutor;
|
this.agentBindingLockExecutor = agentBindingLockExecutor;
|
||||||
this.agentRunRegistry = agentRunRegistry;
|
this.agentRunRegistry = agentRunRegistry;
|
||||||
@@ -196,6 +202,8 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
|
|||||||
QueryWrapper.create().eq(AgentToolBinding::getAgentId, resourceId));
|
QueryWrapper.create().eq(AgentToolBinding::getAgentId, resourceId));
|
||||||
agentKnowledgeBindingService.remove(
|
agentKnowledgeBindingService.remove(
|
||||||
QueryWrapper.create().eq(AgentKnowledgeBinding::getAgentId, resourceId));
|
QueryWrapper.create().eq(AgentKnowledgeBinding::getAgentId, resourceId));
|
||||||
|
agentSkillBindingService.remove(
|
||||||
|
QueryWrapper.create().eq(AgentSkillBinding::getAgentId, resourceId));
|
||||||
agentService.removeById(resourceId);
|
agentService.removeById(resourceId);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package tech.easyflow.agent.runtime;
|
|||||||
import tech.easyflow.agent.entity.Agent;
|
import tech.easyflow.agent.entity.Agent;
|
||||||
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||||
import tech.easyflow.agent.entity.AgentToolBinding;
|
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||||
|
import tech.easyflow.agent.entity.AgentSkillBinding;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -15,6 +16,7 @@ public class AgentDraftChatRequest {
|
|||||||
private Agent agent;
|
private Agent agent;
|
||||||
private List<AgentToolBinding> toolBindings;
|
private List<AgentToolBinding> toolBindings;
|
||||||
private List<AgentKnowledgeBinding> knowledgeBindings;
|
private List<AgentKnowledgeBinding> knowledgeBindings;
|
||||||
|
private List<AgentSkillBinding> skillBindings;
|
||||||
private String sessionId;
|
private String sessionId;
|
||||||
private String prompt;
|
private String prompt;
|
||||||
private List<String> imageUploadIds = new ArrayList<>();
|
private List<String> imageUploadIds = new ArrayList<>();
|
||||||
@@ -74,6 +76,24 @@ public class AgentDraftChatRequest {
|
|||||||
this.knowledgeBindings = knowledgeBindings;
|
this.knowledgeBindings = knowledgeBindings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Skill 绑定快照。
|
||||||
|
*
|
||||||
|
* @return Skill 绑定快照
|
||||||
|
*/
|
||||||
|
public List<AgentSkillBinding> getSkillBindings() {
|
||||||
|
return skillBindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 Skill 绑定快照。
|
||||||
|
*
|
||||||
|
* @param skillBindings Skill 绑定快照
|
||||||
|
*/
|
||||||
|
public void setSkillBindings(List<AgentSkillBinding> skillBindings) {
|
||||||
|
this.skillBindings = skillBindings;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取草稿试运行会话 ID。
|
* 获取草稿试运行会话 ID。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import reactor.core.Disposable;
|
import reactor.core.Disposable;
|
||||||
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
|
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
|
||||||
|
import tech.easyflow.agent.distributed.AgentApprovalRoute;
|
||||||
import tech.easyflow.agent.runtime.lock.AgentRunLock;
|
import tech.easyflow.agent.runtime.lock.AgentRunLock;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
|
import tech.easyflow.agent.runtime.output.AgentRunOutput;
|
||||||
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
|
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
|
||||||
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
||||||
|
import tech.easyflow.core.chat.protocol.ChatDomain;
|
||||||
|
import tech.easyflow.core.chat.protocol.ChatType;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -23,6 +26,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent 运行态注册表。
|
* Agent 运行态注册表。
|
||||||
@@ -36,6 +40,8 @@ public class AgentRunRegistry {
|
|||||||
private final Map<String, String> sessionRuns = new ConcurrentHashMap<>();
|
private final Map<String, String> sessionRuns = new ConcurrentHashMap<>();
|
||||||
private final Map<String, String> resumeTokenIndex = new ConcurrentHashMap<>();
|
private final Map<String, String> resumeTokenIndex = new ConcurrentHashMap<>();
|
||||||
private final Map<String, Set<String>> requestTokens = new ConcurrentHashMap<>();
|
private final Map<String, Set<String>> requestTokens = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, ApprovalTarget> approvalTargets = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, Set<String>> requestApprovals = new ConcurrentHashMap<>();
|
||||||
private final Map<String, RunOwner> owners = new ConcurrentHashMap<>();
|
private final Map<String, RunOwner> owners = new ConcurrentHashMap<>();
|
||||||
private AgentRuntimeRouteRegistry routeRegistry;
|
private AgentRuntimeRouteRegistry routeRegistry;
|
||||||
|
|
||||||
@@ -102,6 +108,16 @@ public class AgentRunRegistry {
|
|||||||
return requestId == null ? null : runs.get(requestId);
|
return requestId == null ? null : runs.get(requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断指定 Runtime 会话当前是否仍有活动运行。
|
||||||
|
*
|
||||||
|
* @param sessionId Runtime 会话 ID
|
||||||
|
* @return 有活动运行时为 true
|
||||||
|
*/
|
||||||
|
public boolean hasActiveSession(String sessionId) {
|
||||||
|
return sessionId != null && sessionRuns.containsKey(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取消并移除指定会话当前活跃运行。
|
* 取消并移除指定会话当前活跃运行。
|
||||||
*
|
*
|
||||||
@@ -174,6 +190,121 @@ public class AgentRunRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为内部恢复目标注册随机且不可推测的公开审批 ID。
|
||||||
|
*
|
||||||
|
* @param requestId 内部请求 ID
|
||||||
|
* @param resumeToken 内部恢复令牌
|
||||||
|
* @return 公开审批 ID
|
||||||
|
*/
|
||||||
|
public String registerApproval(String requestId, String resumeToken) {
|
||||||
|
if (requestId == null || requestId.isBlank() || resumeToken == null || resumeToken.isBlank()) {
|
||||||
|
throw new BusinessException("Agent 审批恢复目标不能为空");
|
||||||
|
}
|
||||||
|
String approvalId = "approval_" + UUID.randomUUID();
|
||||||
|
ApprovalTarget target = new ApprovalTarget(requestId, resumeToken);
|
||||||
|
approvalTargets.put(approvalId, target);
|
||||||
|
requestApprovals.computeIfAbsent(requestId, ignored -> ConcurrentHashMap.newKeySet()).add(approvalId);
|
||||||
|
if (routeRegistry != null) {
|
||||||
|
routeRegistry.registerApproval(approvalId, requestId, resumeToken);
|
||||||
|
}
|
||||||
|
return approvalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析公开审批 ID 对应的内部恢复目标。
|
||||||
|
*
|
||||||
|
* @param approvalId 公开审批 ID
|
||||||
|
* @return 内部恢复目标
|
||||||
|
*/
|
||||||
|
public ApprovalTarget resolveApproval(String approvalId) {
|
||||||
|
if (approvalId == null || approvalId.isBlank()) {
|
||||||
|
throw new BusinessException("Agent 审批 ID 不能为空");
|
||||||
|
}
|
||||||
|
ApprovalTarget local = approvalTargets.get(approvalId);
|
||||||
|
if (local != null) {
|
||||||
|
return local;
|
||||||
|
}
|
||||||
|
AgentApprovalRoute route = routeRegistry == null ? null : routeRegistry.findApproval(approvalId);
|
||||||
|
if (route == null || route.getRequestId() == null || route.getResumeToken() == null) {
|
||||||
|
throw new BusinessException("Agent 审批请求不存在或已失效");
|
||||||
|
}
|
||||||
|
return new ApprovalTarget(route.getRequestId(), route.getResumeToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据当前节点的内部恢复目标查询公开审批 ID。
|
||||||
|
*
|
||||||
|
* @param requestId 内部请求 ID
|
||||||
|
* @param resumeToken 内部恢复令牌
|
||||||
|
* @return 公开审批 ID,不存在时返回 null
|
||||||
|
*/
|
||||||
|
public String findApprovalId(String requestId, String resumeToken) {
|
||||||
|
Set<String> approvals = requestApprovals.get(requestId);
|
||||||
|
if (approvals == null || approvals.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (String approvalId : approvals) {
|
||||||
|
ApprovalTarget target = approvalTargets.get(approvalId);
|
||||||
|
if (target != null && java.util.Objects.equals(resumeToken, target.resumeToken())) {
|
||||||
|
return approvalId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验本节点审批目标归属。
|
||||||
|
*
|
||||||
|
* @param approvalId 公开审批 ID
|
||||||
|
* @param userId 当前用户 ID
|
||||||
|
*/
|
||||||
|
public void assertApprovalOwner(String approvalId, String userId) {
|
||||||
|
ApprovalTarget target = resolveApproval(approvalId);
|
||||||
|
if (runs.containsKey(target.requestId())) {
|
||||||
|
assertOwner(target.requestId(), userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理已经消费的公开审批 ID。
|
||||||
|
*
|
||||||
|
* @param approvalId 公开审批 ID
|
||||||
|
*/
|
||||||
|
public void removeApproval(String approvalId) {
|
||||||
|
ApprovalTarget target = approvalTargets.remove(approvalId);
|
||||||
|
if (target != null) {
|
||||||
|
Set<String> approvals = requestApprovals.get(target.requestId());
|
||||||
|
if (approvals != null) {
|
||||||
|
approvals.remove(approvalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (routeRegistry != null) {
|
||||||
|
routeRegistry.removeApproval(approvalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在恢复 Runtime 前向原连接发送审批决议。
|
||||||
|
*
|
||||||
|
* @param approvalId 公开审批 ID
|
||||||
|
* @param status 决议状态
|
||||||
|
* @param reason 拒绝原因
|
||||||
|
* @return 本节点存在连接且发送成功时为 true
|
||||||
|
*/
|
||||||
|
public boolean emitApprovalResolved(String approvalId, String status, String reason) {
|
||||||
|
ApprovalTarget target = resolveApproval(approvalId);
|
||||||
|
AgentRunContext context = runs.get(target.requestId());
|
||||||
|
if (context == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Map<String, Object> payload = new java.util.LinkedHashMap<>();
|
||||||
|
payload.put("approvalId", approvalId);
|
||||||
|
payload.put("status", status);
|
||||||
|
payload.put("reason", reason);
|
||||||
|
return context.runOutput().emitViewEvent(ChatDomain.TOOL, ChatType.FORM_CANCEL, payload);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 运行结束后移除运行态。
|
* 运行结束后移除运行态。
|
||||||
*
|
*
|
||||||
@@ -199,6 +330,15 @@ public class AgentRunRegistry {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Set<String> approvals = requestApprovals.remove(requestId);
|
||||||
|
if (approvals != null) {
|
||||||
|
approvals.forEach(approvalId -> {
|
||||||
|
approvalTargets.remove(approvalId);
|
||||||
|
if (routeRegistry != null) {
|
||||||
|
routeRegistry.removeApproval(approvalId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
if (routeRegistry != null) {
|
if (routeRegistry != null) {
|
||||||
routeRegistry.removeRun(requestId);
|
routeRegistry.removeRun(requestId);
|
||||||
}
|
}
|
||||||
@@ -368,6 +508,15 @@ public class AgentRunRegistry {
|
|||||||
public record RunOwner(String agentId, String sessionId, String userId) {
|
public record RunOwner(String agentId, String sessionId, String userId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公开审批 ID 解析后的内部恢复目标。
|
||||||
|
*
|
||||||
|
* @param requestId 内部请求 ID
|
||||||
|
* @param resumeToken 内部恢复令牌
|
||||||
|
*/
|
||||||
|
public record ApprovalTarget(String requestId, String resumeToken) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单机内存运行态。
|
* 单机内存运行态。
|
||||||
*
|
*
|
||||||
@@ -377,7 +526,7 @@ public class AgentRunRegistry {
|
|||||||
private final String requestId;
|
private final String requestId;
|
||||||
private final String sessionId;
|
private final String sessionId;
|
||||||
private final AgentRuntime runtime;
|
private final AgentRuntime runtime;
|
||||||
private final ChatSseEmitter chatSseEmitter;
|
private final AgentRunOutput runOutput;
|
||||||
private final ChatRuntimeContext chatContext;
|
private final ChatRuntimeContext chatContext;
|
||||||
private final StringBuilder answer;
|
private final StringBuilder answer;
|
||||||
private final ChatAssistantAccumulator assistantAccumulator;
|
private final ChatAssistantAccumulator assistantAccumulator;
|
||||||
@@ -397,7 +546,7 @@ public class AgentRunRegistry {
|
|||||||
* @param requestId 请求 ID
|
* @param requestId 请求 ID
|
||||||
* @param sessionId 会话 ID
|
* @param sessionId 会话 ID
|
||||||
* @param runtime 有状态运行时
|
* @param runtime 有状态运行时
|
||||||
* @param chatSseEmitter SSE 连接
|
* @param runOutput SSE 连接
|
||||||
* @param chatContext 聊天上下文
|
* @param chatContext 聊天上下文
|
||||||
* @param answer 助手正文累计缓冲
|
* @param answer 助手正文累计缓冲
|
||||||
* @param assistantAccumulator 助手结构化累计器
|
* @param assistantAccumulator 助手结构化累计器
|
||||||
@@ -411,7 +560,7 @@ public class AgentRunRegistry {
|
|||||||
public AgentRunContext(String requestId,
|
public AgentRunContext(String requestId,
|
||||||
String sessionId,
|
String sessionId,
|
||||||
AgentRuntime runtime,
|
AgentRuntime runtime,
|
||||||
ChatSseEmitter chatSseEmitter,
|
AgentRunOutput runOutput,
|
||||||
ChatRuntimeContext chatContext,
|
ChatRuntimeContext chatContext,
|
||||||
StringBuilder answer,
|
StringBuilder answer,
|
||||||
ChatAssistantAccumulator assistantAccumulator,
|
ChatAssistantAccumulator assistantAccumulator,
|
||||||
@@ -425,7 +574,7 @@ public class AgentRunRegistry {
|
|||||||
this.requestId = requestId;
|
this.requestId = requestId;
|
||||||
this.sessionId = sessionId;
|
this.sessionId = sessionId;
|
||||||
this.runtime = runtime;
|
this.runtime = runtime;
|
||||||
this.chatSseEmitter = chatSseEmitter;
|
this.runOutput = runOutput;
|
||||||
this.chatContext = chatContext;
|
this.chatContext = chatContext;
|
||||||
this.answer = answer;
|
this.answer = answer;
|
||||||
this.assistantAccumulator = assistantAccumulator;
|
this.assistantAccumulator = assistantAccumulator;
|
||||||
@@ -465,6 +614,15 @@ public class AgentRunRegistry {
|
|||||||
return owner;
|
return owner;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取协议无关运行输出。
|
||||||
|
*
|
||||||
|
* @return 运行输出
|
||||||
|
*/
|
||||||
|
public AgentRunOutput runOutput() {
|
||||||
|
return runOutput;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取运行事件处理器。
|
* 获取运行事件处理器。
|
||||||
*
|
*
|
||||||
@@ -550,8 +708,8 @@ public class AgentRunRegistry {
|
|||||||
*/
|
*/
|
||||||
public void cancelAndComplete() {
|
public void cancelAndComplete() {
|
||||||
cancel();
|
cancel();
|
||||||
if (finished.compareAndSet(false, true) && chatSseEmitter != null) {
|
if (finished.compareAndSet(false, true) && runOutput != null) {
|
||||||
chatSseEmitter.complete();
|
runOutput.complete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,9 @@ package tech.easyflow.agent.runtime;
|
|||||||
|
|
||||||
import com.easyagents.agent.runtime.AgentDefinition;
|
import com.easyagents.agent.runtime.AgentDefinition;
|
||||||
import com.easyagents.agent.runtime.AgentExecutionOptions;
|
import com.easyagents.agent.runtime.AgentExecutionOptions;
|
||||||
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
|
import com.easyagents.agent.runtime.AgentRuntimeContext;
|
||||||
|
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||||
|
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
||||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
|
||||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgePolicy;
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgePolicy;
|
||||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
|
||||||
@@ -11,28 +13,39 @@ import com.easyagents.agent.runtime.memory.AgentMemoryCompressionParameter;
|
|||||||
import com.easyagents.agent.runtime.memory.AgentMemoryPolicy;
|
import com.easyagents.agent.runtime.memory.AgentMemoryPolicy;
|
||||||
import com.easyagents.agent.runtime.memory.AgentMemoryType;
|
import com.easyagents.agent.runtime.memory.AgentMemoryType;
|
||||||
import com.easyagents.agent.runtime.mcp.McpSpec;
|
import com.easyagents.agent.runtime.mcp.McpSpec;
|
||||||
import com.easyagents.agent.runtime.mcp.McpTransportType;
|
import com.easyagents.agent.runtime.mcp.McpToolManifestEntry;
|
||||||
import com.easyagents.agent.runtime.model.AgentGenerationOptions;
|
import com.easyagents.agent.runtime.model.AgentGenerationOptions;
|
||||||
import com.easyagents.agent.runtime.model.AgentModelSpec;
|
import com.easyagents.agent.runtime.model.AgentModelSpec;
|
||||||
|
import com.easyagents.agent.runtime.tool.AgentToolSpec;
|
||||||
import com.easyagents.agent.runtime.tool.AgentToolCategory;
|
import com.easyagents.agent.runtime.tool.AgentToolCategory;
|
||||||
import com.easyagents.agent.runtime.tool.AgentToolResult;
|
import com.easyagents.agent.runtime.tool.AgentToolResult;
|
||||||
import com.easyagents.agent.runtime.tool.AgentToolSpec;
|
import com.easyagents.agent.runtime.tool.AgentToolVisibility;
|
||||||
|
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolAdapter;
|
||||||
|
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolSpec;
|
||||||
|
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolType;
|
||||||
|
import com.easyagents.agent.runtime.tool.operate.ControlledShellTool;
|
||||||
|
import com.easyagents.agent.runtime.tool.operate.WorkspaceQuotaLimits;
|
||||||
|
import com.easyagents.agent.runtime.tool.operate.WorkspaceQuotaHook;
|
||||||
import com.easyagents.core.document.Document;
|
import com.easyagents.core.document.Document;
|
||||||
import com.easyagents.core.model.chat.tool.Parameter;
|
import io.agentscope.core.tool.Toolkit;
|
||||||
import com.easyagents.core.model.chat.tool.Tool;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import tech.easyflow.agent.entity.Agent;
|
import tech.easyflow.agent.entity.Agent;
|
||||||
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||||
import tech.easyflow.agent.entity.AgentToolBinding;
|
import tech.easyflow.agent.config.AgentBuiltinToolsConfig;
|
||||||
import tech.easyflow.agent.enums.AgentToolType;
|
import tech.easyflow.agent.config.AgentBuiltinToolsConfigResolver;
|
||||||
|
import tech.easyflow.agent.config.AgentShellProperties;
|
||||||
|
import tech.easyflow.agent.config.AgentWorkspaceProperties;
|
||||||
|
import tech.easyflow.agent.runtime.artifact.AgentArtifactOperationException;
|
||||||
|
import tech.easyflow.agent.runtime.artifact.AgentArtifactService;
|
||||||
|
import tech.easyflow.agent.runtime.artifact.AgentArtifactView;
|
||||||
|
import tech.easyflow.agent.runtime.workspace.AgentWorkspaceResolver;
|
||||||
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompilation;
|
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompilation;
|
||||||
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler;
|
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler;
|
||||||
import tech.easyflow.ai.easyagents.tool.ChatToolNameHelper;
|
import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeCompilation;
|
||||||
import tech.easyflow.ai.easyagents.tool.WorkflowTool;
|
import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeCompiler;
|
||||||
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
|
||||||
import tech.easyflow.ai.entity.*;
|
import tech.easyflow.ai.entity.*;
|
||||||
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
|
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
|
||||||
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
|
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
|
||||||
@@ -41,9 +54,8 @@ import tech.easyflow.common.web.exceptions.BusinessException;
|
|||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,22 +71,29 @@ public class AgentRuntimeCompiler {
|
|||||||
* EasyFlow 仅按 Token 阈值触发压缩,消息数阈值固定为不可达上限。
|
* EasyFlow 仅按 Token 阈值触发压缩,消息数阈值固定为不可达上限。
|
||||||
*/
|
*/
|
||||||
private static final int DISABLED_MESSAGE_COMPRESSION_THRESHOLD = Integer.MAX_VALUE;
|
private static final int DISABLED_MESSAGE_COMPRESSION_THRESHOLD = Integer.MAX_VALUE;
|
||||||
private static final Pattern MCP_INPUT_PATTERN = Pattern.compile("\\$\\{input:([A-Za-z0-9_.-]+)}");
|
private static final int MAX_RUNTIME_TOOL_COUNT = 128;
|
||||||
|
private static final long MAX_RUNTIME_SCHEMA_BYTES = 2L * 1024L * 1024L;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private ModelService modelService;
|
private ModelService modelService;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowService workflowService;
|
|
||||||
@Resource
|
|
||||||
private PluginItemService pluginItemService;
|
|
||||||
@Resource
|
|
||||||
private McpService mcpService;
|
|
||||||
@Resource
|
|
||||||
private DocumentCollectionService documentCollectionService;
|
private DocumentCollectionService documentCollectionService;
|
||||||
@Resource
|
@Resource
|
||||||
private ObjectMapper objectMapper;
|
private ObjectMapper objectMapper;
|
||||||
@Resource
|
@Resource
|
||||||
private AgentToolRuntimeCompiler agentToolRuntimeCompiler;
|
private AgentToolRuntimeCompiler agentToolRuntimeCompiler;
|
||||||
|
@Resource
|
||||||
|
private AgentSkillRuntimeCompiler agentSkillRuntimeCompiler;
|
||||||
|
@Resource
|
||||||
|
private AgentBuiltinToolsConfigResolver agentBuiltinToolsConfigResolver;
|
||||||
|
@Resource
|
||||||
|
private AgentWorkspaceResolver agentWorkspaceResolver;
|
||||||
|
@Resource
|
||||||
|
private AgentWorkspaceProperties agentWorkspaceProperties;
|
||||||
|
@Resource
|
||||||
|
private AgentShellProperties agentShellProperties;
|
||||||
|
@Resource
|
||||||
|
private AgentArtifactService agentArtifactService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 编译 Agent 运行时定义和调用器。
|
* 编译 Agent 运行时定义和调用器。
|
||||||
@@ -99,10 +118,39 @@ public class AgentRuntimeCompiler {
|
|||||||
bundle.setDefinition(definition);
|
bundle.setDefinition(definition);
|
||||||
|
|
||||||
compileTools(agent, definition, bundle);
|
compileTools(agent, definition, bundle);
|
||||||
|
if (agentBuiltinToolsConfigResolver != null) {
|
||||||
|
validateBuiltinTools(definition,
|
||||||
|
agentBuiltinToolsConfigResolver.resolvePublishedRuntime(agent.getExecutionConfigJson()));
|
||||||
|
}
|
||||||
compileKnowledge(agent, definition, bundle);
|
compileKnowledge(agent, definition, bundle);
|
||||||
return bundle;
|
return bundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为真实运行会话编译并附加会话隔离的内置工具。
|
||||||
|
*
|
||||||
|
* <p>该重载先复用发布校验编译,再使用可信 RuntimeContext 创建当前会话工作区。</p>
|
||||||
|
*
|
||||||
|
* @param agent Agent 运行视图
|
||||||
|
* @param runtimeContext 可信运行上下文
|
||||||
|
* @param draftMode 是否为草稿试运行
|
||||||
|
* @return 带会话操作工具及 Artifact 调用器的运行时编译结果
|
||||||
|
*/
|
||||||
|
public AgentRuntimeBundle compile(Agent agent,
|
||||||
|
AgentRuntimeContext runtimeContext,
|
||||||
|
boolean draftMode) {
|
||||||
|
AgentRuntimeBundle bundle = compile(agent);
|
||||||
|
// 仅兼容未经过 Spring 装配的历史单元测试桩;生产 Bean 必须完整注入以下依赖。
|
||||||
|
if (agentBuiltinToolsConfigResolver == null) {
|
||||||
|
return bundle;
|
||||||
|
}
|
||||||
|
AgentBuiltinToolsConfig config = draftMode
|
||||||
|
? agentBuiltinToolsConfigResolver.resolveDraftRuntime(agent.getExecutionConfigJson())
|
||||||
|
: agentBuiltinToolsConfigResolver.resolvePublishedRuntime(agent.getExecutionConfigJson());
|
||||||
|
attachBuiltinTools(agent, runtimeContext, draftMode, config, bundle);
|
||||||
|
return bundle;
|
||||||
|
}
|
||||||
|
|
||||||
private AgentModelSpec buildModelSpec(Agent agent) {
|
private AgentModelSpec buildModelSpec(Agent agent) {
|
||||||
Model model = modelService.getModelInstance(agent.getModelId());
|
Model model = modelService.getModelInstance(agent.getModelId());
|
||||||
if (model == null) {
|
if (model == null) {
|
||||||
@@ -211,172 +259,336 @@ public class AgentRuntimeCompiler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void compileTools(Agent agent, AgentDefinition definition, AgentRuntimeBundle bundle) {
|
private void compileTools(Agent agent, AgentDefinition definition, AgentRuntimeBundle bundle) {
|
||||||
AgentToolRuntimeCompilation compilation = agentToolRuntimeCompiler.compile(agent);
|
AgentToolRuntimeCompilation direct = agentToolRuntimeCompiler.compile(agent);
|
||||||
definition.setToolSpecs(compilation.getToolSpecs());
|
AgentSkillRuntimeCompilation skills = agentSkillRuntimeCompiler.compile(agent);
|
||||||
definition.setMcpSpecs(compilation.getMcpSpecs());
|
List<AgentToolSpec> toolSpecs = new ArrayList<>(direct.getToolSpecs());
|
||||||
bundle.setToolInvokers(compilation.getToolInvokers());
|
Set<String> names = new LinkedHashSet<>();
|
||||||
|
direct.getToolSpecs().forEach(spec -> names.add(spec.getName()));
|
||||||
|
for (AgentToolSpec spec : skills.getToolSpecs()) {
|
||||||
|
if (!names.add(spec.getName())) {
|
||||||
|
throw new BusinessException("Agent Tool 运行名冲突:" + spec.getName());
|
||||||
}
|
}
|
||||||
|
toolSpecs.add(spec);
|
||||||
private Tool buildTool(AgentToolBinding binding) {
|
|
||||||
AgentToolType type = AgentToolType.from(binding.getToolType());
|
|
||||||
if (type == AgentToolType.WORKFLOW) {
|
|
||||||
Workflow workflow = snapshotOrPublishedWorkflow(binding);
|
|
||||||
if (workflow == null) {
|
|
||||||
throw new BusinessException("绑定工作流不存在");
|
|
||||||
}
|
}
|
||||||
return new WorkflowTool(
|
List<McpSpec> mcpSpecs = new ArrayList<>(direct.getMcpSpecs());
|
||||||
workflow,
|
mcpSpecs.addAll(skills.getMcpSpecs());
|
||||||
true,
|
assertToolBudget(toolSpecs, mcpSpecs);
|
||||||
PublishedWorkflowDefinitionIds.published(String.valueOf(workflow.getId()))
|
Map<String, com.easyagents.agent.runtime.tool.AgentToolInvoker> invokers =
|
||||||
);
|
new LinkedHashMap<>(direct.getToolInvokers());
|
||||||
}
|
skills.getToolInvokers().forEach((name, invoker) -> {
|
||||||
if (type == AgentToolType.PLUGIN) {
|
if (invokers.putIfAbsent(name, invoker) != null) {
|
||||||
PluginItem pluginItem = snapshotOrCurrentPlugin(binding);
|
throw new BusinessException("Agent Tool 运行名冲突:" + name);
|
||||||
if (pluginItem == null) {
|
|
||||||
throw new BusinessException("绑定插件不存在");
|
|
||||||
}
|
|
||||||
return pluginItem.toFunction();
|
|
||||||
}
|
|
||||||
throw new BusinessException("不支持的 Agent 工具类型:" + type.name());
|
|
||||||
}
|
|
||||||
|
|
||||||
private McpSpec buildMcpSpec(AgentToolBinding binding) {
|
|
||||||
Mcp mcp = snapshotOrCurrentMcp(binding);
|
|
||||||
if (mcp == null) {
|
|
||||||
throw new BusinessException("绑定 MCP 不存在");
|
|
||||||
}
|
|
||||||
Map.Entry<String, Map<String, Object>> server = firstMcpServer(mcp);
|
|
||||||
Map<String, Object> serverConfig = server.getValue();
|
|
||||||
McpTransportType transportType = parseMcpTransportType(mcp, serverConfig);
|
|
||||||
|
|
||||||
McpSpec spec = new McpSpec();
|
|
||||||
spec.setName(mcpRuntimeName(mcp));
|
|
||||||
spec.setDescription(firstNonBlank(mcp.getDescription(), mcp.getTitle()));
|
|
||||||
spec.setTransportType(transportType);
|
|
||||||
spec.setCommand(resolveMcpInput(stringValue(serverConfig, "command", null)));
|
|
||||||
spec.setArgs(resolveMcpInputs(stringListValue(serverConfig, "args")));
|
|
||||||
spec.setEnv(resolveMcpInputMap(stringMapValue(serverConfig, "env")));
|
|
||||||
spec.setUrl(resolveMcpInput(stringValue(serverConfig, "url", null)));
|
|
||||||
spec.setHeaders(resolveMcpInputMap(stringMapValue(serverConfig, "headers")));
|
|
||||||
spec.setQueryParams(resolveMcpInputMap(stringMapValue(serverConfig, "queryParams")));
|
|
||||||
Duration timeout = durationValue(serverConfig, "timeout");
|
|
||||||
if (timeout != null) {
|
|
||||||
spec.setTimeout(timeout);
|
|
||||||
}
|
|
||||||
Duration initializationTimeout = durationValue(serverConfig, "initializationTimeout");
|
|
||||||
if (initializationTimeout != null) {
|
|
||||||
spec.setInitializationTimeout(initializationTimeout);
|
|
||||||
}
|
|
||||||
spec.setGroupName(mcpRuntimeName(mcp));
|
|
||||||
spec.setApprovalRequired(Boolean.TRUE.equals(mcp.getApprovalRequired()));
|
|
||||||
spec.setApprovalRequest(buildMcpApprovalRequest(mcp));
|
|
||||||
spec.setToolNamePrefix(mcpRuntimeToolPrefix(mcp.getId()));
|
|
||||||
spec.getMetadata().put("toolType", AgentToolType.MCP.name());
|
|
||||||
spec.getMetadata().put("mcpId", String.valueOf(mcp.getId()));
|
|
||||||
spec.getMetadata().put("mcpTitle", mcp.getTitle());
|
|
||||||
spec.getMetadata().put("serverName", server.getKey());
|
|
||||||
return spec;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void applyMcpToolBinding(McpSpec spec, AgentToolBinding binding) {
|
|
||||||
if (Boolean.TRUE.equals(binding.getHitlEnabled())) {
|
|
||||||
spec.setApprovalRequired(true);
|
|
||||||
spec.setApprovalRequest(buildBindingApprovalRequest(binding));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private AgentToolApprovalRequest buildMcpApprovalRequest(Mcp mcp) {
|
|
||||||
AgentToolApprovalRequest request = new AgentToolApprovalRequest();
|
|
||||||
request.setApprovalPrompt("是否批准执行 MCP 工具:" + firstNonBlank(mcp.getTitle(), mcpRuntimeName(mcp)));
|
|
||||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
|
||||||
metadata.put("toolType", AgentToolType.MCP.name());
|
|
||||||
metadata.put("mcpId", String.valueOf(mcp.getId()));
|
|
||||||
metadata.put("mcpTitle", mcp.getTitle());
|
|
||||||
request.setMetadata(metadata);
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
private AgentToolApprovalRequest buildBindingApprovalRequest(AgentToolBinding binding) {
|
|
||||||
AgentToolApprovalRequest request = new AgentToolApprovalRequest();
|
|
||||||
request.setApprovalPrompt(stringValue(binding.getHitlConfigJson(), "prompt", "是否批准执行 MCP 工具"));
|
|
||||||
Map<String, Object> metadata = sanitizedHitlMetadata(binding.getHitlConfigJson());
|
|
||||||
metadata.put("toolType", binding.getToolType());
|
|
||||||
metadata.put("bindingId", binding.getId());
|
|
||||||
metadata.put("targetId", binding.getTargetId());
|
|
||||||
request.setMetadata(metadata);
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
private AgentToolSpec toToolSpec(Tool tool, AgentToolBinding binding) {
|
|
||||||
AgentToolSpec spec = new AgentToolSpec();
|
|
||||||
String name = resolveRuntimeToolName(tool, binding);
|
|
||||||
spec.setName(name);
|
|
||||||
spec.setDescription(safeDescription(tool == null ? null : tool.getDescription()));
|
|
||||||
spec.setCategory(AgentToolCategory.valueOf(AgentToolType.from(binding.getToolType()).name()));
|
|
||||||
spec.setParametersSchema(toSchema(tool == null ? null : tool.getParameters()));
|
|
||||||
spec.setApprovalRequired(Boolean.TRUE.equals(binding.getHitlEnabled()));
|
|
||||||
if (Boolean.TRUE.equals(binding.getHitlEnabled())) {
|
|
||||||
AgentToolApprovalRequest request = new AgentToolApprovalRequest();
|
|
||||||
request.setApprovalPrompt(stringValue(binding.getHitlConfigJson(), "prompt", "是否批准执行工具:" + name));
|
|
||||||
Map<String, Object> metadata = sanitizedHitlMetadata(binding.getHitlConfigJson());
|
|
||||||
metadata.put("toolType", binding.getToolType());
|
|
||||||
metadata.put("bindingId", binding.getId());
|
|
||||||
metadata.put("targetId", binding.getTargetId());
|
|
||||||
request.setMetadata(metadata);
|
|
||||||
spec.setApprovalRequest(request);
|
|
||||||
}
|
|
||||||
spec.getMetadata().put("bindingId", binding.getId());
|
|
||||||
spec.getMetadata().put("targetId", binding.getTargetId());
|
|
||||||
return spec;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> sanitizedHitlMetadata(Map<String, Object> config) {
|
|
||||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
|
||||||
if (config != null) {
|
|
||||||
config.forEach((key, value) -> {
|
|
||||||
if (!isHitlPromptKey(key)) {
|
|
||||||
metadata.put(key, value);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
definition.setToolSpecs(toolSpecs);
|
||||||
return metadata;
|
definition.setMcpSpecs(mcpSpecs);
|
||||||
|
definition.setSkillBoxSpec(skills.getSkillBoxSpec());
|
||||||
|
bundle.setToolInvokers(invokers);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isHitlPromptKey(String key) {
|
private void validateBuiltinTools(AgentDefinition definition, AgentBuiltinToolsConfig config) {
|
||||||
if (key == null) {
|
Set<String> builtinNames = builtinToolNames(config);
|
||||||
return false;
|
assertNoBuiltinNameConflict(definition, builtinNames);
|
||||||
|
List<AgentToolSpec> specs = new ArrayList<>(definition.getToolSpecs());
|
||||||
|
specs.addAll(buildOperateBudgetSpecs(config));
|
||||||
|
if (config.artifactPublish().enabled()) {
|
||||||
|
specs.add(buildArtifactPublishSpec(config.artifactPublish()));
|
||||||
}
|
}
|
||||||
String normalized = key.trim();
|
assertToolBudget(specs, definition.getMcpSpecs());
|
||||||
return "prompt".equalsIgnoreCase(normalized)
|
|
||||||
|| "question".equalsIgnoreCase(normalized)
|
|
||||||
|| "approvalPrompt".equalsIgnoreCase(normalized);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private AgentToolResult invokeTool(Tool tool, Map<String, Object> arguments) {
|
private void attachBuiltinTools(Agent agent,
|
||||||
String toolName = tool == null ? null : tool.getName();
|
AgentRuntimeContext runtimeContext,
|
||||||
LOG.info("Agent tool invoke started, toolName={}, arguments={}", toolName, arguments);
|
boolean draftMode,
|
||||||
|
AgentBuiltinToolsConfig config,
|
||||||
|
AgentRuntimeBundle bundle) {
|
||||||
|
if (runtimeContext == null || runtimeContext.getTenantId() == null
|
||||||
|
|| runtimeContext.getSessionId() == null) {
|
||||||
|
throw new BusinessException("Agent 内置工具运行上下文不完整");
|
||||||
|
}
|
||||||
|
validateBuiltinTools(bundle.getDefinition(), config);
|
||||||
|
if (builtinToolNames(config).isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Path workspace;
|
||||||
try {
|
try {
|
||||||
Object result = tool.invoke(arguments == null ? Map.of() : arguments);
|
workspace = agentWorkspaceResolver.resolve(
|
||||||
String resultText = result == null ? "" : String.valueOf(result);
|
new BigInteger(runtimeContext.getTenantId()), agent.getId(), runtimeContext.getSessionId());
|
||||||
LOG.info("Agent tool invoke completed, toolName={}, result={}", toolName, truncate(resultText));
|
} catch (NumberFormatException error) {
|
||||||
return AgentToolResult.success(resultText);
|
throw new BusinessException("Agent 内置工具租户标识不合法");
|
||||||
} catch (Exception e) {
|
}
|
||||||
LOG.error("Agent tool invoke failed, toolName={}, message={}", toolName, e.getMessage(), e);
|
WorkspaceQuotaLimits quota = workspaceQuota();
|
||||||
return AgentToolResult.failure(e.getMessage() == null ? "工具执行失败" : e.getMessage());
|
List<AgentOperateToolSpec> operateSpecs = new ArrayList<>();
|
||||||
|
addOperateSpec(operateSpecs, AgentOperateToolType.READ_FILE, config.read(), workspace, quota);
|
||||||
|
addOperateSpec(operateSpecs, AgentOperateToolType.WRITE_FILE, config.write(), workspace, quota);
|
||||||
|
addOperateSpec(operateSpecs, AgentOperateToolType.PATCH, config.patch(), workspace, quota);
|
||||||
|
addOperateSpec(operateSpecs, AgentOperateToolType.SHELL, config.shell(), workspace, quota);
|
||||||
|
bundle.getDefinition().setOperateToolSpecs(operateSpecs);
|
||||||
|
if (config.artifactPublish().enabled()) {
|
||||||
|
AgentToolSpec spec = buildArtifactPublishSpec(config.artifactPublish());
|
||||||
|
bundle.getDefinition().getToolSpecs().add(spec);
|
||||||
|
if (bundle.getToolInvokers().putIfAbsent(spec.getName(),
|
||||||
|
(arguments, context) -> publishArtifact(arguments, context, workspace, draftMode)) != null) {
|
||||||
|
throw new BusinessException("Agent Tool 运行名冲突:" + spec.getName());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolveRuntimeToolName(Tool tool, AgentToolBinding binding) {
|
private List<AgentToolSpec> buildOperateBudgetSpecs(AgentBuiltinToolsConfig config) {
|
||||||
String bindingName = binding == null ? null : binding.getToolName();
|
if (agentWorkspaceResolver == null || agentWorkspaceResolver.getRealRoot() == null) {
|
||||||
if (ChatToolNameHelper.isSafeToolName(bindingName)) {
|
throw new BusinessException("Agent 工作区尚未初始化");
|
||||||
return bindingName;
|
|
||||||
}
|
}
|
||||||
String toolName = tool == null ? null : tool.getName();
|
List<AgentOperateToolSpec> operateSpecs = new ArrayList<>();
|
||||||
if (ChatToolNameHelper.isSafeToolName(toolName)) {
|
Path root = agentWorkspaceResolver.getRealRoot();
|
||||||
return toolName;
|
WorkspaceQuotaLimits quota = workspaceQuota();
|
||||||
|
addOperateSpec(operateSpecs, AgentOperateToolType.READ_FILE, config.read(), root, quota);
|
||||||
|
addOperateSpec(operateSpecs, AgentOperateToolType.WRITE_FILE, config.write(), root, quota);
|
||||||
|
addOperateSpec(operateSpecs, AgentOperateToolType.PATCH, config.patch(), root, quota);
|
||||||
|
addOperateSpec(operateSpecs, AgentOperateToolType.SHELL, config.shell(), root, quota);
|
||||||
|
Toolkit toolkit = new Toolkit();
|
||||||
|
List<AgentToolSpec> specs = new AgentOperateToolAdapter().register(operateSpecs, toolkit);
|
||||||
|
for (AgentToolSpec spec : specs) {
|
||||||
|
io.agentscope.core.tool.AgentTool tool = toolkit.getTool(spec.getName());
|
||||||
|
if (tool == null) {
|
||||||
|
throw new BusinessException("Agent 内置工具 Schema 生成失败:" + spec.getName());
|
||||||
|
}
|
||||||
|
spec.setParametersSchema(tool.getParameters());
|
||||||
|
spec.setOutputSchema(tool.getOutputSchema());
|
||||||
|
}
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorkspaceQuotaLimits workspaceQuota() {
|
||||||
|
return new WorkspaceQuotaLimits(
|
||||||
|
agentWorkspaceProperties.getMaxTotalSize().toBytes(),
|
||||||
|
agentWorkspaceProperties.getMaxSingleFileSize().toBytes(),
|
||||||
|
agentWorkspaceProperties.getMaxFileCount(),
|
||||||
|
agentWorkspaceProperties.getMaxReadSize().toBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addOperateSpec(List<AgentOperateToolSpec> target,
|
||||||
|
AgentOperateToolType type,
|
||||||
|
AgentBuiltinToolsConfig.ToolSwitch toolSwitch,
|
||||||
|
Path workspace,
|
||||||
|
WorkspaceQuotaLimits quota) {
|
||||||
|
if (!toolSwitch.enabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AgentOperateToolSpec spec = new AgentOperateToolSpec();
|
||||||
|
spec.setType(type);
|
||||||
|
spec.setBaseDir(workspace.toString());
|
||||||
|
spec.setApprovalRequired(toolSwitch.approvalRequired());
|
||||||
|
spec.setWorkspaceQuotaLimits(quota);
|
||||||
|
spec.setWorkspaceQuotaHook(new WorkspaceQuotaHook() {
|
||||||
|
@Override
|
||||||
|
public void beforeRead(Path workspaceRoot, Path target, long requestedBytes) {
|
||||||
|
agentWorkspaceResolver.touch(workspaceRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void beforeWrite(Path workspaceRoot, Path target, long previousBytes, long resultingBytes) {
|
||||||
|
agentWorkspaceResolver.touch(workspaceRoot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (type == AgentOperateToolType.PATCH) {
|
||||||
|
spec.setPatchMaxSize(agentWorkspaceProperties.getMaxReadSize().toBytes());
|
||||||
|
spec.setPatchMaxFiles(agentWorkspaceProperties.getMaxFileCount());
|
||||||
|
spec.setPatchMaxAffectedBytes(agentWorkspaceProperties.getMaxTotalSize().toBytes());
|
||||||
|
}
|
||||||
|
if (type == AgentOperateToolType.SHELL) {
|
||||||
|
spec.setShellAllowedCommands(ControlledShellTool.DEFAULT_ALLOWED_COMMANDS);
|
||||||
|
spec.setShellDefaultTimeout(agentShellProperties.getDefaultTimeout());
|
||||||
|
spec.setShellMaxTimeout(agentShellProperties.getMaxTimeout());
|
||||||
|
spec.setShellMaxCommandLength(agentShellProperties.getMaxCommandLength());
|
||||||
|
spec.setShellMaxOutputSize(agentShellProperties.getMaxOutputSize().toBytes());
|
||||||
|
spec.setShellMaxConcurrency(agentShellProperties.getMaxConcurrentPerInstance());
|
||||||
|
}
|
||||||
|
target.add(spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentToolSpec buildArtifactPublishSpec(AgentBuiltinToolsConfig.ToolSwitch toolSwitch) {
|
||||||
|
AgentToolSpec spec = new AgentToolSpec();
|
||||||
|
spec.setName("artifact_publish");
|
||||||
|
spec.setDescription("Publish a completed user-facing file from the current Agent workspace as a private "
|
||||||
|
+ "downloadable artifact. You MUST call this tool after creating or updating any final file that "
|
||||||
|
+ "the user expects to receive or download, including DOCX, XLSX, PPTX, PDF, CSV, images, archives, "
|
||||||
|
+ "or source files. Do not finish with only a workspace path. Publish each final deliverable after "
|
||||||
|
+ "validation, use a clear download filename, and publish the updated version again if the file "
|
||||||
|
+ "changes. Do not publish temporary files, intermediate scripts, caches, previews, or internal "
|
||||||
|
+ "working files. If publishing fails, report the failure clearly to the user.");
|
||||||
|
spec.setCategory(AgentToolCategory.CUSTOM);
|
||||||
|
spec.setVisibility(AgentToolVisibility.VISIBLE);
|
||||||
|
spec.setApprovalRequired(toolSwitch.approvalRequired());
|
||||||
|
spec.setParametersSchema(Map.of(
|
||||||
|
"type", "object",
|
||||||
|
"properties", Map.of(
|
||||||
|
"path", Map.of("type", "string", "description",
|
||||||
|
"Workspace-relative path to the completed final file; directories and temporary or intermediate files are not allowed."),
|
||||||
|
"fileName", Map.of("type", "string", "description",
|
||||||
|
"Optional user-facing download name with the correct file extension.")),
|
||||||
|
"required", List.of("path"),
|
||||||
|
"additionalProperties", false));
|
||||||
|
spec.setOutputSchema(Map.of(
|
||||||
|
"type", "object",
|
||||||
|
"properties", Map.of(
|
||||||
|
"schemaVersion", Map.of("type", "integer"),
|
||||||
|
"artifactId", Map.of("type", "string"),
|
||||||
|
"fileName", Map.of("type", "string"),
|
||||||
|
"mimeType", Map.of("type", "string"),
|
||||||
|
"size", Map.of("type", "integer"),
|
||||||
|
"sha256", Map.of("type", "string"),
|
||||||
|
"downloadUrl", Map.of("type", "string"),
|
||||||
|
"status", Map.of("type", "string")),
|
||||||
|
"required", List.of("schemaVersion", "artifactId", "fileName", "mimeType", "size",
|
||||||
|
"sha256", "downloadUrl", "status"),
|
||||||
|
"additionalProperties", false));
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentToolResult publishArtifact(Map<String, Object> arguments,
|
||||||
|
com.easyagents.agent.runtime.tool.AgentToolContext context,
|
||||||
|
Path workspace,
|
||||||
|
boolean draftMode) {
|
||||||
|
try {
|
||||||
|
String path = stringValue(arguments, "path", null);
|
||||||
|
String fileName = stringValue(arguments, "fileName", null);
|
||||||
|
AgentArtifactView artifact = agentArtifactService.publish(
|
||||||
|
workspace, path, fileName,
|
||||||
|
draftMode ? AgentArtifactService.MODE_DRAFT : AgentArtifactService.MODE_FORMAL,
|
||||||
|
context);
|
||||||
|
Map<String, Object> safe = artifact.toMap();
|
||||||
|
AgentRuntimeEvent projection = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_RESULT);
|
||||||
|
projection.setTraceId(context.getTraceId());
|
||||||
|
projection.setSessionId(context.getSessionId());
|
||||||
|
projection.setAgentId(context.getAgentId());
|
||||||
|
projection.setToolCallId(context.getToolCallId());
|
||||||
|
projection.getPayload().put("artifactProjectionOnly", true);
|
||||||
|
projection.getPayload().put("toolName", "artifact_publish");
|
||||||
|
projection.getPayload().put("artifactPublished", safe);
|
||||||
|
context.emitEvent(projection);
|
||||||
|
String json = objectMapper.writeValueAsString(safe);
|
||||||
|
AgentToolResult result = AgentToolResult.success(json);
|
||||||
|
result.setDisplayContent(safe);
|
||||||
|
return result;
|
||||||
|
} catch (AgentArtifactOperationException error) {
|
||||||
|
return artifactFailure(error.getCode(), error.getMessage(), error.isRetryable());
|
||||||
|
} catch (Exception error) {
|
||||||
|
LOG.error("Agent artifact_publish tool failed", error);
|
||||||
|
return artifactFailure("ARTIFACT_PUBLISH_FAILED", "产物发布失败", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentToolResult artifactFailure(String code, String message, boolean retryable) {
|
||||||
|
try {
|
||||||
|
return AgentToolResult.failure(objectMapper.writeValueAsString(Map.of(
|
||||||
|
"code", code, "message", message, "retryable", retryable)));
|
||||||
|
} catch (Exception error) {
|
||||||
|
LOG.error("Serialize Agent artifact failure payload failed, code={}", code, error);
|
||||||
|
return AgentToolResult.failure("{\"code\":\"ARTIFACT_PUBLISH_FAILED\",\"message\":\"产物发布失败\",\"retryable\":true}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> builtinToolNames(AgentBuiltinToolsConfig config) {
|
||||||
|
Set<String> names = new LinkedHashSet<>();
|
||||||
|
if (config.read().enabled()) {
|
||||||
|
names.add(AgentOperateToolAdapter.VIEW_TEXT_FILE_TOOL);
|
||||||
|
names.add(AgentOperateToolAdapter.LIST_DIRECTORY_TOOL);
|
||||||
|
}
|
||||||
|
if (config.write().enabled()) {
|
||||||
|
names.add(AgentOperateToolAdapter.WRITE_TEXT_FILE_TOOL);
|
||||||
|
names.add(AgentOperateToolAdapter.INSERT_TEXT_FILE_TOOL);
|
||||||
|
}
|
||||||
|
if (config.patch().enabled()) {
|
||||||
|
names.add(AgentOperateToolAdapter.APPLY_PATCH_TOOL);
|
||||||
|
}
|
||||||
|
if (config.shell().enabled()) {
|
||||||
|
names.add(AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL);
|
||||||
|
}
|
||||||
|
if (config.artifactPublish().enabled()) {
|
||||||
|
names.add("artifact_publish");
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertNoBuiltinNameConflict(AgentDefinition definition, Set<String> builtinNames) {
|
||||||
|
Set<String> existing = new LinkedHashSet<>();
|
||||||
|
for (AgentToolSpec spec : definition.getToolSpecs()) {
|
||||||
|
existing.add(spec.getName());
|
||||||
|
}
|
||||||
|
for (McpSpec mcp : definition.getMcpSpecs()) {
|
||||||
|
if (mcp.getFrozenToolManifest() != null) {
|
||||||
|
mcp.getFrozenToolManifest().forEach(entry -> existing.add(entry.getName()));
|
||||||
|
}
|
||||||
|
if (mcp.getEnableTools() != null) {
|
||||||
|
existing.addAll(mcp.getEnableTools());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (String name : builtinNames) {
|
||||||
|
if (existing.contains(name)) {
|
||||||
|
throw new BusinessException("Agent Tool 运行名冲突:" + name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对最终合并后的直接 Tool、Skill Tool 与冻结 MCP 清单执行统一预算校验。
|
||||||
|
*
|
||||||
|
* @param toolSpecs 静态 Tool 声明
|
||||||
|
* @param mcpSpecs MCP 声明
|
||||||
|
*/
|
||||||
|
private void assertToolBudget(List<AgentToolSpec> toolSpecs, List<McpSpec> mcpSpecs) {
|
||||||
|
assertToolBudget(toolSpecs, mcpSpecs, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertToolBudget(List<AgentToolSpec> toolSpecs,
|
||||||
|
List<McpSpec> mcpSpecs,
|
||||||
|
int additionalToolCount) {
|
||||||
|
int toolCount = toolSpecs == null ? 0 : toolSpecs.size();
|
||||||
|
toolCount = Math.addExact(toolCount, additionalToolCount);
|
||||||
|
long schemaBytes = 0L;
|
||||||
|
if (toolSpecs != null) {
|
||||||
|
for (AgentToolSpec spec : toolSpecs) {
|
||||||
|
schemaBytes = addSchemaBytes(schemaBytes, spec.getParametersSchema());
|
||||||
|
schemaBytes = addSchemaBytes(schemaBytes, spec.getOutputSchema());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mcpSpecs != null) {
|
||||||
|
for (McpSpec spec : mcpSpecs) {
|
||||||
|
List<McpToolManifestEntry> manifest = spec.getFrozenToolManifest();
|
||||||
|
if (manifest != null && !manifest.isEmpty()) {
|
||||||
|
toolCount = Math.addExact(toolCount, manifest.size());
|
||||||
|
for (McpToolManifestEntry entry : manifest) {
|
||||||
|
schemaBytes = addSchemaBytes(schemaBytes, entry.getInputSchema());
|
||||||
|
schemaBytes = addSchemaBytes(schemaBytes, entry.getOutputSchema());
|
||||||
|
}
|
||||||
|
} else if (spec.getEnableTools() != null && !spec.getEnableTools().isEmpty()) {
|
||||||
|
toolCount = Math.addExact(toolCount, spec.getEnableTools().size());
|
||||||
|
} else {
|
||||||
|
// 历史直接 MCP 尚无冻结清单时至少计为一个动态工具;新 Skill MCP 均必须有清单。
|
||||||
|
toolCount = Math.addExact(toolCount, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (toolCount > MAX_RUNTIME_TOOL_COUNT) {
|
||||||
|
throw new BusinessException("Agent Runtime Tool 数量超过 128 个,请减少直接工具或 Skill 绑定");
|
||||||
|
}
|
||||||
|
if (schemaBytes > MAX_RUNTIME_SCHEMA_BYTES) {
|
||||||
|
throw new BusinessException("Agent Runtime Tool Schema 超过 2 MiB,请减少工具或精简 Schema");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private long addSchemaBytes(long current, Object schema) {
|
||||||
|
try {
|
||||||
|
long total = Math.addExact(current, objectMapper.writeValueAsBytes(schema == null ? Map.of() : schema).length);
|
||||||
|
if (total > MAX_RUNTIME_SCHEMA_BYTES) {
|
||||||
|
throw new BusinessException("Agent Runtime Tool Schema 超过 2 MiB,请减少工具或精简 Schema");
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
} catch (BusinessException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new BusinessException(500, 500, "计算 Agent Tool Schema 预算失败", exception);
|
||||||
}
|
}
|
||||||
BigInteger targetId = binding == null ? null : binding.getTargetId();
|
|
||||||
return ChatToolNameHelper.buildFallbackName("tool", targetId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void compileKnowledge(Agent agent, AgentDefinition definition, AgentRuntimeBundle bundle) {
|
private void compileKnowledge(Agent agent, AgentDefinition definition, AgentRuntimeBundle bundle) {
|
||||||
@@ -502,165 +714,6 @@ public class AgentRuntimeCompiler {
|
|||||||
return text.substring(0, LOG_TEXT_MAX_LENGTH) + "...";
|
return text.substring(0, LOG_TEXT_MAX_LENGTH) + "...";
|
||||||
}
|
}
|
||||||
|
|
||||||
private Workflow snapshotOrPublishedWorkflow(AgentToolBinding binding) {
|
|
||||||
if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) {
|
|
||||||
Workflow workflow = objectMapper.convertValue(binding.getResourceSnapshot(), Workflow.class);
|
|
||||||
workflow.setId(firstNonNull(workflow.getId(), binding.getTargetId()));
|
|
||||||
return workflow;
|
|
||||||
}
|
|
||||||
return workflowService.getPublishedById(binding.getTargetId());
|
|
||||||
}
|
|
||||||
|
|
||||||
private PluginItem snapshotOrCurrentPlugin(AgentToolBinding binding) {
|
|
||||||
if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) {
|
|
||||||
PluginItem pluginItem = objectMapper.convertValue(binding.getResourceSnapshot(), PluginItem.class);
|
|
||||||
pluginItem.setId(firstNonNull(pluginItem.getId(), binding.getTargetId()));
|
|
||||||
return pluginItem;
|
|
||||||
}
|
|
||||||
return pluginItemService.getById(binding.getTargetId());
|
|
||||||
}
|
|
||||||
|
|
||||||
private Mcp snapshotOrCurrentMcp(AgentToolBinding binding) {
|
|
||||||
if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) {
|
|
||||||
Mcp mcp = objectMapper.convertValue(binding.getResourceSnapshot(), Mcp.class);
|
|
||||||
mcp.setId(firstNonNull(mcp.getId(), binding.getTargetId()));
|
|
||||||
return mcp;
|
|
||||||
}
|
|
||||||
return mcpService.getById(binding.getTargetId());
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map.Entry<String, Map<String, Object>> firstMcpServer(Mcp mcp) {
|
|
||||||
Map<String, Object> config = parseMcpConfig(mcp);
|
|
||||||
Map<String, Object> servers = mapValue(config, "mcpServers");
|
|
||||||
if (servers.isEmpty()) {
|
|
||||||
throw new BusinessException("MCP 配置 JSON 中没有找到任何 MCP 服务名称");
|
|
||||||
}
|
|
||||||
Map.Entry<String, Object> first = servers.entrySet().iterator().next();
|
|
||||||
if (!(first.getValue() instanceof Map<?, ?> rawServer)) {
|
|
||||||
throw new BusinessException("MCP 服务配置必须是对象:" + first.getKey());
|
|
||||||
}
|
|
||||||
Map<String, Object> serverConfig = new LinkedHashMap<>();
|
|
||||||
rawServer.forEach((key, value) -> serverConfig.put(String.valueOf(key), value));
|
|
||||||
return Map.entry(first.getKey(), serverConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> parseMcpConfig(Mcp mcp) {
|
|
||||||
String configJson = mcp == null ? null : mcp.getConfigJson();
|
|
||||||
if (configJson == null || configJson.isBlank()) {
|
|
||||||
throw new BusinessException("MCP 配置 JSON 不能为空");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return objectMapper.readValue(configJson, new com.fasterxml.jackson.core.type.TypeReference<>() {});
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new BusinessException("MCP 配置 JSON 格式错误");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private McpTransportType parseMcpTransportType(Mcp mcp, Map<String, Object> serverConfig) {
|
|
||||||
String transport = firstNonBlank(
|
|
||||||
mcp == null ? null : mcp.getTransportType(),
|
|
||||||
stringValue(serverConfig, "transport", null)
|
|
||||||
);
|
|
||||||
return McpTransportType.from(transport);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String mcpRuntimeName(Mcp mcp) {
|
|
||||||
BigInteger id = mcp == null ? null : mcp.getId();
|
|
||||||
return "mcp_" + safeToolNameSegment(id == null ? "unknown" : String.valueOf(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
private String mcpRuntimeToolPrefix(BigInteger mcpId) {
|
|
||||||
return "mcp_" + safeToolNameSegment(String.valueOf(mcpId)) + "_";
|
|
||||||
}
|
|
||||||
|
|
||||||
private String safeToolNameSegment(String value) {
|
|
||||||
String normalized = String.valueOf(value == null ? "" : value).trim()
|
|
||||||
.replaceAll("[^A-Za-z0-9_-]", "_")
|
|
||||||
.replaceAll("_+", "_");
|
|
||||||
if (normalized.isBlank()) {
|
|
||||||
return "tool";
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<String> stringListValue(Map<String, Object> map, String key) {
|
|
||||||
Object value = map == null ? null : map.get(key);
|
|
||||||
if (value == null) {
|
|
||||||
return new ArrayList<>();
|
|
||||||
}
|
|
||||||
if (value instanceof Collection<?> collection) {
|
|
||||||
List<String> result = new ArrayList<>();
|
|
||||||
for (Object item : collection) {
|
|
||||||
if (item != null) {
|
|
||||||
result.add(String.valueOf(item));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
throw new BusinessException("Agent 配置字段必须是数组:" + key);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Duration durationValue(Map<String, Object> map, String key) {
|
|
||||||
Object value = map == null ? null : map.get(key);
|
|
||||||
if (value == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (value instanceof Number number) {
|
|
||||||
return Duration.ofSeconds(number.longValue());
|
|
||||||
}
|
|
||||||
String text = String.valueOf(value).trim();
|
|
||||||
if (text.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return Duration.parse(text);
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
try {
|
|
||||||
return Duration.ofSeconds(Long.parseLong(text));
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
throw new BusinessException("Agent 配置字段必须是秒数或 Duration:" + key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<String> resolveMcpInputs(List<String> values) {
|
|
||||||
if (values == null || values.isEmpty()) {
|
|
||||||
return new ArrayList<>();
|
|
||||||
}
|
|
||||||
List<String> result = new ArrayList<>(values.size());
|
|
||||||
for (String value : values) {
|
|
||||||
result.add(resolveMcpInput(value));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, String> resolveMcpInputMap(Map<String, String> values) {
|
|
||||||
if (values == null || values.isEmpty()) {
|
|
||||||
return new LinkedHashMap<>();
|
|
||||||
}
|
|
||||||
Map<String, String> result = new LinkedHashMap<>();
|
|
||||||
values.forEach((key, value) -> result.put(key, resolveMcpInput(value)));
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveMcpInput(String value) {
|
|
||||||
if (value == null || value.isBlank()) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
Matcher matcher = MCP_INPUT_PATTERN.matcher(value);
|
|
||||||
StringBuffer resolved = new StringBuffer();
|
|
||||||
while (matcher.find()) {
|
|
||||||
String inputKey = matcher.group(1);
|
|
||||||
String resolvedValue = System.getProperty("mcp.input." + inputKey);
|
|
||||||
if (resolvedValue == null || resolvedValue.isBlank()) {
|
|
||||||
throw new BusinessException("MCP 输入变量未解析:" + inputKey);
|
|
||||||
}
|
|
||||||
matcher.appendReplacement(resolved, Matcher.quoteReplacement(resolvedValue));
|
|
||||||
}
|
|
||||||
matcher.appendTail(resolved);
|
|
||||||
return resolved.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private DocumentCollection snapshotOrPublishedKnowledge(AgentKnowledgeBinding binding) {
|
private DocumentCollection snapshotOrPublishedKnowledge(AgentKnowledgeBinding binding) {
|
||||||
if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) {
|
if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) {
|
||||||
DocumentCollection knowledge = objectMapper.convertValue(binding.getResourceSnapshot(), DocumentCollection.class);
|
DocumentCollection knowledge = objectMapper.convertValue(binding.getResourceSnapshot(), DocumentCollection.class);
|
||||||
@@ -683,74 +736,6 @@ public class AgentRuntimeCompiler {
|
|||||||
return value == null ? null : String.valueOf(value);
|
return value == null ? null : String.valueOf(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> toSchema(Parameter[] parameters) {
|
|
||||||
Map<String, Object> schema = new LinkedHashMap<>();
|
|
||||||
Map<String, Object> properties = new LinkedHashMap<>();
|
|
||||||
List<String> required = new ArrayList<>();
|
|
||||||
if (parameters != null) {
|
|
||||||
for (Parameter parameter : parameters) {
|
|
||||||
properties.put(parameter.getName(), parameterSchema(parameter));
|
|
||||||
if (parameter.isRequired()) {
|
|
||||||
required.add(parameter.getName());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
schema.put("type", "object");
|
|
||||||
schema.put("properties", properties);
|
|
||||||
schema.put("required", required);
|
|
||||||
return schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> parameterSchema(Parameter parameter) {
|
|
||||||
Map<String, Object> schema = new LinkedHashMap<>();
|
|
||||||
schema.put("type", parameter.getType() == null ? "string" : parameter.getType());
|
|
||||||
putOptionalString(schema, "description", parameter.getDescription());
|
|
||||||
if (parameter.getChildren() != null && !parameter.getChildren().isEmpty()) {
|
|
||||||
Map<String, Object> children = new LinkedHashMap<>();
|
|
||||||
for (Parameter child : parameter.getChildren()) {
|
|
||||||
if (child != null && child.getName() != null && !child.getName().isBlank()) {
|
|
||||||
children.put(child.getName(), parameterSchema(child));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ("array".equalsIgnoreCase(parameter.getType())) {
|
|
||||||
schema.put("items", firstArrayItemSchema(parameter.getChildren()));
|
|
||||||
} else {
|
|
||||||
schema.put("properties", children);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> firstArrayItemSchema(List<Parameter> children) {
|
|
||||||
return children.stream()
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.findFirst()
|
|
||||||
.map(this::parameterSchema)
|
|
||||||
.orElse(Map.of("type", "string"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 写入非空字符串字段,避免向模型 function schema 输出 null。
|
|
||||||
*
|
|
||||||
* @param target 目标 schema
|
|
||||||
* @param key 字段名
|
|
||||||
* @param value 字段值
|
|
||||||
*/
|
|
||||||
private void putOptionalString(Map<String, Object> target, String key, String value) {
|
|
||||||
if (value != null && !value.isBlank()) {
|
|
||||||
target.put(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将工具描述规整为模型协议可接受的字符串。
|
|
||||||
*
|
|
||||||
* @param description 原始描述
|
|
||||||
* @return 非 null 描述
|
|
||||||
*/
|
|
||||||
private String safeDescription(String description) {
|
|
||||||
return description == null ? "" : description;
|
|
||||||
}
|
|
||||||
|
|
||||||
private AgentMemoryType memoryTypeValue(Map<String, Object> map, String key) {
|
private AgentMemoryType memoryTypeValue(Map<String, Object> map, String key) {
|
||||||
String value = stringValue(map, key, AgentMemoryType.AUTO_CONTEXT.name());
|
String value = stringValue(map, key, AgentMemoryType.AUTO_CONTEXT.name());
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import java.util.Map;
|
|||||||
public class AgentToolHitlPayload {
|
public class AgentToolHitlPayload {
|
||||||
|
|
||||||
private String requestId;
|
private String requestId;
|
||||||
private String resumeToken;
|
private String approvalId;
|
||||||
private String sessionId;
|
private String sessionId;
|
||||||
private String agentId;
|
private String agentId;
|
||||||
private String toolCallId;
|
private String toolCallId;
|
||||||
@@ -39,21 +39,21 @@ public class AgentToolHitlPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取恢复令牌。
|
* 获取公开审批 ID。
|
||||||
*
|
*
|
||||||
* @return 恢复令牌
|
* @return 不暴露内部恢复令牌的审批 ID
|
||||||
*/
|
*/
|
||||||
public String getResumeToken() {
|
public String getApprovalId() {
|
||||||
return resumeToken;
|
return approvalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置恢复令牌。
|
* 设置公开审批 ID。
|
||||||
*
|
*
|
||||||
* @param resumeToken 恢复令牌
|
* @param approvalId 公开审批 ID
|
||||||
*/
|
*/
|
||||||
public void setResumeToken(String resumeToken) {
|
public void setApprovalId(String approvalId) {
|
||||||
this.resumeToken = resumeToken;
|
this.approvalId = approvalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package tech.easyflow.agent.runtime.agui;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AG-UI 自定义 HITL 兼容桥的审批请求。
|
||||||
|
*/
|
||||||
|
public class AgentAguiHitlResolveRequest {
|
||||||
|
|
||||||
|
private String approvalId;
|
||||||
|
private String decision;
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
/** @return 公开审批 ID */
|
||||||
|
public String getApprovalId() {
|
||||||
|
return approvalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param approvalId 公开审批 ID */
|
||||||
|
public void setApprovalId(String approvalId) {
|
||||||
|
this.approvalId = approvalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return APPROVE 或 REJECT */
|
||||||
|
public String getDecision() {
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param decision APPROVE 或 REJECT */
|
||||||
|
public void setDecision(String decision) {
|
||||||
|
this.decision = decision;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 拒绝原因 */
|
||||||
|
public String getReason() {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param reason 拒绝原因 */
|
||||||
|
public void setReason(String reason) {
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
package tech.easyflow.agent.runtime.agui;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import io.agentscope.core.agui.model.AguiMessage;
|
||||||
|
import io.agentscope.core.agui.model.RunAgentInput;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.agent.entity.Agent;
|
||||||
|
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||||
|
import tech.easyflow.agent.entity.AgentSkillBinding;
|
||||||
|
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||||
|
import tech.easyflow.agent.runtime.AgentChatCapability;
|
||||||
|
import tech.easyflow.agent.runtime.AgentChatRequest;
|
||||||
|
import tech.easyflow.agent.runtime.AgentDraftChatRequest;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将受控 AG-UI RunAgentInput 映射为现有 Agent 业务请求。
|
||||||
|
*
|
||||||
|
* <p>客户端历史、工具、上下文和 state 均不进入 Runtime 权限或会话恢复逻辑。</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AgentAguiRunInputMapper {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(AgentAguiRunInputMapper.class);
|
||||||
|
private static final int MAX_ATTACHMENTS_PER_TYPE = 32;
|
||||||
|
private static final int MAX_BINDINGS_PER_TYPE = 256;
|
||||||
|
private static final int MAX_SKILL_BINDINGS = 20;
|
||||||
|
private static final int MAX_CAPABILITIES = 32;
|
||||||
|
private static final int MAX_CAPABILITY_RESOURCE_IDS = 256;
|
||||||
|
private static final int MAX_FORWARDED_PROPS_BYTES = 1_048_576;
|
||||||
|
private static final int MAX_IDENTIFIER_LENGTH = 128;
|
||||||
|
private static final int MAX_PROMPT_LENGTH = 65_536;
|
||||||
|
private static final Set<String> FORMAL_EASYFLOW_KEYS = Set.of("input");
|
||||||
|
private static final Set<String> DRAFT_EASYFLOW_KEYS = Set.of("draft", "input");
|
||||||
|
private static final Set<String> INPUT_KEYS = Set.of(
|
||||||
|
"capabilities", "documentUploadIds", "imageUploadIds");
|
||||||
|
private static final Set<String> DRAFT_KEYS = Set.of(
|
||||||
|
"agent", "knowledgeBindings", "toolBindings", "skillBindings");
|
||||||
|
private static final Set<String> SKILL_BINDING_KEYS = Set.of("skillId", "sortNo");
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建输入映射器。
|
||||||
|
*
|
||||||
|
* @param objectMapper Jackson 映射器
|
||||||
|
*/
|
||||||
|
public AgentAguiRunInputMapper(ObjectMapper objectMapper) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 映射正式聊天请求。
|
||||||
|
*
|
||||||
|
* @param agentId URL 中的可信 Agent ID
|
||||||
|
* @param input AG-UI 运行输入
|
||||||
|
* @return 现有正式聊天请求
|
||||||
|
*/
|
||||||
|
public AgentChatRequest toFormalRequest(BigInteger agentId, RunAgentInput input) {
|
||||||
|
ValidatedInput validated = validate(input, false);
|
||||||
|
AgentChatRequest request = new AgentChatRequest();
|
||||||
|
request.setAgentId(agentId);
|
||||||
|
request.setSessionId(parseFormalThreadId(validated.threadId()));
|
||||||
|
request.setPrompt(validated.userMessage().getContent());
|
||||||
|
Map<String, Object> inputProps = nestedMap(easyflowProps(input), "input");
|
||||||
|
request.setImageUploadIds(stringList(
|
||||||
|
inputProps.get("imageUploadIds"), "imageUploadIds", MAX_ATTACHMENTS_PER_TYPE));
|
||||||
|
request.setDocumentUploadIds(stringList(
|
||||||
|
inputProps.get("documentUploadIds"), "documentUploadIds", MAX_ATTACHMENTS_PER_TYPE));
|
||||||
|
List<AgentChatCapability> capabilities = convertList(
|
||||||
|
inputProps.get("capabilities"), AgentChatCapability.class,
|
||||||
|
"capabilities", MAX_CAPABILITIES);
|
||||||
|
validateCapabilities(capabilities);
|
||||||
|
request.setCapabilities(capabilities);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 映射草稿试用请求。
|
||||||
|
*
|
||||||
|
* @param input AG-UI 运行输入
|
||||||
|
* @return 现有草稿试用请求
|
||||||
|
*/
|
||||||
|
public AgentDraftChatRequest toDraftRequest(RunAgentInput input) {
|
||||||
|
ValidatedInput validated = validate(input, true);
|
||||||
|
Map<String, Object> easyflow = easyflowProps(input);
|
||||||
|
Map<String, Object> inputProps = nestedMap(easyflow, "input");
|
||||||
|
Map<String, Object> draftProps = nestedMap(easyflow, "draft");
|
||||||
|
AgentDraftChatRequest request = new AgentDraftChatRequest();
|
||||||
|
request.setSessionId(validated.threadId());
|
||||||
|
request.setPrompt(validated.userMessage().getContent());
|
||||||
|
request.setImageUploadIds(stringList(
|
||||||
|
inputProps.get("imageUploadIds"), "imageUploadIds", MAX_ATTACHMENTS_PER_TYPE));
|
||||||
|
request.setDocumentUploadIds(stringList(
|
||||||
|
inputProps.get("documentUploadIds"), "documentUploadIds", MAX_ATTACHMENTS_PER_TYPE));
|
||||||
|
request.setAgent(convertRequired(draftProps.get("agent"), Agent.class, "Agent 草稿不能为空"));
|
||||||
|
request.setToolBindings(convertList(
|
||||||
|
draftProps.get("toolBindings"), AgentToolBinding.class,
|
||||||
|
"toolBindings", MAX_BINDINGS_PER_TYPE));
|
||||||
|
request.setKnowledgeBindings(convertList(
|
||||||
|
draftProps.get("knowledgeBindings"), AgentKnowledgeBinding.class,
|
||||||
|
"knowledgeBindings", MAX_BINDINGS_PER_TYPE));
|
||||||
|
request.setSkillBindings(convertSkillBindings(draftProps.get("skillBindings")));
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 AG-UI wire 上下文。
|
||||||
|
*
|
||||||
|
* @param input AG-UI 运行输入
|
||||||
|
* @return wire 上下文
|
||||||
|
*/
|
||||||
|
public AgentAguiWireContext wireContext(RunAgentInput input) {
|
||||||
|
ValidatedInput validated = validateCommon(input);
|
||||||
|
return new AgentAguiWireContext(
|
||||||
|
validated.threadId(),
|
||||||
|
input.getRunId(),
|
||||||
|
validated.userMessage().getId(),
|
||||||
|
validated.userMessage().getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ValidatedInput validate(RunAgentInput input, boolean draft) {
|
||||||
|
ValidatedInput validated = validateCommon(input);
|
||||||
|
validateForwardedProps(input, draft);
|
||||||
|
return validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ValidatedInput validateCommon(RunAgentInput input) {
|
||||||
|
if (input == null) {
|
||||||
|
throw new BusinessException("AG-UI 运行输入不能为空");
|
||||||
|
}
|
||||||
|
requireIdentifier(input.getThreadId(), "threadId");
|
||||||
|
requireIdentifier(input.getRunId(), "runId");
|
||||||
|
if (input.getMessages() == null || input.getMessages().size() != 1) {
|
||||||
|
throw new BusinessException("AG-UI 入口每轮只接受一条用户消息");
|
||||||
|
}
|
||||||
|
if (input.getTools() != null && !input.getTools().isEmpty()) {
|
||||||
|
throw new BusinessException("当前 Agent 入口不接受客户端工具");
|
||||||
|
}
|
||||||
|
if (input.getContext() != null && !input.getContext().isEmpty()) {
|
||||||
|
throw new BusinessException("当前 Agent 入口不接受客户端上下文");
|
||||||
|
}
|
||||||
|
if (input.getState() != null && !input.getState().isEmpty()) {
|
||||||
|
throw new BusinessException("当前 Agent 入口不接受客户端 state");
|
||||||
|
}
|
||||||
|
AguiMessage userMessage = input.getMessages().get(0);
|
||||||
|
if (userMessage == null || userMessage.getContent() == null) {
|
||||||
|
throw new BusinessException("AG-UI 输入缺少本轮用户消息");
|
||||||
|
}
|
||||||
|
if (!userMessage.isUserMessage()) {
|
||||||
|
throw new BusinessException("AG-UI 入口只接受用户消息");
|
||||||
|
}
|
||||||
|
requireIdentifier(userMessage.getId(), "messageId");
|
||||||
|
if (userMessage.hasToolCalls()
|
||||||
|
|| (userMessage.getToolCallId() != null && !userMessage.getToolCallId().isBlank())) {
|
||||||
|
throw new BusinessException("AG-UI 用户消息不能携带工具调用");
|
||||||
|
}
|
||||||
|
if (userMessage.getContent().length() > MAX_PROMPT_LENGTH) {
|
||||||
|
throw new BusinessException("Agent 输入内容过长");
|
||||||
|
}
|
||||||
|
return new ValidatedInput(input.getThreadId(), userMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateForwardedProps(RunAgentInput input, boolean draft) {
|
||||||
|
Map<String, Object> forwardedProps = input.getForwardedProps() == null
|
||||||
|
? Map.of()
|
||||||
|
: input.getForwardedProps();
|
||||||
|
rejectUnknownKeys(forwardedProps, Set.of("easyflow"), "forwardedProps");
|
||||||
|
validateSerializedSize(forwardedProps);
|
||||||
|
Map<String, Object> easyflow = easyflowProps(input);
|
||||||
|
rejectUnknownKeys(easyflow, draft ? DRAFT_EASYFLOW_KEYS : FORMAL_EASYFLOW_KEYS, "easyflow");
|
||||||
|
Map<String, Object> inputProps = nestedMap(easyflow, "input");
|
||||||
|
rejectUnknownKeys(inputProps, INPUT_KEYS, "easyflow.input");
|
||||||
|
if (draft) {
|
||||||
|
Map<String, Object> draftProps = nestedMap(easyflow, "draft");
|
||||||
|
rejectUnknownKeys(draftProps, DRAFT_KEYS, "easyflow.draft");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateSerializedSize(Map<String, Object> forwardedProps) {
|
||||||
|
try {
|
||||||
|
if (objectMapper.writeValueAsBytes(forwardedProps).length > MAX_FORWARDED_PROPS_BYTES) {
|
||||||
|
throw new BusinessException("AG-UI forwardedProps 内容过大");
|
||||||
|
}
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw new BusinessException("AG-UI forwardedProps 格式不合法");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigInteger parseFormalThreadId(String threadId) {
|
||||||
|
try {
|
||||||
|
BigInteger value = new BigInteger(threadId);
|
||||||
|
if (value.signum() <= 0) {
|
||||||
|
throw new NumberFormatException("non-positive");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
} catch (NumberFormatException exception) {
|
||||||
|
throw new BusinessException("正式 Agent threadId 必须是有效会话 ID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> easyflowProps(RunAgentInput input) {
|
||||||
|
Object value = input.getForwardedProps() == null
|
||||||
|
? null
|
||||||
|
: input.getForwardedProps().get("easyflow");
|
||||||
|
if (value == null) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
if (!(value instanceof Map<?, ?> map)) {
|
||||||
|
throw new BusinessException("AG-UI easyflow 扩展格式不合法");
|
||||||
|
}
|
||||||
|
return (Map<String, Object>) map;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> nestedMap(Map<String, Object> source, String key) {
|
||||||
|
Object value = source.get(key);
|
||||||
|
if (value == null) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
if (!(value instanceof Map<?, ?> map)) {
|
||||||
|
throw new BusinessException("AG-UI " + key + " 扩展格式不合法");
|
||||||
|
}
|
||||||
|
return (Map<String, Object>) map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> stringList(Object value, String name, int maximumSize) {
|
||||||
|
if (value == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (!(value instanceof List<?> list) || list.size() > maximumSize) {
|
||||||
|
throw new BusinessException("AG-UI " + name + " 数量不合法");
|
||||||
|
}
|
||||||
|
List<String> result = new ArrayList<>(list.size());
|
||||||
|
for (Object item : list) {
|
||||||
|
if (!(item instanceof String text) || text.isBlank()
|
||||||
|
|| text.length() > MAX_IDENTIFIER_LENGTH) {
|
||||||
|
throw new BusinessException("AG-UI " + name + " 内容不合法");
|
||||||
|
}
|
||||||
|
result.add(text);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> List<T> convertList(Object value,
|
||||||
|
Class<T> targetType,
|
||||||
|
String name,
|
||||||
|
int maximumSize) {
|
||||||
|
if (value == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (!(value instanceof List<?> list) || list.size() > maximumSize) {
|
||||||
|
throw new BusinessException("AG-UI " + name + " 数量不合法");
|
||||||
|
}
|
||||||
|
List<T> result = new ArrayList<>(list.size());
|
||||||
|
try {
|
||||||
|
for (Object item : list) {
|
||||||
|
result.add(objectMapper.convertValue(item, targetType));
|
||||||
|
}
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
throw new BusinessException("AG-UI " + name + " 内容不合法");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将客户端 Skill 引用转换为最小领域绑定,服务端快照与摘要字段不会进入 Runtime。
|
||||||
|
*
|
||||||
|
* @param value 客户端 Skill 引用列表
|
||||||
|
* @return 仅包含 Skill ID 与排序号的绑定
|
||||||
|
*/
|
||||||
|
private List<AgentSkillBinding> convertSkillBindings(Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (!(value instanceof List<?> list) || list.size() > MAX_SKILL_BINDINGS) {
|
||||||
|
throw new BusinessException("AG-UI skillBindings 数量不合法");
|
||||||
|
}
|
||||||
|
List<AgentSkillBinding> result = new ArrayList<>(list.size());
|
||||||
|
for (Object item : list) {
|
||||||
|
if (!(item instanceof Map<?, ?> raw)) {
|
||||||
|
throw new BusinessException("AG-UI skillBindings 内容不合法");
|
||||||
|
}
|
||||||
|
Map<String, Object> binding = stringKeyMap(raw, "skillBindings");
|
||||||
|
rejectUnknownKeys(binding, SKILL_BINDING_KEYS, "easyflow.draft.skillBindings");
|
||||||
|
AgentSkillBinding converted = new AgentSkillBinding();
|
||||||
|
converted.setSkillId(positiveBigInteger(binding.get("skillId"), "skillId"));
|
||||||
|
converted.setSortNo(optionalInteger(binding.get("sortNo"), "sortNo"));
|
||||||
|
result.add(converted);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将任意 Map 规范为字符串键 Map。
|
||||||
|
*
|
||||||
|
* @param source 原始 Map
|
||||||
|
* @param name 字段名称
|
||||||
|
* @return 字符串键 Map
|
||||||
|
*/
|
||||||
|
private Map<String, Object> stringKeyMap(Map<?, ?> source, String name) {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<?, ?> entry : source.entrySet()) {
|
||||||
|
if (!(entry.getKey() instanceof String key)) {
|
||||||
|
throw new BusinessException("AG-UI " + name + " 内容不合法");
|
||||||
|
}
|
||||||
|
result.put(key, entry.getValue());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析正整数 ID。
|
||||||
|
*
|
||||||
|
* @param value 原始值
|
||||||
|
* @param name 字段名称
|
||||||
|
* @return 正整数 ID
|
||||||
|
*/
|
||||||
|
private BigInteger positiveBigInteger(Object value, String name) {
|
||||||
|
if (value == null) {
|
||||||
|
throw new BusinessException("AG-UI " + name + " 不能为空");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
BigInteger result = new BigInteger(String.valueOf(value));
|
||||||
|
if (result.signum() <= 0) {
|
||||||
|
throw new NumberFormatException("non-positive");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (NumberFormatException exception) {
|
||||||
|
throw new BusinessException("AG-UI " + name + " 不合法");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析可选整数。
|
||||||
|
*
|
||||||
|
* @param value 原始值
|
||||||
|
* @param name 字段名称
|
||||||
|
* @return 整数或 null
|
||||||
|
*/
|
||||||
|
private Integer optionalInteger(Object value, String name) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Integer.valueOf(String.valueOf(value));
|
||||||
|
} catch (NumberFormatException exception) {
|
||||||
|
throw new BusinessException("AG-UI " + name + " 不合法");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateCapabilities(List<AgentChatCapability> capabilities) {
|
||||||
|
for (AgentChatCapability capability : capabilities) {
|
||||||
|
if (capability == null || capability.getType() == null
|
||||||
|
|| capability.getType().isBlank() || capability.getType().length() > 64
|
||||||
|
|| capability.getResourceIds().size() > MAX_CAPABILITY_RESOURCE_IDS) {
|
||||||
|
throw new BusinessException("AG-UI capabilities 内容不合法");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T convertRequired(Object value, Class<T> targetType, String message) {
|
||||||
|
if (value == null) {
|
||||||
|
throw new BusinessException(message);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.convertValue(value, targetType);
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
throw new BusinessException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireIdentifier(String value, String name) {
|
||||||
|
if (value == null || value.isBlank() || value.length() > MAX_IDENTIFIER_LENGTH
|
||||||
|
|| !value.matches("[A-Za-z0-9._:-]+")) {
|
||||||
|
throw new BusinessException("AG-UI " + name + " 不合法");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rejectUnknownKeys(Map<String, Object> source, Set<String> allowedKeys, String name) {
|
||||||
|
if (!allowedKeys.containsAll(source.keySet())) {
|
||||||
|
LOG.debug("Reject unsupported AG-UI keys, namespace={}, keys={}", name, source.keySet());
|
||||||
|
throw new BusinessException("AG-UI " + name + " 包含不支持的字段");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ValidatedInput(String threadId, AguiMessage userMessage) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package tech.easyflow.agent.runtime.agui;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单次 AG-UI 连接的客户端 wire 标识。
|
||||||
|
*
|
||||||
|
* @param threadId 客户端 thread ID
|
||||||
|
* @param runId 客户端 run ID,仅用于协议输出
|
||||||
|
* @param userMessageId 客户端本轮用户消息 ID
|
||||||
|
* @param userMessageContent 客户端本轮用户消息正文
|
||||||
|
*/
|
||||||
|
public record AgentAguiWireContext(
|
||||||
|
String threadId,
|
||||||
|
String runId,
|
||||||
|
String userMessageId,
|
||||||
|
String userMessageContent) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package tech.easyflow.agent.runtime.artifact;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.agent.runtime.AgentRuntimeStateCleanupService;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||||
|
import tech.easyflow.chatlog.service.ChatSessionExtension;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 会话的 Artifact 生命周期与历史安全投影扩展。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AgentArtifactChatSessionExtension implements ChatSessionExtension {
|
||||||
|
|
||||||
|
private static final String AGENT_ASSISTANT_CODE = "AGENT";
|
||||||
|
|
||||||
|
private final AgentRuntimeStateCleanupService runtimeStateCleanupService;
|
||||||
|
private final AgentArtifactService artifactService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Agent 会话扩展。
|
||||||
|
*
|
||||||
|
* @param runtimeStateCleanupService Agent 运行态清理服务
|
||||||
|
* @param artifactService Artifact 服务
|
||||||
|
*/
|
||||||
|
public AgentArtifactChatSessionExtension(AgentRuntimeStateCleanupService runtimeStateCleanupService,
|
||||||
|
AgentArtifactService artifactService) {
|
||||||
|
this.runtimeStateCleanupService = runtimeStateCleanupService;
|
||||||
|
this.artifactService = artifactService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supports(ChatSessionSummary summary) {
|
||||||
|
return summary != null && AGENT_ASSISTANT_CODE.equals(summary.getAssistantCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void beforeDelete(ChatSessionSummary summary, BigInteger userId, BigInteger operatorId) {
|
||||||
|
requireIdentity(summary, userId);
|
||||||
|
runtimeStateCleanupService.clearChatSession(summary.getId(), userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterDelete(ChatSessionSummary summary, BigInteger userId, BigInteger operatorId) {
|
||||||
|
requireIdentity(summary, userId);
|
||||||
|
artifactService.markSessionDeletePending(
|
||||||
|
summary.getTenantId(), userId, summary.getAssistantId(), summary.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void projectMessages(ChatSessionSummary summary, List<ChatMessageRecord> records) {
|
||||||
|
requireIdentity(summary, summary.getUserId());
|
||||||
|
artifactService.projectHistoryArtifacts(records,
|
||||||
|
summary.getTenantId(), summary.getUserId(), summary.getAssistantId(), summary.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireIdentity(ChatSessionSummary summary, BigInteger userId) {
|
||||||
|
if (summary == null || summary.getId() == null || summary.getTenantId() == null
|
||||||
|
|| summary.getAssistantId() == null || summary.getUserId() == null
|
||||||
|
|| !Objects.equals(summary.getUserId(), userId)) {
|
||||||
|
throw new BusinessException("Agent 会话归属不完整");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package tech.easyflow.agent.runtime.artifact;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import com.mybatisflex.core.tenant.TenantManager;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.agent.entity.AgentArtifact;
|
||||||
|
import tech.easyflow.agent.mapper.AgentArtifactMapper;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有界领取并补偿清理过期、待删除和删除失败 Artifact。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AgentArtifactCleanupScheduler {
|
||||||
|
|
||||||
|
private static final int BATCH_SIZE = 100;
|
||||||
|
private static final String PUBLISH_TIMEOUT_ERROR = "ARTIFACT_PUBLISH_TIMEOUT";
|
||||||
|
private static final String SESSION_UNAVAILABLE_ERROR = "ARTIFACT_SESSION_UNAVAILABLE";
|
||||||
|
|
||||||
|
private final AgentArtifactMapper mapper;
|
||||||
|
private final AgentArtifactService artifactService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建清理任务。
|
||||||
|
*
|
||||||
|
* @param mapper Artifact Mapper
|
||||||
|
* @param artifactService Artifact 服务
|
||||||
|
*/
|
||||||
|
public AgentArtifactCleanupScheduler(AgentArtifactMapper mapper,
|
||||||
|
AgentArtifactService artifactService) {
|
||||||
|
this.mapper = mapper;
|
||||||
|
this.artifactService = artifactService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 周期性清理,单次最多处理一百条,避免全表扫描和长时间占用调度线程。
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelayString = "${easyflow.agent.workspace.cleanup-interval:30m}")
|
||||||
|
public void cleanup() {
|
||||||
|
TenantManager.withoutTenantCondition(() -> {
|
||||||
|
cleanupWithoutTenantCondition();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在关闭 ORM 当前租户条件的作用域中执行全租户清理。
|
||||||
|
*/
|
||||||
|
private void cleanupWithoutTenantCondition() {
|
||||||
|
Date now = new Date();
|
||||||
|
List<AgentArtifact> expired = mapper.selectListByQuery(QueryWrapper.create()
|
||||||
|
.eq(AgentArtifact::getChatMode, AgentArtifactService.MODE_DRAFT)
|
||||||
|
.eq(AgentArtifact::getStatus, AgentArtifactStatus.AVAILABLE.name())
|
||||||
|
.le(AgentArtifact::getExpiresAt, now)
|
||||||
|
.orderBy(AgentArtifact::getId, true)
|
||||||
|
.limit(BATCH_SIZE));
|
||||||
|
for (AgentArtifact artifact : expired) {
|
||||||
|
AgentArtifact update = new AgentArtifact();
|
||||||
|
update.setStatus(AgentArtifactStatus.DELETE_PENDING.name());
|
||||||
|
update.setNextRetryAt(now);
|
||||||
|
update.setModified(now);
|
||||||
|
mapper.updateByQuery(update, QueryWrapper.create()
|
||||||
|
.eq(AgentArtifact::getId, artifact.getId())
|
||||||
|
.eq(AgentArtifact::getStatus, AgentArtifactStatus.AVAILABLE.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<java.math.BigInteger, AgentArtifact> candidates = new LinkedHashMap<>();
|
||||||
|
List<AgentArtifact> abandonedPublishing = mapper.selectListByQuery(QueryWrapper.create()
|
||||||
|
.eq(AgentArtifact::getStatus, AgentArtifactStatus.PUBLISHING.name())
|
||||||
|
.le(AgentArtifact::getNextRetryAt, now)
|
||||||
|
.orderBy(AgentArtifact::getId, true)
|
||||||
|
.limit(BATCH_SIZE));
|
||||||
|
for (AgentArtifact artifact : abandonedPublishing) {
|
||||||
|
AgentArtifact update = new AgentArtifact();
|
||||||
|
update.setStatus(AgentArtifactStatus.DELETE_PENDING.name());
|
||||||
|
update.setNextRetryAt(now);
|
||||||
|
update.setLastErrorCode(PUBLISH_TIMEOUT_ERROR);
|
||||||
|
update.setModified(now);
|
||||||
|
int changed = mapper.updateByQuery(update, QueryWrapper.create()
|
||||||
|
.eq(AgentArtifact::getId, artifact.getId())
|
||||||
|
.eq(AgentArtifact::getStatus, AgentArtifactStatus.PUBLISHING.name())
|
||||||
|
.le(AgentArtifact::getNextRetryAt, now));
|
||||||
|
if (changed == 1) {
|
||||||
|
artifact.setStatus(AgentArtifactStatus.DELETE_PENDING.name());
|
||||||
|
artifact.setNextRetryAt(now);
|
||||||
|
artifact.setLastErrorCode(PUBLISH_TIMEOUT_ERROR);
|
||||||
|
candidates.put(artifact.getId(), artifact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidates.size() < BATCH_SIZE) {
|
||||||
|
List<AgentArtifact> orphans = mapper.selectOrphanedFormalArtifacts(BATCH_SIZE - candidates.size());
|
||||||
|
for (AgentArtifact artifact : orphans) {
|
||||||
|
AgentArtifact update = new AgentArtifact();
|
||||||
|
update.setStatus(AgentArtifactStatus.DELETE_PENDING.name());
|
||||||
|
update.setNextRetryAt(now);
|
||||||
|
update.setLastErrorCode(SESSION_UNAVAILABLE_ERROR);
|
||||||
|
update.setModified(now);
|
||||||
|
int changed = mapper.updateByQuery(update, QueryWrapper.create()
|
||||||
|
.eq(AgentArtifact::getId, artifact.getId())
|
||||||
|
.eq(AgentArtifact::getStatus, artifact.getStatus()));
|
||||||
|
if (changed == 1) {
|
||||||
|
artifact.setStatus(AgentArtifactStatus.DELETE_PENDING.name());
|
||||||
|
artifact.setNextRetryAt(now);
|
||||||
|
artifact.setLastErrorCode(SESSION_UNAVAILABLE_ERROR);
|
||||||
|
candidates.put(artifact.getId(), artifact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidates.size() < BATCH_SIZE) {
|
||||||
|
add(candidates, mapper.selectListByQuery(QueryWrapper.create()
|
||||||
|
.eq(AgentArtifact::getStatus, AgentArtifactStatus.DELETE_PENDING.name())
|
||||||
|
.orderBy(AgentArtifact::getId, true)
|
||||||
|
.limit(BATCH_SIZE - candidates.size())));
|
||||||
|
}
|
||||||
|
if (candidates.size() < BATCH_SIZE) {
|
||||||
|
add(candidates, mapper.selectListByQuery(QueryWrapper.create()
|
||||||
|
.eq(AgentArtifact::getStatus, AgentArtifactStatus.DELETE_FAILED.name())
|
||||||
|
.le(AgentArtifact::getNextRetryAt, now)
|
||||||
|
.orderBy(AgentArtifact::getId, true)
|
||||||
|
.limit(BATCH_SIZE - candidates.size())));
|
||||||
|
}
|
||||||
|
new ArrayList<>(candidates.values()).forEach(artifactService::deleteObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void add(Map<java.math.BigInteger, AgentArtifact> target, List<AgentArtifact> artifacts) {
|
||||||
|
for (AgentArtifact artifact : artifacts) {
|
||||||
|
target.putIfAbsent(artifact.getId(), artifact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package tech.easyflow.agent.runtime.artifact;
|
||||||
|
|
||||||
|
import io.minio.GetObjectArgs;
|
||||||
|
import io.minio.PutObjectArgs;
|
||||||
|
import io.minio.RemoveObjectArgs;
|
||||||
|
import io.minio.StatObjectArgs;
|
||||||
|
import io.minio.StatObjectResponse;
|
||||||
|
import io.minio.errors.ErrorResponseException;
|
||||||
|
import org.dromara.x.file.storage.core.FileStorageService;
|
||||||
|
import org.dromara.x.file.storage.core.platform.MinioFileStorage;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复用 x-file-storage 中固定私有平台的 Agent Artifact 对象存储适配器。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AgentArtifactObjectStorage {
|
||||||
|
|
||||||
|
/** 固定私有产物平台,不允许配置回退。 */
|
||||||
|
public static final String PLATFORM = "minio-agent-artifacts";
|
||||||
|
|
||||||
|
private final FileStorageService fileStorageService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建对象存储适配器。
|
||||||
|
*
|
||||||
|
* @param fileStorageService x-file-storage 聚合服务
|
||||||
|
*/
|
||||||
|
public AgentArtifactObjectStorage(FileStorageService fileStorageService) {
|
||||||
|
this.fileStorageService = fileStorageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用就绪时校验固定私有平台,缺失或配置公开域名时 fail-fast。
|
||||||
|
*/
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void validatePlatform() {
|
||||||
|
MinioFileStorage storage = storage();
|
||||||
|
if (StringUtils.hasText(storage.getDomain())) {
|
||||||
|
throw new IllegalStateException("Agent Artifact 存储必须使用无公开域名的私有 MinIO 平台");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
boolean exists = storage.getClient().bucketExists(
|
||||||
|
io.minio.BucketExistsArgs.builder().bucket(storage.getBucketName()).build());
|
||||||
|
if (!exists) {
|
||||||
|
throw new IllegalStateException("Agent Artifact 私有 MinIO bucket 不存在");
|
||||||
|
}
|
||||||
|
} catch (IllegalStateException error) {
|
||||||
|
throw error;
|
||||||
|
} catch (Exception error) {
|
||||||
|
throw new IllegalStateException("校验 Agent Artifact 私有 MinIO bucket 失败", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流式写入对象。
|
||||||
|
*
|
||||||
|
* @param objectKey 业务对象键
|
||||||
|
* @param input 输入流,由调用方关闭
|
||||||
|
* @param size 已校验字节数
|
||||||
|
* @param mimeType MIME 类型
|
||||||
|
* @return 对象 ETag
|
||||||
|
*/
|
||||||
|
public String put(String objectKey, InputStream input, long size, String mimeType) {
|
||||||
|
try {
|
||||||
|
MinioFileStorage storage = storage();
|
||||||
|
return storage.getClient().putObject(PutObjectArgs.builder()
|
||||||
|
.bucket(storage.getBucketName())
|
||||||
|
.object(fullKey(storage, objectKey))
|
||||||
|
.stream(input, size, -1)
|
||||||
|
.contentType(mimeType)
|
||||||
|
.build()).etag();
|
||||||
|
} catch (Exception error) {
|
||||||
|
throw new AgentArtifactOperationException(
|
||||||
|
"ARTIFACT_STORAGE_UNAVAILABLE", "产物存储暂时不可用", true, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询私有对象实际元数据。
|
||||||
|
*
|
||||||
|
* @param objectKey 业务对象键
|
||||||
|
* @return 对象实际大小与 ETag
|
||||||
|
*/
|
||||||
|
public StoredObjectMetadata stat(String objectKey) {
|
||||||
|
try {
|
||||||
|
MinioFileStorage storage = storage();
|
||||||
|
StatObjectResponse response = storage.getClient().statObject(StatObjectArgs.builder()
|
||||||
|
.bucket(storage.getBucketName())
|
||||||
|
.object(fullKey(storage, objectKey))
|
||||||
|
.build());
|
||||||
|
return new StoredObjectMetadata(response.size(), response.etag());
|
||||||
|
} catch (Exception error) {
|
||||||
|
throw new AgentArtifactOperationException(
|
||||||
|
"ARTIFACT_STORAGE_UNAVAILABLE", "读取产物对象元数据失败", true, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开私有对象读取流。
|
||||||
|
*
|
||||||
|
* @param objectKey 业务对象键
|
||||||
|
* @return MinIO 输入流,由调用方关闭
|
||||||
|
* @throws IOException 对象读取失败
|
||||||
|
*/
|
||||||
|
public InputStream open(String objectKey) throws IOException {
|
||||||
|
try {
|
||||||
|
MinioFileStorage storage = storage();
|
||||||
|
return storage.getClient().getObject(GetObjectArgs.builder()
|
||||||
|
.bucket(storage.getBucketName())
|
||||||
|
.object(fullKey(storage, objectKey))
|
||||||
|
.build());
|
||||||
|
} catch (Exception error) {
|
||||||
|
throw new IOException("读取 Agent Artifact 对象失败", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等删除一个私有对象。
|
||||||
|
*
|
||||||
|
* @param objectKey 业务对象键
|
||||||
|
*/
|
||||||
|
public void delete(String objectKey) {
|
||||||
|
try {
|
||||||
|
MinioFileStorage storage = storage();
|
||||||
|
storage.getClient().removeObject(RemoveObjectArgs.builder()
|
||||||
|
.bucket(storage.getBucketName())
|
||||||
|
.object(fullKey(storage, objectKey))
|
||||||
|
.build());
|
||||||
|
} catch (ErrorResponseException error) {
|
||||||
|
String code = error.errorResponse() == null ? null : error.errorResponse().code();
|
||||||
|
if ("NoSuchKey".equals(code) || "NoSuchObject".equals(code)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new AgentArtifactOperationException(
|
||||||
|
"ARTIFACT_STORAGE_UNAVAILABLE", "产物对象删除失败", true, error);
|
||||||
|
} catch (Exception error) {
|
||||||
|
throw new AgentArtifactOperationException(
|
||||||
|
"ARTIFACT_STORAGE_UNAVAILABLE", "产物对象删除失败", true, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private MinioFileStorage storage() {
|
||||||
|
MinioFileStorage storage = fileStorageService.getFileStorage(PLATFORM);
|
||||||
|
if (storage == null) {
|
||||||
|
throw new IllegalStateException("缺少固定 x-file-storage 平台: " + PLATFORM);
|
||||||
|
}
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fullKey(MinioFileStorage storage, String objectKey) {
|
||||||
|
String basePath = storage.getBasePath();
|
||||||
|
if (!StringUtils.hasText(basePath)) {
|
||||||
|
return objectKey;
|
||||||
|
}
|
||||||
|
return basePath.replaceAll("/+$", "") + "/" + objectKey.replaceAll("^/+", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 私有对象存储返回的可信元数据。
|
||||||
|
*
|
||||||
|
* @param size 实际对象字节数
|
||||||
|
* @param etag 对象 ETag
|
||||||
|
*/
|
||||||
|
public record StoredObjectMetadata(long size, String etag) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package tech.easyflow.agent.runtime.artifact;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Artifact Tool 可安全返回给模型的稳定业务异常。
|
||||||
|
*/
|
||||||
|
public class AgentArtifactOperationException extends RuntimeException {
|
||||||
|
|
||||||
|
private final String code;
|
||||||
|
private final boolean retryable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Artifact 业务异常。
|
||||||
|
*
|
||||||
|
* @param code 稳定错误码
|
||||||
|
* @param message 脱敏错误消息
|
||||||
|
* @param retryable 是否可重试
|
||||||
|
*/
|
||||||
|
public AgentArtifactOperationException(String code, String message, boolean retryable) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
this.retryable = retryable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建保留内部原因的 Artifact 业务异常。
|
||||||
|
*
|
||||||
|
* @param code 稳定错误码
|
||||||
|
* @param message 脱敏错误消息
|
||||||
|
* @param retryable 是否可重试
|
||||||
|
* @param cause 内部异常原因,仅用于服务端日志
|
||||||
|
*/
|
||||||
|
public AgentArtifactOperationException(String code, String message, boolean retryable, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
this.code = code;
|
||||||
|
this.retryable = retryable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 稳定错误码 */
|
||||||
|
public String getCode() { return code; }
|
||||||
|
/** @return 是否可重试 */
|
||||||
|
public boolean isRetryable() { return retryable; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,996 @@
|
|||||||
|
package tech.easyflow.agent.runtime.artifact;
|
||||||
|
|
||||||
|
import com.easyagents.agent.runtime.tool.AgentToolContext;
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import com.mybatisflex.core.update.UpdateChain;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import tech.easyflow.agent.config.AgentWorkspaceProperties;
|
||||||
|
import tech.easyflow.agent.entity.AgentArtifact;
|
||||||
|
import tech.easyflow.agent.mapper.AgentArtifactMapper;
|
||||||
|
import tech.easyflow.agent.runtime.workspace.AgentWorkspaceResolver;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||||
|
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.core.runtime.ChatRuntimeExtKeys;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.nio.channels.SeekableByteChannel;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.security.DigestInputStream;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Enumeration;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent Artifact 发布、归属校验和生命周期服务。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class AgentArtifactService {
|
||||||
|
|
||||||
|
/** 草稿产物模式。 */
|
||||||
|
public static final String MODE_DRAFT = "DRAFT";
|
||||||
|
/** 正式聊天产物模式。 */
|
||||||
|
public static final String MODE_FORMAL = "FORMAL";
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(AgentArtifactService.class);
|
||||||
|
private static final int MAX_FILE_NAME_LENGTH = 255;
|
||||||
|
private static final int MAX_ZIP_ENTRIES = 256;
|
||||||
|
private static final int MAX_ZIP_ENTRY_NAME_LENGTH = 1024;
|
||||||
|
private static final long MAX_ZIP_CENTRAL_DIRECTORY_SIZE = 1024L * 1024;
|
||||||
|
private static final long MAX_ZIP_DECLARED_SIZE = 512L * 1024 * 1024;
|
||||||
|
private static final long MAX_ZIP_COMPRESSION_RATIO = 200L;
|
||||||
|
private static final int ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014B50;
|
||||||
|
private static final int ZIP_CENTRAL_DIRECTORY_HEADER_SIZE = 46;
|
||||||
|
private static final int ZIP_EOCD_SIGNATURE = 0x06054B50;
|
||||||
|
private static final int ZIP_EOCD_MIN_SIZE = 22;
|
||||||
|
private static final int ZIP_EOCD_MAX_SIZE = 65_557;
|
||||||
|
private static final int ZIP_UINT16_MAX = 0xFFFF;
|
||||||
|
private static final long ZIP_UINT32_MAX = 0xFFFF_FFFFL;
|
||||||
|
private static final long PUBLISH_RECOVERY_TIMEOUT_SECONDS = 10 * 60L;
|
||||||
|
private static final String UNAVAILABLE_STATUS = "UNAVAILABLE";
|
||||||
|
|
||||||
|
private final AgentArtifactMapper mapper;
|
||||||
|
private final AgentArtifactObjectStorage objectStorage;
|
||||||
|
private final AgentWorkspaceResolver workspaceResolver;
|
||||||
|
private final AgentWorkspaceProperties workspaceProperties;
|
||||||
|
private ChatSessionQueryService chatSessionQueryService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Artifact 服务。
|
||||||
|
*
|
||||||
|
* @param mapper 状态账本 Mapper
|
||||||
|
* @param objectStorage 私有对象存储
|
||||||
|
* @param workspaceResolver 工作区解析器
|
||||||
|
* @param workspaceProperties 工作区限制
|
||||||
|
*/
|
||||||
|
public AgentArtifactService(AgentArtifactMapper mapper,
|
||||||
|
AgentArtifactObjectStorage objectStorage,
|
||||||
|
AgentWorkspaceResolver workspaceResolver,
|
||||||
|
AgentWorkspaceProperties workspaceProperties) {
|
||||||
|
this.mapper = mapper;
|
||||||
|
this.objectStorage = objectStorage;
|
||||||
|
this.workspaceResolver = workspaceResolver;
|
||||||
|
this.workspaceProperties = workspaceProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 延迟注入会话查询服务,避免会话投影扩展初始化形成依赖环。
|
||||||
|
*
|
||||||
|
* @param chatSessionQueryService 会话查询服务
|
||||||
|
*/
|
||||||
|
@Autowired
|
||||||
|
@Lazy
|
||||||
|
public void setChatSessionQueryService(ChatSessionQueryService chatSessionQueryService) {
|
||||||
|
this.chatSessionQueryService = chatSessionQueryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将当前会话工作区中的普通文件发布为私有 Artifact。
|
||||||
|
*
|
||||||
|
* @param workspace 当前会话绝对工作区
|
||||||
|
* @param relativePath 工作区相对文件路径
|
||||||
|
* @param requestedFileName 可选展示文件名
|
||||||
|
* @param mode DRAFT 或 FORMAL
|
||||||
|
* @param context 可信 Tool 调用上下文
|
||||||
|
* @return 安全产物视图
|
||||||
|
*/
|
||||||
|
public AgentArtifactView publish(Path workspace,
|
||||||
|
String relativePath,
|
||||||
|
String requestedFileName,
|
||||||
|
String mode,
|
||||||
|
AgentToolContext context) {
|
||||||
|
ToolIdentity identity = requireIdentity(context, mode);
|
||||||
|
Path file = workspaceResolver.resolveExistingFile(workspace, relativePath);
|
||||||
|
long size = fileSize(file);
|
||||||
|
if (size > workspaceProperties.getMaxSingleFileSize().toBytes()) {
|
||||||
|
throw new AgentArtifactOperationException(
|
||||||
|
"WORKSPACE_QUOTA_EXCEEDED", "文件超过允许发布的单文件大小", false);
|
||||||
|
}
|
||||||
|
String fileName = safeFileName(requestedFileName, file.getFileName().toString());
|
||||||
|
String mimeType = mimeType(file);
|
||||||
|
String artifactId = opaqueId();
|
||||||
|
String objectKey = "artifacts/%s/%s/%s/content%s".formatted(
|
||||||
|
identity.tenantId(), identity.agentId(), artifactId, safeExtension(fileName));
|
||||||
|
AgentArtifact artifact = publishingRecord(
|
||||||
|
artifactId, fileName, mimeType, size, objectKey, identity, context);
|
||||||
|
mapper.insert(artifact);
|
||||||
|
|
||||||
|
boolean uploadAttempted = false;
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
String etag;
|
||||||
|
try (InputStream raw = Files.newInputStream(file);
|
||||||
|
DigestInputStream input = new DigestInputStream(raw, digest)) {
|
||||||
|
uploadAttempted = true;
|
||||||
|
etag = objectStorage.put(objectKey, input, size, mimeType);
|
||||||
|
}
|
||||||
|
String sha256 = HexFormat.of().formatHex(digest.digest());
|
||||||
|
verifyStoredObject(objectKey, size, sha256);
|
||||||
|
boolean changed = UpdateChain.of(new AgentArtifact(), mapper)
|
||||||
|
.set(AgentArtifact::getSha256, sha256)
|
||||||
|
.set(AgentArtifact::getStorageEtag, etag)
|
||||||
|
.set(AgentArtifact::getStatus, AgentArtifactStatus.AVAILABLE.name())
|
||||||
|
.set(AgentArtifact::getNextRetryAt, null)
|
||||||
|
.set(AgentArtifact::getLastErrorCode, null)
|
||||||
|
.set(AgentArtifact::getModified, new Date())
|
||||||
|
.set(AgentArtifact::getModifiedBy, identity.userId())
|
||||||
|
.eq(AgentArtifact::getId, artifact.getId())
|
||||||
|
.eq(AgentArtifact::getStatus, AgentArtifactStatus.PUBLISHING.name())
|
||||||
|
.update();
|
||||||
|
if (!changed) {
|
||||||
|
throw new AgentArtifactOperationException(
|
||||||
|
"ARTIFACT_PUBLISH_FAILED", "提交产物发布状态失败", true);
|
||||||
|
}
|
||||||
|
artifact.setSha256(sha256);
|
||||||
|
artifact.setStorageEtag(etag);
|
||||||
|
artifact.setStatus(AgentArtifactStatus.AVAILABLE.name());
|
||||||
|
workspaceResolver.touch(workspace);
|
||||||
|
return toView(artifact);
|
||||||
|
} catch (AgentArtifactOperationException error) {
|
||||||
|
compensatePublishFailure(artifact, uploadAttempted, error.getCode(), error);
|
||||||
|
throw error;
|
||||||
|
} catch (NoSuchAlgorithmException | IOException error) {
|
||||||
|
compensatePublishFailure(artifact, uploadAttempted, "ARTIFACT_PUBLISH_FAILED", error);
|
||||||
|
throw new AgentArtifactOperationException(
|
||||||
|
"ARTIFACT_PUBLISH_FAILED", "读取并发布工作区文件失败", true);
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
compensatePublishFailure(artifact, uploadAttempted, "ARTIFACT_PUBLISH_FAILED", error);
|
||||||
|
throw new AgentArtifactOperationException(
|
||||||
|
"ARTIFACT_PUBLISH_FAILED", "产物发布失败", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验当前登录用户并返回可下载账本。
|
||||||
|
*
|
||||||
|
* @param artifactId 对外 Artifact ID
|
||||||
|
* @param account 当前账号
|
||||||
|
* @param expectedAgentId 当前页面 Agent ID
|
||||||
|
* @param expectedMode 当前页面聊天模式
|
||||||
|
* @param expectedSessionId 当前页面正式会话 ID
|
||||||
|
* @param expectedRuntimeSessionId 当前页面草稿 Runtime 会话 ID
|
||||||
|
* @return 可下载记录
|
||||||
|
*/
|
||||||
|
public AgentArtifact requireDownload(String artifactId,
|
||||||
|
LoginAccount account,
|
||||||
|
BigInteger expectedAgentId,
|
||||||
|
String expectedMode,
|
||||||
|
BigInteger expectedSessionId,
|
||||||
|
String expectedRuntimeSessionId) {
|
||||||
|
if (!StringUtils.hasText(artifactId) || account == null
|
||||||
|
|| account.getId() == null || account.getTenantId() == null
|
||||||
|
|| expectedAgentId == null || expectedAgentId.signum() <= 0
|
||||||
|
|| !StringUtils.hasText(expectedMode)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "产物不存在");
|
||||||
|
}
|
||||||
|
AgentArtifact artifact = mapper.selectOneByQuery(QueryWrapper.create()
|
||||||
|
.eq(AgentArtifact::getTenantId, account.getTenantId())
|
||||||
|
.eq(AgentArtifact::getArtifactId, artifactId));
|
||||||
|
if (artifact == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "产物不存在");
|
||||||
|
}
|
||||||
|
if (!account.getId().equals(artifact.getOwnerUserId())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权下载该产物");
|
||||||
|
}
|
||||||
|
validateDownloadScope(
|
||||||
|
artifact, expectedAgentId, expectedMode, expectedSessionId, expectedRuntimeSessionId);
|
||||||
|
if (artifact.getExpiresAt() != null && artifact.getExpiresAt().before(new Date())) {
|
||||||
|
markDeletePending(artifact);
|
||||||
|
throw new ResponseStatusException(HttpStatus.GONE, "产物已过期");
|
||||||
|
}
|
||||||
|
if (!AgentArtifactStatus.AVAILABLE.name().equals(artifact.getStatus())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.CONFLICT, "产物当前不可下载");
|
||||||
|
}
|
||||||
|
return artifact;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开已鉴权账本对应的对象流。
|
||||||
|
*
|
||||||
|
* @param artifact 已鉴权记录
|
||||||
|
* @return 对象输入流
|
||||||
|
* @throws IOException 对象读取失败
|
||||||
|
*/
|
||||||
|
public InputStream openDownload(AgentArtifact artifact) throws IOException {
|
||||||
|
if (artifact == null || !AgentArtifactObjectStorage.PLATFORM.equals(artifact.getStoragePlatform())) {
|
||||||
|
throw new IOException("Artifact 存储平台不匹配");
|
||||||
|
}
|
||||||
|
return objectStorage.open(artifact.getObjectKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用当前 Artifact 账本批量覆盖会话历史中的安全产物投影。
|
||||||
|
*
|
||||||
|
* @param messages 同一正式会话的一页或全部消息
|
||||||
|
* @param tenantId 当前登录租户 ID
|
||||||
|
* @param ownerUserId 当前登录用户 ID
|
||||||
|
* @param chatSessionId 已鉴权的聊天会话 ID
|
||||||
|
* @throws IllegalArgumentException 可信归属不完整时抛出
|
||||||
|
*/
|
||||||
|
public void projectHistoryArtifacts(List<ChatMessageRecord> messages,
|
||||||
|
BigInteger tenantId,
|
||||||
|
BigInteger ownerUserId,
|
||||||
|
BigInteger agentId,
|
||||||
|
BigInteger chatSessionId) {
|
||||||
|
requirePositive(tenantId, "tenantId");
|
||||||
|
requirePositive(ownerUserId, "ownerUserId");
|
||||||
|
requirePositive(agentId, "agentId");
|
||||||
|
requirePositive(chatSessionId, "chatSessionId");
|
||||||
|
if (messages == null || messages.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Set<BigInteger> roundIds = collectHistoryRoundIds(messages, chatSessionId);
|
||||||
|
if (roundIds.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<AgentArtifact> artifacts = mapper.selectListByQuery(QueryWrapper.create()
|
||||||
|
.eq(AgentArtifact::getTenantId, tenantId)
|
||||||
|
.eq(AgentArtifact::getOwnerUserId, ownerUserId)
|
||||||
|
.eq(AgentArtifact::getAgentId, agentId)
|
||||||
|
.eq(AgentArtifact::getChatMode, MODE_FORMAL)
|
||||||
|
.eq(AgentArtifact::getChatSessionId, chatSessionId)
|
||||||
|
.in(AgentArtifact::getRoundId, roundIds)
|
||||||
|
.orderBy(AgentArtifact::getId, true));
|
||||||
|
Map<String, AgentArtifact> ledgerById = new LinkedHashMap<>();
|
||||||
|
Map<BigInteger, List<AgentArtifact>> ledgerByRound = new LinkedHashMap<>();
|
||||||
|
for (AgentArtifact artifact : artifacts) {
|
||||||
|
if (artifact != null && StringUtils.hasText(artifact.getArtifactId())) {
|
||||||
|
ledgerById.put(artifact.getArtifactId(), artifact);
|
||||||
|
ledgerByRound.computeIfAbsent(artifact.getRoundId(), ignored -> new ArrayList<>()).add(artifact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (ChatMessageRecord message : messages) {
|
||||||
|
projectMessageArtifacts(message, chatSessionId, ledgerById, ledgerByRound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将正式聊天会话的全部产物标记为待删除。
|
||||||
|
*
|
||||||
|
* @param chatSessionId 聊天会话 ID
|
||||||
|
*/
|
||||||
|
public void markSessionDeletePending(BigInteger tenantId,
|
||||||
|
BigInteger ownerUserId,
|
||||||
|
BigInteger agentId,
|
||||||
|
BigInteger chatSessionId) {
|
||||||
|
requirePositive(tenantId, "tenantId");
|
||||||
|
requirePositive(ownerUserId, "ownerUserId");
|
||||||
|
requirePositive(agentId, "agentId");
|
||||||
|
requirePositive(chatSessionId, "chatSessionId");
|
||||||
|
AgentArtifact update = new AgentArtifact();
|
||||||
|
update.setStatus(AgentArtifactStatus.DELETE_PENDING.name());
|
||||||
|
update.setNextRetryAt(new Date());
|
||||||
|
update.setModified(new Date());
|
||||||
|
mapper.updateByQuery(update, QueryWrapper.create()
|
||||||
|
.eq(AgentArtifact::getTenantId, tenantId)
|
||||||
|
.eq(AgentArtifact::getOwnerUserId, ownerUserId)
|
||||||
|
.eq(AgentArtifact::getAgentId, agentId)
|
||||||
|
.eq(AgentArtifact::getChatMode, MODE_FORMAL)
|
||||||
|
.eq(AgentArtifact::getChatSessionId, chatSessionId)
|
||||||
|
.in(AgentArtifact::getStatus,
|
||||||
|
AgentArtifactStatus.PUBLISHING.name(),
|
||||||
|
AgentArtifactStatus.AVAILABLE.name(),
|
||||||
|
AgentArtifactStatus.FAILED.name(),
|
||||||
|
AgentArtifactStatus.DELETE_FAILED.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将指定草稿 Runtime 会话的产物标记为待删除。
|
||||||
|
*
|
||||||
|
* @param runtimeSessionId 草稿会话 ID
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param ownerUserId 所有者用户 ID
|
||||||
|
*/
|
||||||
|
public void markDraftSessionDeletePending(String runtimeSessionId,
|
||||||
|
BigInteger tenantId,
|
||||||
|
BigInteger ownerUserId) {
|
||||||
|
if (!StringUtils.hasText(runtimeSessionId) || tenantId == null || ownerUserId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AgentArtifact update = new AgentArtifact();
|
||||||
|
update.setStatus(AgentArtifactStatus.DELETE_PENDING.name());
|
||||||
|
update.setNextRetryAt(new Date());
|
||||||
|
update.setModified(new Date());
|
||||||
|
mapper.updateByQuery(update, QueryWrapper.create()
|
||||||
|
.eq(AgentArtifact::getTenantId, tenantId)
|
||||||
|
.eq(AgentArtifact::getChatMode, MODE_DRAFT)
|
||||||
|
.eq(AgentArtifact::getRuntimeSessionId, runtimeSessionId)
|
||||||
|
.eq(AgentArtifact::getOwnerUserId, ownerUserId)
|
||||||
|
.in(AgentArtifact::getStatus,
|
||||||
|
AgentArtifactStatus.PUBLISHING.name(),
|
||||||
|
AgentArtifactStatus.AVAILABLE.name(),
|
||||||
|
AgentArtifactStatus.FAILED.name(),
|
||||||
|
AgentArtifactStatus.DELETE_FAILED.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除一条待清理对象并更新终态。
|
||||||
|
*
|
||||||
|
* @param artifact 待清理记录
|
||||||
|
*/
|
||||||
|
public void deleteObject(AgentArtifact artifact) {
|
||||||
|
if (artifact == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
objectStorage.delete(artifact.getObjectKey());
|
||||||
|
AgentArtifact update = new AgentArtifact();
|
||||||
|
update.setStatus(AgentArtifactStatus.DELETED.name());
|
||||||
|
update.setNextRetryAt(null);
|
||||||
|
update.setLastErrorCode(null);
|
||||||
|
update.setModified(new Date());
|
||||||
|
mapper.updateByQuery(update, QueryWrapper.create().eq(AgentArtifact::getId, artifact.getId()));
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
int retries = artifact.getRetryCount() == null ? 1 : artifact.getRetryCount() + 1;
|
||||||
|
AgentArtifact update = new AgentArtifact();
|
||||||
|
update.setStatus(AgentArtifactStatus.DELETE_FAILED.name());
|
||||||
|
update.setRetryCount(retries);
|
||||||
|
update.setNextRetryAt(Date.from(Instant.now().plusSeconds(Math.min(3_600L, 60L << Math.min(retries, 5)))));
|
||||||
|
update.setLastErrorCode("ARTIFACT_STORAGE_UNAVAILABLE");
|
||||||
|
update.setModified(new Date());
|
||||||
|
mapper.updateByQuery(update, QueryWrapper.create().eq(AgentArtifact::getId, artifact.getId()));
|
||||||
|
LOG.error("Agent Artifact object cleanup failed, artifactId={}", artifact.getArtifactId(), error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换数据库记录为安全视图。
|
||||||
|
*
|
||||||
|
* @param artifact 账本记录
|
||||||
|
* @return 安全视图
|
||||||
|
*/
|
||||||
|
public AgentArtifactView toView(AgentArtifact artifact) {
|
||||||
|
String downloadUrl = AgentArtifactStatus.AVAILABLE.name().equals(artifact.getStatus())
|
||||||
|
? downloadUrl(artifact)
|
||||||
|
: null;
|
||||||
|
return new AgentArtifactView(
|
||||||
|
1, artifact.getArtifactId(), artifact.getFileName(), artifact.getMimeType(),
|
||||||
|
artifact.getSizeBytes() == null ? 0L : artifact.getSizeBytes(), artifact.getSha256(),
|
||||||
|
downloadUrl, artifact.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ToolIdentity requireIdentity(AgentToolContext context, String mode) {
|
||||||
|
if (context == null || context.getRuntimeContext() == null) {
|
||||||
|
throw new AgentArtifactOperationException("ARTIFACT_ACCESS_DENIED", "产物调用上下文缺失", false);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
BigInteger tenantId = positiveId(context.getRuntimeContext().getTenantId());
|
||||||
|
BigInteger userId = positiveId(context.getRuntimeContext().getUserId());
|
||||||
|
BigInteger agentId = positiveId(context.getAgentId());
|
||||||
|
String sessionId = context.getSessionId();
|
||||||
|
if (!StringUtils.hasText(sessionId) || !StringUtils.hasText(context.getRequestId())
|
||||||
|
|| !StringUtils.hasText(context.getToolCallId())) {
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
String safeMode = MODE_DRAFT.equals(mode) ? MODE_DRAFT : MODE_FORMAL;
|
||||||
|
BigInteger chatSessionId = MODE_FORMAL.equals(safeMode) ? positiveId(sessionId) : null;
|
||||||
|
BigInteger roundId = MODE_FORMAL.equals(safeMode)
|
||||||
|
? positiveId(String.valueOf(context.getRuntimeContext().getMetadata()
|
||||||
|
.get(ChatRuntimeExtKeys.CURRENT_ROUND_ID))) : null;
|
||||||
|
Integer variantIndex = MODE_FORMAL.equals(safeMode)
|
||||||
|
? positiveInteger(context.getRuntimeContext().getMetadata()
|
||||||
|
.get(ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX)) : null;
|
||||||
|
return new ToolIdentity(
|
||||||
|
tenantId, userId, agentId, sessionId, chatSessionId, roundId, variantIndex, safeMode);
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
throw new AgentArtifactOperationException("ARTIFACT_ACCESS_DENIED", "产物调用归属不完整", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentArtifact publishingRecord(String artifactId,
|
||||||
|
String fileName,
|
||||||
|
String mimeType,
|
||||||
|
long size,
|
||||||
|
String objectKey,
|
||||||
|
ToolIdentity identity,
|
||||||
|
AgentToolContext context) {
|
||||||
|
Date now = new Date();
|
||||||
|
AgentArtifact artifact = new AgentArtifact();
|
||||||
|
artifact.setArtifactId(artifactId);
|
||||||
|
artifact.setTenantId(identity.tenantId());
|
||||||
|
artifact.setAgentId(identity.agentId());
|
||||||
|
artifact.setOwnerUserId(identity.userId());
|
||||||
|
artifact.setChatMode(identity.mode());
|
||||||
|
artifact.setChatSessionId(identity.chatSessionId());
|
||||||
|
artifact.setRoundId(identity.roundId());
|
||||||
|
artifact.setVariantIndex(identity.variantIndex());
|
||||||
|
artifact.setRuntimeSessionId(identity.runtimeSessionId());
|
||||||
|
artifact.setRequestId(context.getRequestId());
|
||||||
|
artifact.setToolCallId(context.getToolCallId());
|
||||||
|
artifact.setFileName(fileName);
|
||||||
|
artifact.setMimeType(mimeType);
|
||||||
|
artifact.setSizeBytes(size);
|
||||||
|
artifact.setStoragePlatform(AgentArtifactObjectStorage.PLATFORM);
|
||||||
|
artifact.setObjectKey(objectKey);
|
||||||
|
artifact.setStatus(AgentArtifactStatus.PUBLISHING.name());
|
||||||
|
artifact.setNextRetryAt(Date.from(Instant.now().plusSeconds(PUBLISH_RECOVERY_TIMEOUT_SECONDS)));
|
||||||
|
artifact.setExpiresAt(MODE_DRAFT.equals(identity.mode())
|
||||||
|
? Date.from(Instant.now().plusSeconds(24 * 60 * 60L)) : null);
|
||||||
|
artifact.setRetryCount(0);
|
||||||
|
artifact.setCreated(now);
|
||||||
|
artifact.setCreatedBy(identity.userId());
|
||||||
|
artifact.setModified(now);
|
||||||
|
artifact.setModifiedBy(identity.userId());
|
||||||
|
artifact.setIsDeleted(0);
|
||||||
|
return artifact;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateDownloadScope(AgentArtifact artifact,
|
||||||
|
BigInteger expectedAgentId,
|
||||||
|
String expectedMode,
|
||||||
|
BigInteger expectedSessionId,
|
||||||
|
String expectedRuntimeSessionId) {
|
||||||
|
if (!Objects.equals(artifact.getAgentId(), expectedAgentId)
|
||||||
|
|| !artifact.getChatMode().equalsIgnoreCase(expectedMode)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "产物不属于当前 Agent 会话");
|
||||||
|
}
|
||||||
|
if (MODE_FORMAL.equals(artifact.getChatMode())) {
|
||||||
|
if (expectedSessionId == null || expectedSessionId.signum() <= 0
|
||||||
|
|| StringUtils.hasText(expectedRuntimeSessionId)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "产物不属于当前正式会话");
|
||||||
|
}
|
||||||
|
if (!Objects.equals(artifact.getChatSessionId(), expectedSessionId)
|
||||||
|
|| chatSessionQueryService == null || artifact.getChatSessionId() == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.GONE, "产物所属会话已失效");
|
||||||
|
}
|
||||||
|
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(artifact.getChatSessionId());
|
||||||
|
boolean valid = summary != null
|
||||||
|
&& !Integer.valueOf(1).equals(summary.getIsDeleted())
|
||||||
|
&& "AGENT".equals(summary.getAssistantCode())
|
||||||
|
&& Objects.equals(summary.getTenantId(), artifact.getTenantId())
|
||||||
|
&& Objects.equals(summary.getUserId(), artifact.getOwnerUserId())
|
||||||
|
&& Objects.equals(summary.getAssistantId(), artifact.getAgentId());
|
||||||
|
if (!valid) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.GONE, "产物所属会话已失效");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean validDraft = MODE_DRAFT.equals(artifact.getChatMode())
|
||||||
|
&& expectedSessionId == null
|
||||||
|
&& artifact.getAgentId() != null && artifact.getAgentId().signum() > 0
|
||||||
|
&& StringUtils.hasText(artifact.getRuntimeSessionId())
|
||||||
|
&& Objects.equals(artifact.getRuntimeSessionId(), expectedRuntimeSessionId)
|
||||||
|
&& artifact.getChatSessionId() == null;
|
||||||
|
if (!validDraft) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.CONFLICT, "产物归属记录不完整");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String downloadUrl(AgentArtifact artifact) {
|
||||||
|
String base = "/api/v1/agent/artifacts/%s/content?agentId=%s&mode=%s".formatted(
|
||||||
|
artifact.getArtifactId(), artifact.getAgentId(), artifact.getChatMode());
|
||||||
|
if (MODE_FORMAL.equals(artifact.getChatMode())) {
|
||||||
|
return base + "&sessionId=" + artifact.getChatSessionId();
|
||||||
|
}
|
||||||
|
return base + "&runtimeSessionId=" + artifact.getRuntimeSessionId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<BigInteger> collectHistoryRoundIds(List<ChatMessageRecord> messages,
|
||||||
|
BigInteger chatSessionId) {
|
||||||
|
Set<BigInteger> roundIds = new LinkedHashSet<>();
|
||||||
|
for (ChatMessageRecord message : messages) {
|
||||||
|
if (message != null && Objects.equals(message.getSessionId(), chatSessionId)
|
||||||
|
&& message.getRoundId() != null) {
|
||||||
|
roundIds.add(message.getRoundId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roundIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void projectMessageArtifacts(ChatMessageRecord message,
|
||||||
|
BigInteger chatSessionId,
|
||||||
|
Map<String, AgentArtifact> ledgerById,
|
||||||
|
Map<BigInteger, List<AgentArtifact>> ledgerByRound) {
|
||||||
|
if (message == null || !Objects.equals(message.getSessionId(), chatSessionId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, Object> originalPayload = message.getContentPayload();
|
||||||
|
Object rawArtifacts = originalPayload == null ? null : originalPayload.get("artifacts");
|
||||||
|
List<?> list = rawArtifacts instanceof List<?> values ? values : List.of();
|
||||||
|
boolean assistantMessage = "assistant".equalsIgnoreCase(message.getSenderRole());
|
||||||
|
List<AgentArtifact> variantArtifacts = ledgerByRound.getOrDefault(message.getRoundId(), List.of())
|
||||||
|
.stream()
|
||||||
|
.filter(ledger -> Objects.equals(ledger.getVariantIndex(), message.getVariantIndex()))
|
||||||
|
.toList();
|
||||||
|
if (list.isEmpty() && (!assistantMessage || variantArtifacts.isEmpty())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<Map<String, Object>> projected = new ArrayList<>(list.size());
|
||||||
|
Set<String> projectedIds = new LinkedHashSet<>();
|
||||||
|
for (Object item : list) {
|
||||||
|
if (!(item instanceof Map<?, ?> oldView)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String artifactId = safeString(oldView.get("artifactId"));
|
||||||
|
AgentArtifact ledger = ledgerById.get(artifactId);
|
||||||
|
boolean sameRound = ledger != null
|
||||||
|
&& Objects.equals(ledger.getRoundId(), message.getRoundId())
|
||||||
|
&& Objects.equals(ledger.getVariantIndex(), message.getVariantIndex())
|
||||||
|
&& Objects.equals(message.getSessionId(), chatSessionId);
|
||||||
|
projected.add(sameRound ? toView(ledger).toMap() : unavailableView(oldView, artifactId));
|
||||||
|
if (StringUtils.hasText(artifactId)) {
|
||||||
|
projectedIds.add(artifactId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (assistantMessage) {
|
||||||
|
for (AgentArtifact ledger : variantArtifacts) {
|
||||||
|
if (projectedIds.add(ledger.getArtifactId())) {
|
||||||
|
projected.add(toView(ledger).toMap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<String, Object> payload = originalPayload == null
|
||||||
|
? new LinkedHashMap<>() : new LinkedHashMap<>(originalPayload);
|
||||||
|
payload.put("artifacts", projected);
|
||||||
|
message.setContentPayload(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> unavailableView(Map<?, ?> oldView, String artifactId) {
|
||||||
|
Object sizeValue = oldView.get("size");
|
||||||
|
long size = sizeValue instanceof Number number ? Math.max(0L, number.longValue()) : 0L;
|
||||||
|
return new AgentArtifactView(
|
||||||
|
1,
|
||||||
|
artifactId,
|
||||||
|
safeString(oldView.get("fileName")),
|
||||||
|
safeString(oldView.get("mimeType")),
|
||||||
|
size,
|
||||||
|
safeString(oldView.get("sha256")),
|
||||||
|
null,
|
||||||
|
UNAVAILABLE_STATUS).toMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeString(Object value) {
|
||||||
|
return value instanceof String text ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requirePositive(BigInteger value, String field) {
|
||||||
|
if (value == null || value.signum() <= 0) {
|
||||||
|
throw new IllegalArgumentException(field + " must be positive");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void compensatePublishFailure(AgentArtifact artifact,
|
||||||
|
boolean uploadAttempted,
|
||||||
|
String code,
|
||||||
|
Throwable error) {
|
||||||
|
boolean cleanupFailed = false;
|
||||||
|
if (uploadAttempted) {
|
||||||
|
try {
|
||||||
|
objectStorage.delete(artifact.getObjectKey());
|
||||||
|
} catch (RuntimeException cleanupError) {
|
||||||
|
cleanupFailed = true;
|
||||||
|
error.addSuppressed(cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AgentArtifact update = new AgentArtifact();
|
||||||
|
update.setStatus(cleanupFailed
|
||||||
|
? AgentArtifactStatus.DELETE_FAILED.name() : AgentArtifactStatus.FAILED.name());
|
||||||
|
update.setLastErrorCode(code);
|
||||||
|
update.setRetryCount(cleanupFailed ? 1 : 0);
|
||||||
|
update.setNextRetryAt(cleanupFailed ? new Date() : null);
|
||||||
|
update.setModified(new Date());
|
||||||
|
QueryWrapper condition = QueryWrapper.create()
|
||||||
|
.eq(AgentArtifact::getId, artifact.getId());
|
||||||
|
if (cleanupFailed) {
|
||||||
|
condition.in(AgentArtifact::getStatus,
|
||||||
|
AgentArtifactStatus.PUBLISHING.name(),
|
||||||
|
AgentArtifactStatus.DELETE_PENDING.name());
|
||||||
|
} else {
|
||||||
|
// 会话删除已把记录置为 DELETE_PENDING 时,保留删除意图交给调度器幂等收口。
|
||||||
|
condition.eq(AgentArtifact::getStatus, AgentArtifactStatus.PUBLISHING.name());
|
||||||
|
}
|
||||||
|
mapper.updateByQuery(update, condition);
|
||||||
|
LOG.error("Agent Artifact publish failed, artifactId={}", artifact.getArtifactId(), error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markDeletePending(AgentArtifact artifact) {
|
||||||
|
AgentArtifact update = new AgentArtifact();
|
||||||
|
update.setStatus(AgentArtifactStatus.DELETE_PENDING.name());
|
||||||
|
update.setNextRetryAt(new Date());
|
||||||
|
update.setModified(new Date());
|
||||||
|
mapper.updateByQuery(update, QueryWrapper.create()
|
||||||
|
.eq(AgentArtifact::getId, artifact.getId())
|
||||||
|
.eq(AgentArtifact::getStatus, AgentArtifactStatus.AVAILABLE.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long fileSize(Path file) {
|
||||||
|
try {
|
||||||
|
return Files.size(file);
|
||||||
|
} catch (IOException error) {
|
||||||
|
throw new AgentArtifactOperationException(
|
||||||
|
"WORKSPACE_FILE_NOT_FOUND", "读取工作区文件大小失败", true, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String mimeType(Path file) {
|
||||||
|
byte[] header = new byte[512];
|
||||||
|
int length;
|
||||||
|
try (InputStream input = Files.newInputStream(file)) {
|
||||||
|
length = input.read(header);
|
||||||
|
} catch (IOException error) {
|
||||||
|
throw new AgentArtifactOperationException(
|
||||||
|
"WORKSPACE_FILE_NOT_FOUND", "读取工作区文件类型失败", true, error);
|
||||||
|
}
|
||||||
|
if (length < 0) {
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
if (startsWith(header, length, new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A})) {
|
||||||
|
return "image/png";
|
||||||
|
}
|
||||||
|
if (startsWith(header, length, new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF})) {
|
||||||
|
return "image/jpeg";
|
||||||
|
}
|
||||||
|
if (startsWith(header, length, "GIF87a".getBytes(StandardCharsets.US_ASCII))
|
||||||
|
|| startsWith(header, length, "GIF89a".getBytes(StandardCharsets.US_ASCII))) {
|
||||||
|
return "image/gif";
|
||||||
|
}
|
||||||
|
if (startsWith(header, length, "%PDF-".getBytes(StandardCharsets.US_ASCII))) {
|
||||||
|
return "application/pdf";
|
||||||
|
}
|
||||||
|
if (startsWith(header, length, new byte[]{'P', 'K', 0x03, 0x04})
|
||||||
|
|| startsWith(header, length, new byte[]{'P', 'K', 0x05, 0x06})
|
||||||
|
|| startsWith(header, length, new byte[]{'P', 'K', 0x07, 0x08})) {
|
||||||
|
return officeOpenXmlMime(file);
|
||||||
|
}
|
||||||
|
if (isSafeUtf8Text(header, length)) {
|
||||||
|
return "text/plain";
|
||||||
|
}
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String officeOpenXmlMime(Path file) {
|
||||||
|
if (!hasBoundedClassicZipDirectory(file)) {
|
||||||
|
return "application/zip";
|
||||||
|
}
|
||||||
|
boolean contentTypes = false;
|
||||||
|
String documentType = null;
|
||||||
|
long declaredSize = 0L;
|
||||||
|
try (ZipFile zipFile = new ZipFile(file.toFile())) {
|
||||||
|
if (zipFile.size() > MAX_ZIP_ENTRIES) {
|
||||||
|
return "application/zip";
|
||||||
|
}
|
||||||
|
Enumeration<? extends ZipEntry> entries = zipFile.entries();
|
||||||
|
int inspected = 0;
|
||||||
|
while (entries.hasMoreElements() && inspected++ < MAX_ZIP_ENTRIES) {
|
||||||
|
ZipEntry entry = entries.nextElement();
|
||||||
|
if (isSuspiciousZipEntry(entry, declaredSize)) {
|
||||||
|
return "application/zip";
|
||||||
|
}
|
||||||
|
declaredSize += Math.max(0L, entry.getSize());
|
||||||
|
String name = entry.getName();
|
||||||
|
contentTypes |= "[Content_Types].xml".equals(name);
|
||||||
|
if (name.startsWith("word/")) {
|
||||||
|
documentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
} else if (name.startsWith("xl/")) {
|
||||||
|
documentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||||
|
} else if (name.startsWith("ppt/")) {
|
||||||
|
documentType = "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
return "application/zip";
|
||||||
|
}
|
||||||
|
return contentTypes && documentType != null ? documentType : "application/zip";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在构造 {@link ZipFile} 前以常量内存校验经典 ZIP 的中央目录边界和实际条目数。
|
||||||
|
*
|
||||||
|
* @param file 待识别 ZIP 文件
|
||||||
|
* @return 中央目录可安全交给 ZipFile 解析时为 true
|
||||||
|
*/
|
||||||
|
private boolean hasBoundedClassicZipDirectory(Path file) {
|
||||||
|
try (SeekableByteChannel channel = Files.newByteChannel(file, StandardOpenOption.READ)) {
|
||||||
|
long archiveSize = channel.size();
|
||||||
|
if (archiveSize < ZIP_EOCD_MIN_SIZE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int tailSize = (int) Math.min(archiveSize, ZIP_EOCD_MAX_SIZE);
|
||||||
|
long tailOffset = archiveSize - tailSize;
|
||||||
|
ByteBuffer tail = readZipAt(channel, tailOffset, tailSize);
|
||||||
|
int eocdIndex = findZipEocd(tail);
|
||||||
|
if (eocdIndex < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int diskNumber = unsignedZipShort(tail, eocdIndex + 4);
|
||||||
|
int directoryDisk = unsignedZipShort(tail, eocdIndex + 6);
|
||||||
|
int entriesOnDisk = unsignedZipShort(tail, eocdIndex + 8);
|
||||||
|
int totalEntries = unsignedZipShort(tail, eocdIndex + 10);
|
||||||
|
long directorySize = unsignedZipInt(tail, eocdIndex + 12);
|
||||||
|
long directoryOffset = unsignedZipInt(tail, eocdIndex + 16);
|
||||||
|
if (diskNumber == ZIP_UINT16_MAX || directoryDisk == ZIP_UINT16_MAX
|
||||||
|
|| entriesOnDisk == ZIP_UINT16_MAX || totalEntries == ZIP_UINT16_MAX
|
||||||
|
|| directorySize == ZIP_UINT32_MAX || directoryOffset == ZIP_UINT32_MAX
|
||||||
|
|| diskNumber != 0 || directoryDisk != 0 || entriesOnDisk != totalEntries
|
||||||
|
|| totalEntries > MAX_ZIP_ENTRIES
|
||||||
|
|| directorySize > MAX_ZIP_CENTRAL_DIRECTORY_SIZE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
long eocdOffset = tailOffset + eocdIndex;
|
||||||
|
long directoryEnd = Math.addExact(directoryOffset, directorySize);
|
||||||
|
if (directoryEnd != eocdOffset || directoryEnd > archiveSize) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return hasExpectedCentralDirectoryEntries(
|
||||||
|
channel, directoryOffset, directoryEnd, totalEntries);
|
||||||
|
} catch (IOException | ArithmeticException ignored) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有界扫描中央目录头,防止伪造较小 EOCD 条目数绕过预检。
|
||||||
|
*
|
||||||
|
* @param channel ZIP 文件通道
|
||||||
|
* @param position 中央目录起点
|
||||||
|
* @param end 中央目录终点
|
||||||
|
* @param expectedEntries EOCD 声明条目数
|
||||||
|
* @return 实际结构和数量一致时为 true
|
||||||
|
* @throws IOException 读取失败或文件截断时抛出
|
||||||
|
*/
|
||||||
|
private boolean hasExpectedCentralDirectoryEntries(SeekableByteChannel channel,
|
||||||
|
long position,
|
||||||
|
long end,
|
||||||
|
int expectedEntries) throws IOException {
|
||||||
|
int actualEntries = 0;
|
||||||
|
while (position < end) {
|
||||||
|
if (end - position < ZIP_CENTRAL_DIRECTORY_HEADER_SIZE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ByteBuffer header = readZipAt(channel, position, ZIP_CENTRAL_DIRECTORY_HEADER_SIZE);
|
||||||
|
if (header.getInt(0) != ZIP_CENTRAL_DIRECTORY_SIGNATURE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
long variableSize = (long) unsignedZipShort(header, 28)
|
||||||
|
+ unsignedZipShort(header, 30)
|
||||||
|
+ unsignedZipShort(header, 32);
|
||||||
|
position = Math.addExact(position,
|
||||||
|
Math.addExact((long) ZIP_CENTRAL_DIRECTORY_HEADER_SIZE, variableSize));
|
||||||
|
if (position > end || ++actualEntries > MAX_ZIP_ENTRIES) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return position == end && actualEntries == expectedEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在文件尾缓冲区中定位与注释长度一致的 EOCD。
|
||||||
|
*
|
||||||
|
* @param tail ZIP 文件尾缓冲区
|
||||||
|
* @return EOCD 相对偏移,未找到时返回 -1
|
||||||
|
*/
|
||||||
|
private int findZipEocd(ByteBuffer tail) {
|
||||||
|
for (int index = tail.limit() - ZIP_EOCD_MIN_SIZE; index >= 0; index--) {
|
||||||
|
if (tail.getInt(index) == ZIP_EOCD_SIGNATURE) {
|
||||||
|
int commentLength = unsignedZipShort(tail, index + 20);
|
||||||
|
if (index + ZIP_EOCD_MIN_SIZE + commentLength == tail.limit()) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从通道指定位置完整读取固定长度的小端序数据。
|
||||||
|
*
|
||||||
|
* @param channel ZIP 文件通道
|
||||||
|
* @param position 起始偏移
|
||||||
|
* @param length 读取长度
|
||||||
|
* @return 已翻转的小端序缓冲区
|
||||||
|
* @throws IOException 读取失败或文件截断时抛出
|
||||||
|
*/
|
||||||
|
private ByteBuffer readZipAt(SeekableByteChannel channel, long position, int length) throws IOException {
|
||||||
|
if (position < 0L || length < 0 || position > channel.size() - length) {
|
||||||
|
throw new IOException("ZIP record exceeds archive bounds");
|
||||||
|
}
|
||||||
|
ByteBuffer buffer = ByteBuffer.allocate(length).order(ByteOrder.LITTLE_ENDIAN);
|
||||||
|
channel.position(position);
|
||||||
|
while (buffer.hasRemaining()) {
|
||||||
|
if (channel.read(buffer) <= 0) {
|
||||||
|
throw new IOException("ZIP record is truncated");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buffer.flip();
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取小端序无符号 16 位整数。
|
||||||
|
*
|
||||||
|
* @param buffer 来源缓冲区
|
||||||
|
* @param offset 字段偏移
|
||||||
|
* @return 无符号整数值
|
||||||
|
*/
|
||||||
|
private int unsignedZipShort(ByteBuffer buffer, int offset) {
|
||||||
|
return Short.toUnsignedInt(buffer.getShort(offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取小端序无符号 32 位整数。
|
||||||
|
*
|
||||||
|
* @param buffer 来源缓冲区
|
||||||
|
* @param offset 字段偏移
|
||||||
|
* @return 无符号长整数值
|
||||||
|
*/
|
||||||
|
private long unsignedZipInt(ByteBuffer buffer, int offset) {
|
||||||
|
return Integer.toUnsignedLong(buffer.getInt(offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅依据 central directory 元数据识别可能造成过量展开的 ZIP 条目。
|
||||||
|
*
|
||||||
|
* @param entry ZIP 条目元数据
|
||||||
|
* @param accumulatedSize 已累计声明展开大小
|
||||||
|
* @return 条目超出结构探测安全边界时为 true
|
||||||
|
*/
|
||||||
|
private boolean isSuspiciousZipEntry(ZipEntry entry, long accumulatedSize) {
|
||||||
|
String name = entry.getName();
|
||||||
|
long size = entry.getSize();
|
||||||
|
long compressedSize = entry.getCompressedSize();
|
||||||
|
if (name == null || name.length() > MAX_ZIP_ENTRY_NAME_LENGTH
|
||||||
|
|| size < 0L || compressedSize < 0L
|
||||||
|
|| size > MAX_ZIP_DECLARED_SIZE - accumulatedSize) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (size == 0L) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return compressedSize == 0L
|
||||||
|
|| (double) size / (double) compressedSize > MAX_ZIP_COMPRESSION_RATIO;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void verifyStoredObject(String objectKey, long expectedSize, String expectedSha256) throws IOException {
|
||||||
|
AgentArtifactObjectStorage.StoredObjectMetadata metadata = objectStorage.stat(objectKey);
|
||||||
|
if (metadata == null || metadata.size() != expectedSize) {
|
||||||
|
throw new AgentArtifactOperationException(
|
||||||
|
"ARTIFACT_STORAGE_VERIFY_FAILED", "产物对象大小校验失败", true);
|
||||||
|
}
|
||||||
|
MessageDigest digest;
|
||||||
|
try {
|
||||||
|
digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
} catch (NoSuchAlgorithmException error) {
|
||||||
|
throw new IllegalStateException("SHA-256 算法不可用", error);
|
||||||
|
}
|
||||||
|
long actualSize = 0L;
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
try (InputStream raw = objectStorage.open(objectKey);
|
||||||
|
DigestInputStream input = new DigestInputStream(raw, digest)) {
|
||||||
|
int read;
|
||||||
|
while ((read = input.read(buffer)) != -1) {
|
||||||
|
if (read > 0) {
|
||||||
|
actualSize += read;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String actualSha256 = HexFormat.of().formatHex(digest.digest());
|
||||||
|
if (actualSize != expectedSize || !expectedSha256.equals(actualSha256)) {
|
||||||
|
throw new AgentArtifactOperationException(
|
||||||
|
"ARTIFACT_STORAGE_VERIFY_FAILED", "产物对象内容校验失败", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean startsWith(byte[] source, int length, byte[] prefix) {
|
||||||
|
if (length < prefix.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int index = 0; index < prefix.length; index++) {
|
||||||
|
if (source[index] != prefix[index]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isSafeUtf8Text(byte[] source, int length) {
|
||||||
|
for (int index = 0; index < length; index++) {
|
||||||
|
int value = source[index] & 0xFF;
|
||||||
|
if (value == 0 || value < 0x09 || value > 0x0D && value < 0x20) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String decoded = new String(source, 0, length, StandardCharsets.UTF_8);
|
||||||
|
return !decoded.contains("\uFFFD");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeFileName(String requested, String fallback) {
|
||||||
|
String value = StringUtils.hasText(requested) ? requested.trim() : fallback;
|
||||||
|
value = value.replaceAll("[\\r\\n\\u0000-\\u001f\\u007f]", "_");
|
||||||
|
if (value.contains("/") || value.contains("\\\\") || ".".equals(value) || "..".equals(value)) {
|
||||||
|
throw new AgentArtifactOperationException("ARTIFACT_PUBLISH_FAILED", "产物文件名不合法", false);
|
||||||
|
}
|
||||||
|
if (value.length() > MAX_FILE_NAME_LENGTH) {
|
||||||
|
value = value.substring(0, MAX_FILE_NAME_LENGTH);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeExtension(String fileName) {
|
||||||
|
int dot = fileName.lastIndexOf('.');
|
||||||
|
if (dot < 0 || dot == fileName.length() - 1) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String extension = fileName.substring(dot).toLowerCase(Locale.ROOT);
|
||||||
|
return extension.matches("\\.[a-z0-9]{1,16}") ? extension : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigInteger positiveId(String value) {
|
||||||
|
BigInteger id = new BigInteger(value);
|
||||||
|
if (id.signum() <= 0) {
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer positiveInteger(Object value) {
|
||||||
|
int number = value instanceof Number numeric
|
||||||
|
? numeric.intValue() : Integer.parseInt(String.valueOf(value));
|
||||||
|
if (number <= 0) {
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String opaqueId() {
|
||||||
|
return UUID.randomUUID().toString().replace("-", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ToolIdentity(BigInteger tenantId,
|
||||||
|
BigInteger userId,
|
||||||
|
BigInteger agentId,
|
||||||
|
String runtimeSessionId,
|
||||||
|
BigInteger chatSessionId,
|
||||||
|
BigInteger roundId,
|
||||||
|
Integer variantIndex,
|
||||||
|
String mode) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package tech.easyflow.agent.runtime.artifact;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent Artifact 跨数据库与对象存储的状态。
|
||||||
|
*/
|
||||||
|
public enum AgentArtifactStatus {
|
||||||
|
/** 正在上传。 */
|
||||||
|
PUBLISHING,
|
||||||
|
/** 可下载。 */
|
||||||
|
AVAILABLE,
|
||||||
|
/** 发布失败。 */
|
||||||
|
FAILED,
|
||||||
|
/** 等待删除。 */
|
||||||
|
DELETE_PENDING,
|
||||||
|
/** 删除失败且等待重试。 */
|
||||||
|
DELETE_FAILED,
|
||||||
|
/** 已删除。 */
|
||||||
|
DELETED
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package tech.easyflow.agent.runtime.artifact;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Artifact Tool、AG-UI 与管理端共用的安全视图。
|
||||||
|
*
|
||||||
|
* @param schemaVersion 结构版本
|
||||||
|
* @param artifactId 稳定产物 ID
|
||||||
|
* @param fileName 安全文件名
|
||||||
|
* @param mimeType MIME 类型
|
||||||
|
* @param size 文件字节数
|
||||||
|
* @param sha256 文件 SHA-256
|
||||||
|
* @param downloadUrl 鉴权下载地址
|
||||||
|
* @param status 可公开状态
|
||||||
|
*/
|
||||||
|
public record AgentArtifactView(int schemaVersion,
|
||||||
|
String artifactId,
|
||||||
|
String fileName,
|
||||||
|
String mimeType,
|
||||||
|
long size,
|
||||||
|
String sha256,
|
||||||
|
String downloadUrl,
|
||||||
|
String status) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换为稳定字段顺序的安全 Map。
|
||||||
|
*
|
||||||
|
* @return 不含对象存储定位信息的 Map
|
||||||
|
*/
|
||||||
|
public Map<String, Object> toMap() {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("schemaVersion", schemaVersion);
|
||||||
|
result.put("artifactId", artifactId);
|
||||||
|
result.put("fileName", fileName);
|
||||||
|
result.put("mimeType", mimeType);
|
||||||
|
result.put("size", size);
|
||||||
|
result.put("sha256", sha256);
|
||||||
|
result.put("downloadUrl", downloadUrl);
|
||||||
|
result.put("status", status);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
|||||||
import tech.easyflow.agent.enums.AgentToolType;
|
import tech.easyflow.agent.enums.AgentToolType;
|
||||||
import tech.easyflow.agent.runtime.tool.AgentToolExecutionResult;
|
import tech.easyflow.agent.runtime.tool.AgentToolExecutionResult;
|
||||||
import tech.easyflow.agent.runtime.tool.PluginToolExecutor;
|
import tech.easyflow.agent.runtime.tool.PluginToolExecutor;
|
||||||
|
import tech.easyflow.ai.entity.Plugin;
|
||||||
import tech.easyflow.ai.entity.PluginItem;
|
import tech.easyflow.ai.entity.PluginItem;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -14,6 +15,7 @@ import java.util.Map;
|
|||||||
public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools {
|
public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools {
|
||||||
|
|
||||||
private final PluginItem pluginItem;
|
private final PluginItem pluginItem;
|
||||||
|
private final Plugin plugin;
|
||||||
private final String toolName;
|
private final String toolName;
|
||||||
private final String displayName;
|
private final String displayName;
|
||||||
private final PluginToolExecutor pluginToolExecutor;
|
private final PluginToolExecutor pluginToolExecutor;
|
||||||
@@ -22,6 +24,7 @@ public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools {
|
|||||||
* 创建 Plugin 异步工具子能力。
|
* 创建 Plugin 异步工具子能力。
|
||||||
*
|
*
|
||||||
* @param pluginItem 插件工具快照
|
* @param pluginItem 插件工具快照
|
||||||
|
* @param plugin 父插件调用配置快照
|
||||||
* @param toolName runtime 工具名
|
* @param toolName runtime 工具名
|
||||||
* @param displayName 用户可见名称
|
* @param displayName 用户可见名称
|
||||||
* @param pluginToolExecutor Plugin 执行器
|
* @param pluginToolExecutor Plugin 执行器
|
||||||
@@ -29,6 +32,7 @@ public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools {
|
|||||||
* @param taskExecutor 后台执行器
|
* @param taskExecutor 后台执行器
|
||||||
*/
|
*/
|
||||||
public PluginAsyncSubTools(PluginItem pluginItem,
|
public PluginAsyncSubTools(PluginItem pluginItem,
|
||||||
|
Plugin plugin,
|
||||||
String toolName,
|
String toolName,
|
||||||
String displayName,
|
String displayName,
|
||||||
PluginToolExecutor pluginToolExecutor,
|
PluginToolExecutor pluginToolExecutor,
|
||||||
@@ -36,6 +40,7 @@ public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools {
|
|||||||
ThreadPoolTaskExecutor taskExecutor) {
|
ThreadPoolTaskExecutor taskExecutor) {
|
||||||
super(taskStore, taskExecutor);
|
super(taskStore, taskExecutor);
|
||||||
this.pluginItem = pluginItem;
|
this.pluginItem = pluginItem;
|
||||||
|
this.plugin = plugin;
|
||||||
this.toolName = toolName;
|
this.toolName = toolName;
|
||||||
this.displayName = displayName;
|
this.displayName = displayName;
|
||||||
this.pluginToolExecutor = pluginToolExecutor;
|
this.pluginToolExecutor = pluginToolExecutor;
|
||||||
@@ -78,6 +83,6 @@ public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
protected AgentToolExecutionResult executeBusiness(Map<String, Object> arguments) {
|
protected AgentToolExecutionResult executeBusiness(Map<String, Object> arguments) {
|
||||||
return pluginToolExecutor.execute(pluginItem, arguments);
|
return pluginToolExecutor.execute(pluginItem, plugin, arguments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ public class MySqlAgentRunEventRecorder implements AgentRunEventRecorder {
|
|||||||
private boolean shouldPersist(AgentRuntimeEventType type) {
|
private boolean shouldPersist(AgentRuntimeEventType type) {
|
||||||
return type != AgentRuntimeEventType.MESSAGE_DELTA
|
return type != AgentRuntimeEventType.MESSAGE_DELTA
|
||||||
&& type != AgentRuntimeEventType.REASONING_DELTA
|
&& type != AgentRuntimeEventType.REASONING_DELTA
|
||||||
|
&& type != AgentRuntimeEventType.SKILL_STEP
|
||||||
&& type != AgentRuntimeEventType.STARTED
|
&& type != AgentRuntimeEventType.STARTED
|
||||||
&& type != AgentRuntimeEventType.COMPLETED;
|
&& type != AgentRuntimeEventType.COMPLETED;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ public class AgentHitlPendingServiceImpl implements AgentHitlPendingService {
|
|||||||
pending.setRequestId(requestId);
|
pending.setRequestId(requestId);
|
||||||
pending.setToolCallId(firstText(event.getToolCallId(), stringValue(event.getPayload().get("toolCallId"))));
|
pending.setToolCallId(firstText(event.getToolCallId(), stringValue(event.getPayload().get("toolCallId"))));
|
||||||
pending.setToolName(stringValue(event.getPayload().get("toolName")));
|
pending.setToolName(stringValue(event.getPayload().get("toolName")));
|
||||||
pending.setToolInputJson(mapValue(firstNonNull(event.getPayload().get("toolInput"), event.getPayload().get("input"))));
|
pending.setToolInputJson(ToolApprovalInputProjection.project(
|
||||||
|
firstNonNull(event.getPayload().get("toolInput"), event.getPayload().get("input"))));
|
||||||
pending.setStatus(AgentHitlPendingStatus.PENDING.name());
|
pending.setStatus(AgentHitlPendingStatus.PENDING.name());
|
||||||
pending.setExpiresAt(resolveExpiresAt(event));
|
pending.setExpiresAt(resolveExpiresAt(event));
|
||||||
pending.setMetadataJson(metadata(event));
|
pending.setMetadataJson(metadata(event));
|
||||||
@@ -272,17 +273,7 @@ public class AgentHitlPendingServiceImpl implements AgentHitlPendingService {
|
|||||||
if (approvalMetadata instanceof Map<?, ?> map) {
|
if (approvalMetadata instanceof Map<?, ?> map) {
|
||||||
map.forEach((key, value) -> metadata.put(String.valueOf(key), value));
|
map.forEach((key, value) -> metadata.put(String.valueOf(key), value));
|
||||||
}
|
}
|
||||||
return metadata;
|
return ToolApprovalInputProjection.project(metadata);
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
private Map<String, Object> mapValue(Object value) {
|
|
||||||
if (value instanceof Map<?, ?> map) {
|
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
|
||||||
map.forEach((key, item) -> result.put(String.valueOf(key), item));
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
return new LinkedHashMap<>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Date dateValue(Object value) {
|
private Date dateValue(Object value) {
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package tech.easyflow.agent.runtime.hitl;
|
||||||
|
|
||||||
|
import java.lang.reflect.Array;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将工具审批输入转换为可展示、可持久化的脱敏副本。
|
||||||
|
*/
|
||||||
|
public final class ToolApprovalInputProjection {
|
||||||
|
|
||||||
|
private static final String REDACTED = "[已隐藏]";
|
||||||
|
private static final Pattern SENSITIVE_KEY = Pattern.compile(
|
||||||
|
".*(token|secret|password|passwd|apikey|authorization|auth|cookie|credential|privatekey|accesskey|header|environment|env).*",
|
||||||
|
Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern SENSITIVE_QUERY = Pattern.compile(
|
||||||
|
"(?i)([?&](?:token|secret|password|passwd|api[_-]?key|authorization|access[_-]?key)=)[^&#\\s]*");
|
||||||
|
private static final Pattern URL_USER_INFO = Pattern.compile(
|
||||||
|
"(?i)([a-z][a-z0-9+.-]*://)[^/@\\s]+:[^/@\\s]+@");
|
||||||
|
|
||||||
|
private ToolApprovalInputProjection() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 投影工具输入,保留普通业务参数并递归遮蔽敏感字段。
|
||||||
|
*
|
||||||
|
* @param value 原始工具输入
|
||||||
|
* @return 不修改原对象的脱敏 Map;输入不是 Map 时返回空 Map
|
||||||
|
*/
|
||||||
|
public static Map<String, Object> project(Object value) {
|
||||||
|
if (!(value instanceof Map<?, ?> source)) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
return projectMap(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归投影 Map。
|
||||||
|
*
|
||||||
|
* @param source 原始 Map
|
||||||
|
* @return 保持字段顺序的脱敏 Map
|
||||||
|
*/
|
||||||
|
private static Map<String, Object> projectMap(Map<?, ?> source) {
|
||||||
|
Map<String, Object> projected = new LinkedHashMap<>();
|
||||||
|
source.forEach((rawKey, rawValue) -> {
|
||||||
|
String key = String.valueOf(rawKey);
|
||||||
|
projected.put(key, isSensitiveKey(key) ? REDACTED : projectValue(rawValue));
|
||||||
|
});
|
||||||
|
return projected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归投影集合、数组、Map 与字符串值。
|
||||||
|
*
|
||||||
|
* @param value 原始值
|
||||||
|
* @return 脱敏副本
|
||||||
|
*/
|
||||||
|
private static Object projectValue(Object value) {
|
||||||
|
if (value instanceof Map<?, ?> map) {
|
||||||
|
return projectMap(map);
|
||||||
|
}
|
||||||
|
if (value instanceof Collection<?> collection) {
|
||||||
|
List<Object> projected = new ArrayList<>(collection.size());
|
||||||
|
collection.forEach(item -> projected.add(projectValue(item)));
|
||||||
|
return projected;
|
||||||
|
}
|
||||||
|
if (value != null && value.getClass().isArray()) {
|
||||||
|
int length = Array.getLength(value);
|
||||||
|
List<Object> projected = new ArrayList<>(length);
|
||||||
|
for (int index = 0; index < length; index++) {
|
||||||
|
projected.add(projectValue(Array.get(value, index)));
|
||||||
|
}
|
||||||
|
return projected;
|
||||||
|
}
|
||||||
|
if (value instanceof String text) {
|
||||||
|
String withoutUserInfo = URL_USER_INFO.matcher(text).replaceAll("$1" + REDACTED + "@");
|
||||||
|
return SENSITIVE_QUERY.matcher(withoutUserInfo).replaceAll("$1" + REDACTED);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断字段名是否表达凭据、认证头或环境配置。
|
||||||
|
*
|
||||||
|
* @param key 原始字段名
|
||||||
|
* @return 需要整值遮蔽时为 true
|
||||||
|
*/
|
||||||
|
private static boolean isSensitiveKey(String key) {
|
||||||
|
String normalized = key == null ? "" : key
|
||||||
|
.replaceAll("[^A-Za-z0-9]", "")
|
||||||
|
.toLowerCase(Locale.ROOT);
|
||||||
|
return SENSITIVE_KEY.matcher(normalized).matches();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,17 @@ public interface AgentRunLock {
|
|||||||
*/
|
*/
|
||||||
Handle acquire(BigInteger agentId, String sessionId);
|
Handle acquire(BigInteger agentId, String sessionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 无等待尝试获取指定 Agent 会话的运行锁。
|
||||||
|
*
|
||||||
|
* @param agentId Agent ID
|
||||||
|
* @param sessionId 运行时会话 ID
|
||||||
|
* @return 获取成功时返回锁句柄,锁已被占用时返回 null
|
||||||
|
*/
|
||||||
|
default Handle tryAcquire(BigInteger agentId, String sessionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent 运行锁句柄。
|
* Agent 运行锁句柄。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import tech.easyflow.common.cache.RedisLockExecutor;
|
|||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
import java.util.concurrent.ScheduledFuture;
|
import java.util.concurrent.ScheduledFuture;
|
||||||
@@ -52,6 +53,13 @@ public class RedisAgentRunLock implements AgentRunLock {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Handle tryAcquire(BigInteger agentId, String sessionId) {
|
||||||
|
RedisLockExecutor.LockHandle handle = redisLockExecutor.tryAcquire(
|
||||||
|
lockKey(agentId, sessionId), Duration.ZERO, properties.getLockLeaseTimeout());
|
||||||
|
return handle == null ? null : new RedisHandle(handle, scheduleRenew(handle));
|
||||||
|
}
|
||||||
|
|
||||||
private ScheduledFuture<?> scheduleRenew(RedisLockExecutor.LockHandle handle) {
|
private ScheduledFuture<?> scheduleRenew(RedisLockExecutor.LockHandle handle) {
|
||||||
long intervalMillis = Math.max(1000L, properties.getLockRenewInterval().toMillis());
|
long intervalMillis = Math.max(1000L, properties.getLockRenewInterval().toMillis());
|
||||||
return RENEW_EXECUTOR.scheduleAtFixedRate(handle::renew, intervalMillis, intervalMillis, TimeUnit.MILLISECONDS);
|
return RENEW_EXECUTOR.scheduleAtFixedRate(handle::renew, intervalMillis, intervalMillis, TimeUnit.MILLISECONDS);
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package tech.easyflow.agent.runtime.output;
|
||||||
|
|
||||||
|
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
import tech.easyflow.core.chat.protocol.ChatDomain;
|
||||||
|
import tech.easyflow.core.chat.protocol.ChatType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 单次运行的协议无关输出边界。
|
||||||
|
*/
|
||||||
|
public interface AgentRunOutput {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取底层 SSE 连接。
|
||||||
|
*
|
||||||
|
* @return SSE Emitter
|
||||||
|
*/
|
||||||
|
SseEmitter emitter();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 接收一条规范化运行时事件。
|
||||||
|
*
|
||||||
|
* @param event 运行时事件
|
||||||
|
* @return 发送成功时为 true
|
||||||
|
*/
|
||||||
|
boolean emitRuntimeEvent(AgentRuntimeEvent event);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送现有展示语义事件。
|
||||||
|
*
|
||||||
|
* @param domain 事件域
|
||||||
|
* @param type 展示事件类型
|
||||||
|
* @param payload 展示载荷
|
||||||
|
* @return 发送成功时为 true
|
||||||
|
*/
|
||||||
|
boolean emitViewEvent(ChatDomain domain, ChatType type, Object payload);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前协议输出是否已经收到可用于成功收口的运行时终态。
|
||||||
|
*
|
||||||
|
* <p>旧协议允许自然 EOF 兼容收口;要求显式终态的协议实现应覆盖此方法。</p>
|
||||||
|
*
|
||||||
|
* @return 可以按成功状态持久化并结束时为 true
|
||||||
|
*/
|
||||||
|
default boolean canFinishSuccessfully() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送协议终态并关闭连接。
|
||||||
|
*
|
||||||
|
* @param finalText 服务端权威最终正文,可为空
|
||||||
|
* @return 发送成功时为 true
|
||||||
|
*/
|
||||||
|
boolean finish(String finalText);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 正常关闭连接。
|
||||||
|
*/
|
||||||
|
void complete();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 以异常关闭连接。
|
||||||
|
*
|
||||||
|
* @param error 异常
|
||||||
|
*/
|
||||||
|
void completeWithError(Throwable error);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断连接是否关闭。
|
||||||
|
*
|
||||||
|
* @return 已关闭时为 true
|
||||||
|
*/
|
||||||
|
boolean isClosed();
|
||||||
|
}
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
package tech.easyflow.agent.runtime.output;
|
||||||
|
|
||||||
|
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||||
|
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
||||||
|
import com.easyagents.agui.AguiExtendedEvent;
|
||||||
|
import com.easyagents.agui.AguiProtocolEventEncoder;
|
||||||
|
import com.easyagents.agui.AguiRuntimeEventProjector;
|
||||||
|
import io.agentscope.core.agui.event.AguiEvent;
|
||||||
|
import io.agentscope.core.agui.model.AguiMessage;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
import tech.easyflow.agent.runtime.hitl.ToolApprovalInputProjection;
|
||||||
|
import tech.easyflow.core.chat.protocol.ChatDomain;
|
||||||
|
import tech.easyflow.core.chat.protocol.ChatType;
|
||||||
|
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将同一 Agent 业务运行投影为原生 AG-UI SSE 的输出实现。
|
||||||
|
*
|
||||||
|
* <p>所有公开发送方法串行化,避免运行线程与 HITL 恢复线程交错破坏事件顺序。</p>
|
||||||
|
*/
|
||||||
|
public final class AguiAgentRunOutput implements AgentRunOutput {
|
||||||
|
|
||||||
|
private static final String ASSISTANT_ROLE = "assistant";
|
||||||
|
private static final String REASONING_ROLE = "reasoning";
|
||||||
|
|
||||||
|
private final String threadId;
|
||||||
|
private final String runId;
|
||||||
|
private final String clientUserMessageId;
|
||||||
|
private final String clientUserMessageContent;
|
||||||
|
private final ChatSseEmitter delegate;
|
||||||
|
private final AguiRuntimeEventProjector projector;
|
||||||
|
private final AguiProtocolEventEncoder encoder = new AguiProtocolEventEncoder();
|
||||||
|
|
||||||
|
private long customSequence;
|
||||||
|
private long assistantMessageSequence;
|
||||||
|
private long reasoningMessageSequence;
|
||||||
|
private String assistantMessageId;
|
||||||
|
private String lastAssistantMessageId;
|
||||||
|
private String reasoningMessageId;
|
||||||
|
private final StringBuilder assistantText = new StringBuilder();
|
||||||
|
private final Map<String, Map<String, Object>> activeSkillInvocations = new LinkedHashMap<>();
|
||||||
|
private AgentRuntimeEvent pendingCompletedEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 AG-UI 输出。
|
||||||
|
*
|
||||||
|
* @param threadId 客户端 thread ID
|
||||||
|
* @param runId 客户端 run ID
|
||||||
|
* @param clientUserMessageId 本轮客户端用户消息 ID
|
||||||
|
*/
|
||||||
|
public AguiAgentRunOutput(String threadId, String runId, String clientUserMessageId) {
|
||||||
|
this(threadId, runId, clientUserMessageId, null, new ChatSseEmitter());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建包含本轮用户消息快照信息的 AG-UI 输出。
|
||||||
|
*
|
||||||
|
* @param threadId 客户端 thread ID
|
||||||
|
* @param runId 客户端 run ID
|
||||||
|
* @param clientUserMessageId 本轮客户端用户消息 ID
|
||||||
|
* @param clientUserMessageContent 本轮客户端用户消息正文
|
||||||
|
*/
|
||||||
|
public AguiAgentRunOutput(
|
||||||
|
String threadId,
|
||||||
|
String runId,
|
||||||
|
String clientUserMessageId,
|
||||||
|
String clientUserMessageContent) {
|
||||||
|
this(threadId, runId, clientUserMessageId, clientUserMessageContent, new ChatSseEmitter());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用指定 SSE 发射器创建 AG-UI 输出,供受控装配和测试使用。
|
||||||
|
*
|
||||||
|
* @param threadId 客户端 thread ID
|
||||||
|
* @param runId 客户端 run ID
|
||||||
|
* @param clientUserMessageId 本轮客户端用户消息 ID
|
||||||
|
* @param delegate SSE 发射器
|
||||||
|
*/
|
||||||
|
public AguiAgentRunOutput(
|
||||||
|
String threadId,
|
||||||
|
String runId,
|
||||||
|
String clientUserMessageId,
|
||||||
|
ChatSseEmitter delegate) {
|
||||||
|
this(threadId, runId, clientUserMessageId, null, delegate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用指定 SSE 发射器和用户消息快照信息创建 AG-UI 输出。
|
||||||
|
*
|
||||||
|
* @param threadId 客户端 thread ID
|
||||||
|
* @param runId 客户端 run ID
|
||||||
|
* @param clientUserMessageId 本轮客户端用户消息 ID
|
||||||
|
* @param clientUserMessageContent 本轮客户端用户消息正文
|
||||||
|
* @param delegate SSE 发射器
|
||||||
|
*/
|
||||||
|
public AguiAgentRunOutput(
|
||||||
|
String threadId,
|
||||||
|
String runId,
|
||||||
|
String clientUserMessageId,
|
||||||
|
String clientUserMessageContent,
|
||||||
|
ChatSseEmitter delegate) {
|
||||||
|
this.threadId = requireText(threadId, "threadId");
|
||||||
|
this.runId = requireText(runId, "runId");
|
||||||
|
this.clientUserMessageId = clientUserMessageId;
|
||||||
|
this.clientUserMessageContent = clientUserMessageContent;
|
||||||
|
this.delegate = java.util.Objects.requireNonNull(delegate, "delegate cannot be null");
|
||||||
|
this.projector = new AguiRuntimeEventProjector(threadId, runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SseEmitter emitter() {
|
||||||
|
return delegate.getEmitter();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized boolean emitRuntimeEvent(AgentRuntimeEvent event) {
|
||||||
|
if (event == null || event.getEventType() == null || delegate.isClosed()) {
|
||||||
|
return !delegate.isClosed();
|
||||||
|
}
|
||||||
|
AgentRuntimeEventType type = event.getEventType();
|
||||||
|
if (type == AgentRuntimeEventType.MESSAGE_DELTA
|
||||||
|
|| type == AgentRuntimeEventType.REASONING_STARTED
|
||||||
|
|| type == AgentRuntimeEventType.REASONING_DELTA
|
||||||
|
|| type == AgentRuntimeEventType.REASONING_COMPLETED) {
|
||||||
|
// EasyFlow 的跨 delta <think> 归一化结果通过 emitViewEvent 输出,避免重复和标签泄漏。
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (type == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) {
|
||||||
|
return emitToolApproval(event);
|
||||||
|
}
|
||||||
|
if (type == AgentRuntimeEventType.SKILL_CALL
|
||||||
|
|| type == AgentRuntimeEventType.SKILL_RESULT
|
||||||
|
|| type == AgentRuntimeEventType.SKILL_FAILED) {
|
||||||
|
return emitSkillInvocation(event);
|
||||||
|
}
|
||||||
|
if (type == AgentRuntimeEventType.SKILL_STEP) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isAsyncToolEvent(type)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (type == AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (type == AgentRuntimeEventType.MEMORY_COMPRESSION_STARTED
|
||||||
|
|| type == AgentRuntimeEventType.MEMORY_COMPRESSION_COMPLETED) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (type == AgentRuntimeEventType.SUSPENDED) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (type == AgentRuntimeEventType.COMPLETED) {
|
||||||
|
pendingCompletedEvent = event;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (type == AgentRuntimeEventType.CANCELLED) {
|
||||||
|
if (!closeActiveSkillInvocations("CANCELLED")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (type == AgentRuntimeEventType.FAILED) {
|
||||||
|
if (!closeActiveSkillInvocations("INCOMPLETE")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type == AgentRuntimeEventType.TOOL_CALL
|
||||||
|
|| type == AgentRuntimeEventType.FAILED
|
||||||
|
|| type == AgentRuntimeEventType.CANCELLED) {
|
||||||
|
if (!closeOpenMessages()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (Object protocolEvent : projector.project(event)) {
|
||||||
|
if (!send(protocolEvent)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type == AgentRuntimeEventType.TOOL_CALL) {
|
||||||
|
return emitToolMetadata(event);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized boolean emitViewEvent(ChatDomain domain, ChatType type, Object payload) {
|
||||||
|
if (delegate.isClosed()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (type == ChatType.MESSAGE) {
|
||||||
|
return emitAssistantDelta(stringValue(payload, "delta"));
|
||||||
|
}
|
||||||
|
if (type == ChatType.THINKING) {
|
||||||
|
return emitReasoningDelta(firstText(
|
||||||
|
stringValue(payload, "delta"), stringValue(payload, "reasoning")));
|
||||||
|
}
|
||||||
|
if (type == ChatType.INPUT_ACCEPTED) {
|
||||||
|
Map<String, Object> value = copyMap(payload);
|
||||||
|
value.put("clientMessageId", clientUserMessageId);
|
||||||
|
value.put("serverMessageId", value.get("messageId"));
|
||||||
|
return emitCustom("easyflow.input.accepted", value, clientUserMessageId);
|
||||||
|
}
|
||||||
|
if (type == ChatType.CITATIONS) {
|
||||||
|
return emitCustom("easyflow.knowledge.citations", copyMap(payload), lastAssistantMessageId);
|
||||||
|
}
|
||||||
|
if ((type == ChatType.TOOL_CALL || type == ChatType.TOOL_RESULT)
|
||||||
|
&& Boolean.TRUE.equals(copyMap(payload).get("asyncTool"))) {
|
||||||
|
return emitCustom("easyflow.async_tool.status",
|
||||||
|
selectPayload(payload,
|
||||||
|
"asyncTool", "asyncToolName", "input", "label", "name", "output",
|
||||||
|
"phase", "result", "sourceToolCallId", "status", "statusKey", "summary",
|
||||||
|
"taskId", "text", "toolCallId", "toolDisplayName", "toolInput", "toolName"),
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
if (type == ChatType.STATUS) {
|
||||||
|
String statusKey = stringValue(payload, "statusKey");
|
||||||
|
if ("artifact-published".equals(statusKey)) {
|
||||||
|
return emitCustom("easyflow.artifact.published",
|
||||||
|
selectPayload(payload, "schemaVersion", "artifactId", "fileName", "mimeType",
|
||||||
|
"size", "sha256", "downloadUrl", "status"),
|
||||||
|
lastAssistantMessageId);
|
||||||
|
}
|
||||||
|
if ("knowledge-retrieval".equals(statusKey)) {
|
||||||
|
return emitCustom("easyflow.knowledge.retrieval_status",
|
||||||
|
selectPayload(payload, "label", "status", "statusKey"), null);
|
||||||
|
}
|
||||||
|
if ("memory-compression".equals(statusKey)) {
|
||||||
|
return emitCustom("easyflow.runtime.context_status",
|
||||||
|
selectPayload(payload, "compressed", "label", "phase", "status", "statusKey"), null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type == ChatType.FORM_CANCEL) {
|
||||||
|
return emitCustom("easyflow.hitl.tool_approval_resolved", copyMap(payload), null);
|
||||||
|
}
|
||||||
|
if (type == ChatType.ERROR) {
|
||||||
|
return fail(firstText(stringValue(payload, "message"), "Agent runtime failed."), "AGENT_RUNTIME_FAILED");
|
||||||
|
}
|
||||||
|
// 工具标准事件和业务状态已从 AgentRuntimeEvent 投影;其余 Legacy 展示事件不进入 AG-UI wire。
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized boolean canFinishSuccessfully() {
|
||||||
|
return pendingCompletedEvent != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized boolean finish(String finalText) {
|
||||||
|
if (delegate.isClosed()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!reconcileFinalText(finalText)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (pendingCompletedEvent != null && !projector.isTerminated()) {
|
||||||
|
if (!closeActiveSkillInvocations("INCOMPLETE")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!closeOpenMessages()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (Object protocolEvent : projector.project(pendingCompletedEvent)) {
|
||||||
|
if (!send(protocolEvent)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingCompletedEvent = null;
|
||||||
|
}
|
||||||
|
if (!projector.isTerminated()) {
|
||||||
|
if (!fail("Agent stream ended without a terminal event.", "MISSING_TERMINAL_EVENT")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delegate.complete();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void complete() {
|
||||||
|
delegate.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void completeWithError(Throwable error) {
|
||||||
|
if (!projector.isTerminated() && !delegate.isClosed()) {
|
||||||
|
closeActiveSkillInvocations("INCOMPLETE");
|
||||||
|
fail(error == null || error.getMessage() == null
|
||||||
|
? "Agent runtime failed."
|
||||||
|
: error.getMessage(), "AGENT_RUNTIME_FAILED");
|
||||||
|
}
|
||||||
|
delegate.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isClosed() {
|
||||||
|
return delegate.isClosed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean emitAssistantDelta(String delta) {
|
||||||
|
if (delta == null || delta.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!ensureRunStarted()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (reasoningMessageId != null && !closeReasoning()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (assistantMessageId == null) {
|
||||||
|
assistantMessageId = runId + "-assistant-" + (++assistantMessageSequence);
|
||||||
|
lastAssistantMessageId = assistantMessageId;
|
||||||
|
if (!send(new AguiEvent.TextMessageStart(
|
||||||
|
threadId, runId, assistantMessageId, ASSISTANT_ROLE))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assistantText.append(delta);
|
||||||
|
return send(new AguiEvent.TextMessageContent(threadId, runId, assistantMessageId, delta));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean emitReasoningDelta(String delta) {
|
||||||
|
if (delta == null || delta.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!ensureRunStarted()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (assistantMessageId != null && !closeAssistant()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (reasoningMessageId == null) {
|
||||||
|
reasoningMessageId = runId + "-reasoning-" + (++reasoningMessageSequence);
|
||||||
|
if (!send(new AguiEvent.ReasoningMessageStart(
|
||||||
|
threadId, runId, reasoningMessageId, REASONING_ROLE))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return send(new AguiEvent.ReasoningMessageContent(threadId, runId, reasoningMessageId, delta));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean emitToolApproval(AgentRuntimeEvent event) {
|
||||||
|
Map<String, Object> source = event.getPayload() == null ? Map.of() : event.getPayload();
|
||||||
|
Map<String, Object> value = new LinkedHashMap<>();
|
||||||
|
value.put("approvalId", event.getMetadata().get("approvalId"));
|
||||||
|
value.put("toolCallId", firstText(event.getToolCallId(), stringValue(source, "toolCallId")));
|
||||||
|
value.put("toolName", stringValue(source, "toolName"));
|
||||||
|
value.put("toolDisplayName", firstText(
|
||||||
|
stringValue(source, "toolDisplayName"), stringValue(source, "toolName")));
|
||||||
|
value.put("input", ToolApprovalInputProjection.project(
|
||||||
|
firstNonNull(source.get("toolInput"), source.get("input"))));
|
||||||
|
value.put("expiresAt", source.get("expiresAt"));
|
||||||
|
return emitCustom("easyflow.hitl.tool_approval_required", value, event.getMessageId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean emitSkillInvocation(AgentRuntimeEvent event) {
|
||||||
|
Map<String, Object> value = selectPayload(event.getPayload(),
|
||||||
|
"statusKey", "status", "skillId", "skillName", "skillDisplayName",
|
||||||
|
"toolCallId", "message");
|
||||||
|
String statusKey = stringValue(value, "statusKey");
|
||||||
|
if (statusKey == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String status = stringValue(value, "status");
|
||||||
|
if ("RUNNING".equals(status)) {
|
||||||
|
activeSkillInvocations.put(statusKey, new LinkedHashMap<>(value));
|
||||||
|
} else {
|
||||||
|
activeSkillInvocations.remove(statusKey);
|
||||||
|
}
|
||||||
|
return emitCustom("easyflow.skill.invocation_status", value, event.getMessageId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean closeActiveSkillInvocations(String status) {
|
||||||
|
if (activeSkillInvocations.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
List<Map<String, Object>> pending = new ArrayList<>(activeSkillInvocations.values());
|
||||||
|
activeSkillInvocations.clear();
|
||||||
|
for (Map<String, Object> value : pending) {
|
||||||
|
Map<String, Object> terminal = new LinkedHashMap<>(value);
|
||||||
|
terminal.put("status", status);
|
||||||
|
terminal.remove("message");
|
||||||
|
if (!emitCustom("easyflow.skill.invocation_status", terminal, null)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean emitToolMetadata(AgentRuntimeEvent event) {
|
||||||
|
Map<String, Object> source = event.getPayload() == null ? Map.of() : event.getPayload();
|
||||||
|
String toolCallId = firstText(event.getToolCallId(), stringValue(source, "toolCallId"));
|
||||||
|
String toolName = firstText(stringValue(source, "toolName"), stringValue(source, "name"));
|
||||||
|
String toolDisplayName = stringValue(source, "toolDisplayName");
|
||||||
|
if (toolCallId == null || toolDisplayName == null
|
||||||
|
|| toolDisplayName.equals(toolName) || isHiddenToolName(toolName)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Map<String, Object> value = new LinkedHashMap<>();
|
||||||
|
value.put("toolCallId", toolCallId);
|
||||||
|
value.put("toolName", toolName);
|
||||||
|
value.put("toolDisplayName", toolDisplayName);
|
||||||
|
return emitCustom("easyflow.tool.metadata", value, event.getMessageId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean emitCustom(String name, Map<String, Object> payload, String messageId) {
|
||||||
|
if (!ensureRunStarted()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Map<String, Object> value = new LinkedHashMap<>();
|
||||||
|
if (payload != null) {
|
||||||
|
value.putAll(payload);
|
||||||
|
}
|
||||||
|
// 协议保留字段由服务端最终写入,避免业务 payload 覆盖运行边界信息。
|
||||||
|
value.put("schemaVersion", 1);
|
||||||
|
value.put("id", "evt_" + UUID.randomUUID());
|
||||||
|
value.put("runId", runId);
|
||||||
|
value.put("threadId", threadId);
|
||||||
|
value.put("messageId", messageId);
|
||||||
|
value.put("sequence", ++customSequence);
|
||||||
|
value.put("timestamp", Instant.now().toString());
|
||||||
|
return send(new AguiEvent.Custom(threadId, runId, name, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean fail(String message, String code) {
|
||||||
|
if (projector.isTerminated()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!closeOpenMessages()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
AgentRuntimeEvent failed = AgentRuntimeEvent.of(AgentRuntimeEventType.FAILED);
|
||||||
|
failed.getPayload().put("message", message);
|
||||||
|
String resolvedCode = code == null || code.isBlank() ? "AGENT_RUNTIME_FAILED" : code;
|
||||||
|
for (Object protocolEvent : projector.project(failed)) {
|
||||||
|
Object output = protocolEvent instanceof AguiExtendedEvent.RunError runError
|
||||||
|
? new AguiExtendedEvent.RunError(
|
||||||
|
runError.threadId(), runError.runId(), runError.message(), resolvedCode)
|
||||||
|
: protocolEvent;
|
||||||
|
if (!send(output)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean closeOpenMessages() {
|
||||||
|
return closeReasoning() && closeAssistant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean closeReasoning() {
|
||||||
|
if (reasoningMessageId == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String messageId = reasoningMessageId;
|
||||||
|
reasoningMessageId = null;
|
||||||
|
return send(new AguiEvent.ReasoningMessageEnd(threadId, runId, messageId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean closeAssistant() {
|
||||||
|
if (assistantMessageId == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String messageId = assistantMessageId;
|
||||||
|
assistantMessageId = null;
|
||||||
|
return send(new AguiEvent.TextMessageEnd(threadId, runId, messageId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean reconcileFinalText(String finalText) {
|
||||||
|
if (finalText == null || finalText.contentEquals(assistantText)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String streamedText = assistantText.toString();
|
||||||
|
if (finalText.startsWith(streamedText)) {
|
||||||
|
return emitAssistantDelta(finalText.substring(streamedText.length()));
|
||||||
|
}
|
||||||
|
if (!closeOpenMessages()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String resolvedAssistantMessageId = lastAssistantMessageId;
|
||||||
|
if (resolvedAssistantMessageId == null) {
|
||||||
|
resolvedAssistantMessageId = runId + "-assistant-" + (++assistantMessageSequence);
|
||||||
|
lastAssistantMessageId = resolvedAssistantMessageId;
|
||||||
|
}
|
||||||
|
List<AguiMessage> messages = new ArrayList<>(2);
|
||||||
|
if (clientUserMessageId != null && clientUserMessageContent != null) {
|
||||||
|
messages.add(AguiMessage.userMessage(clientUserMessageId, clientUserMessageContent));
|
||||||
|
}
|
||||||
|
messages.add(AguiMessage.assistantMessage(resolvedAssistantMessageId, finalText));
|
||||||
|
assistantText.setLength(0);
|
||||||
|
assistantText.append(finalText);
|
||||||
|
return send(new AguiExtendedEvent.MessagesSnapshot(threadId, runId, messages));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean send(Object event) {
|
||||||
|
return delegate.sendData(encoder.encodeToJson(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean ensureRunStarted() {
|
||||||
|
if (projector.isTerminated()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
AgentRuntimeEvent started = AgentRuntimeEvent.of(AgentRuntimeEventType.STARTED);
|
||||||
|
for (Object protocolEvent : projector.project(started)) {
|
||||||
|
if (!send(protocolEvent)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isAsyncToolEvent(AgentRuntimeEventType type) {
|
||||||
|
return type == AgentRuntimeEventType.ASYNC_TOOL_SUBMITTED
|
||||||
|
|| type == AgentRuntimeEventType.ASYNC_TOOL_OBSERVED
|
||||||
|
|| type == AgentRuntimeEventType.ASYNC_TOOL_RESULT
|
||||||
|
|| type == AgentRuntimeEventType.ASYNC_TOOL_CANCELLED
|
||||||
|
|| type == AgentRuntimeEventType.ASYNC_TOOL_LISTED
|
||||||
|
|| type == AgentRuntimeEventType.ASYNC_TOOL_FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isHiddenToolName(String toolName) {
|
||||||
|
return "retrieve_knowledge".equalsIgnoreCase(toolName)
|
||||||
|
|| "context_reload".equalsIgnoreCase(toolName)
|
||||||
|
|| "__fragment__".equalsIgnoreCase(toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static Map<String, Object> copyMap(Object payload) {
|
||||||
|
return payload instanceof Map<?, ?> map
|
||||||
|
? new LinkedHashMap<>((Map<String, Object>) map)
|
||||||
|
: new LinkedHashMap<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选取允许进入 AG-UI CUSTOM 的公开字段。
|
||||||
|
*
|
||||||
|
* @param payload 服务层展示载荷
|
||||||
|
* @param allowedKeys 允许字段
|
||||||
|
* @return 公开载荷
|
||||||
|
*/
|
||||||
|
private static Map<String, Object> selectPayload(Object payload, String... allowedKeys) {
|
||||||
|
Map<String, Object> source = copyMap(payload);
|
||||||
|
Map<String, Object> selected = new LinkedHashMap<>();
|
||||||
|
for (String key : allowedKeys) {
|
||||||
|
if (source.containsKey(key)) {
|
||||||
|
selected.put(key, source.get(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stringValue(Object payload, String key) {
|
||||||
|
if (!(payload instanceof Map<?, ?> map)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Object value = map.get(key);
|
||||||
|
return value instanceof String text ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object firstNonNull(Object first, Object second) {
|
||||||
|
return first == null ? second : first;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstText(String... values) {
|
||||||
|
for (String value : values) {
|
||||||
|
if (value != null && !value.isBlank()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireText(String value, String name) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new IllegalArgumentException(name + " cannot be blank");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package tech.easyflow.agent.runtime.output;
|
||||||
|
|
||||||
|
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
import tech.easyflow.core.chat.protocol.ChatDomain;
|
||||||
|
import tech.easyflow.core.chat.protocol.ChatEnvelope;
|
||||||
|
import tech.easyflow.core.chat.protocol.ChatType;
|
||||||
|
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保持现有 EasyFlow ChatEnvelope 行为的运行输出。
|
||||||
|
*/
|
||||||
|
public final class LegacyAgentRunOutput implements AgentRunOutput {
|
||||||
|
|
||||||
|
private final ChatSseEmitter delegate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Legacy 输出。
|
||||||
|
*/
|
||||||
|
public LegacyAgentRunOutput() {
|
||||||
|
this(new ChatSseEmitter());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用指定 SSE 发射器创建 Legacy 输出。
|
||||||
|
*
|
||||||
|
* @param delegate SSE 发射器
|
||||||
|
*/
|
||||||
|
public LegacyAgentRunOutput(ChatSseEmitter delegate) {
|
||||||
|
this.delegate = Objects.requireNonNull(delegate, "delegate cannot be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SseEmitter emitter() {
|
||||||
|
return delegate.getEmitter();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean emitRuntimeEvent(AgentRuntimeEvent event) {
|
||||||
|
return !delegate.isClosed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean emitViewEvent(ChatDomain domain, ChatType type, Object payload) {
|
||||||
|
ChatEnvelope<Object> envelope = new ChatEnvelope<>();
|
||||||
|
envelope.setDomain(domain);
|
||||||
|
envelope.setType(type);
|
||||||
|
envelope.setPayload(payload);
|
||||||
|
return delegate.send(envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean finish(String finalText) {
|
||||||
|
ChatEnvelope<Map<String, Object>> envelope = new ChatEnvelope<>();
|
||||||
|
envelope.setDomain(ChatDomain.SYSTEM);
|
||||||
|
envelope.setType(ChatType.DONE);
|
||||||
|
if (finalText != null) {
|
||||||
|
envelope.setPayload(Map.of("finalText", finalText));
|
||||||
|
}
|
||||||
|
return delegate.sendDone(envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void complete() {
|
||||||
|
delegate.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void completeWithError(Throwable error) {
|
||||||
|
delegate.completeWithError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isClosed() {
|
||||||
|
return delegate.isClosed();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package tech.easyflow.agent.runtime.skill;
|
||||||
|
|
||||||
|
import com.easyagents.agent.runtime.mcp.McpSpec;
|
||||||
|
import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec;
|
||||||
|
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
|
||||||
|
import com.easyagents.agent.runtime.tool.AgentToolSpec;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent Skill 运行时编译结果。
|
||||||
|
*/
|
||||||
|
public class AgentSkillRuntimeCompilation {
|
||||||
|
|
||||||
|
private AgentSkillBoxSpec skillBoxSpec;
|
||||||
|
private List<AgentToolSpec> toolSpecs = new ArrayList<>();
|
||||||
|
private List<McpSpec> mcpSpecs = new ArrayList<>();
|
||||||
|
private Map<String, AgentToolInvoker> toolInvokers = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
/** @return SkillBox 声明 */
|
||||||
|
public AgentSkillBoxSpec getSkillBoxSpec() { return skillBoxSpec; }
|
||||||
|
/** @param skillBoxSpec SkillBox 声明 */
|
||||||
|
public void setSkillBoxSpec(AgentSkillBoxSpec skillBoxSpec) { this.skillBoxSpec = skillBoxSpec; }
|
||||||
|
/** @return 静态 Tool 声明 */
|
||||||
|
public List<AgentToolSpec> getToolSpecs() { return toolSpecs; }
|
||||||
|
/** @param toolSpecs 静态 Tool 声明 */
|
||||||
|
public void setToolSpecs(List<AgentToolSpec> toolSpecs) {
|
||||||
|
this.toolSpecs = toolSpecs == null ? new ArrayList<>() : new ArrayList<>(toolSpecs);
|
||||||
|
}
|
||||||
|
/** @return MCP 声明 */
|
||||||
|
public List<McpSpec> getMcpSpecs() { return mcpSpecs; }
|
||||||
|
/** @param mcpSpecs MCP 声明 */
|
||||||
|
public void setMcpSpecs(List<McpSpec> mcpSpecs) {
|
||||||
|
this.mcpSpecs = mcpSpecs == null ? new ArrayList<>() : new ArrayList<>(mcpSpecs);
|
||||||
|
}
|
||||||
|
/** @return Tool 调用器 */
|
||||||
|
public Map<String, AgentToolInvoker> getToolInvokers() { return toolInvokers; }
|
||||||
|
/** @param toolInvokers Tool 调用器 */
|
||||||
|
public void setToolInvokers(Map<String, AgentToolInvoker> toolInvokers) {
|
||||||
|
this.toolInvokers = toolInvokers == null ? new LinkedHashMap<>() : new LinkedHashMap<>(toolInvokers);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
package tech.easyflow.agent.runtime.skill;
|
||||||
|
|
||||||
|
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
|
||||||
|
import com.easyagents.agent.runtime.mcp.McpSpec;
|
||||||
|
import com.easyagents.agent.runtime.mcp.McpToolManifestEntry;
|
||||||
|
import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec;
|
||||||
|
import com.easyagents.agent.runtime.skill.AgentSkillSpec;
|
||||||
|
import com.easyagents.agent.runtime.tool.AgentToolSpec;
|
||||||
|
import com.easyagents.skill.util.SkillHashes;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.agent.entity.Agent;
|
||||||
|
import tech.easyflow.agent.entity.AgentSkillBinding;
|
||||||
|
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||||
|
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompilation;
|
||||||
|
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Agent 内部冻结的 Skill 投影编译为一个 SkillBox 及其延迟激活 Tool。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AgentSkillRuntimeCompiler {
|
||||||
|
|
||||||
|
private static final TypeReference<List<McpToolManifestEntry>> MCP_MANIFEST_TYPE = new TypeReference<>() { };
|
||||||
|
private static final TypeReference<Map<String, String>> STRING_MAP_TYPE = new TypeReference<>() { };
|
||||||
|
|
||||||
|
private final AgentSkillRuntimeProjector runtimeProjector;
|
||||||
|
private final AgentToolRuntimeCompiler toolRuntimeCompiler;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Skill 运行时编译器。
|
||||||
|
*
|
||||||
|
* @param runtimeProjector Skill 冻结投影器
|
||||||
|
* @param toolRuntimeCompiler 公共 Tool 编译器
|
||||||
|
* @param objectMapper JSON 映射器
|
||||||
|
*/
|
||||||
|
public AgentSkillRuntimeCompiler(AgentSkillRuntimeProjector runtimeProjector,
|
||||||
|
AgentToolRuntimeCompiler toolRuntimeCompiler,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.runtimeProjector = runtimeProjector;
|
||||||
|
this.toolRuntimeCompiler = toolRuntimeCompiler;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编译 Agent 的全部 Skill。
|
||||||
|
*
|
||||||
|
* <p>正式 Agent 直接消费冻结投影;草稿试用只有引用时才读取当前已发布 Skill 构建临时投影。</p>
|
||||||
|
*
|
||||||
|
* @param agent Agent 运行定义
|
||||||
|
* @return Skill 运行时编译结果
|
||||||
|
*/
|
||||||
|
public AgentSkillRuntimeCompilation compile(Agent agent) {
|
||||||
|
AgentSkillRuntimeCompilation result = new AgentSkillRuntimeCompilation();
|
||||||
|
List<AgentSkillBinding> bindings = agent == null ? null : agent.getSkillBindings();
|
||||||
|
if (bindings == null || bindings.isEmpty()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (!hasCompleteSnapshots(bindings)) {
|
||||||
|
throw new BusinessException("Agent Skill 运行快照缺失,请重新保存或发布 Agent");
|
||||||
|
}
|
||||||
|
List<AgentSkillBinding> effectiveBindings = bindings;
|
||||||
|
runtimeProjector.assertFrozenBindings(effectiveBindings);
|
||||||
|
|
||||||
|
AgentSkillBoxSpec box = new AgentSkillBoxSpec();
|
||||||
|
box.setSkillBoxId("skill-box");
|
||||||
|
List<AgentSkillSpec> skills = new ArrayList<>();
|
||||||
|
Map<String, List<String>> toolBindings = new LinkedHashMap<>();
|
||||||
|
List<AgentToolSpec> toolSpecs = new ArrayList<>();
|
||||||
|
List<McpSpec> mcpSpecs = new ArrayList<>();
|
||||||
|
Map<String, com.easyagents.agent.runtime.tool.AgentToolInvoker> invokers = new LinkedHashMap<>();
|
||||||
|
Map<String, String> targetOwners = directTargetOwners(agent);
|
||||||
|
Set<String> runtimeNames = new HashSet<>();
|
||||||
|
|
||||||
|
for (AgentSkillBinding binding : effectiveBindings) {
|
||||||
|
Map<String, Object> snapshot = binding.getResourceSnapshot();
|
||||||
|
String skillId = requiredText(snapshot, "skillId", "Skill 运行快照缺少 ID");
|
||||||
|
String displayName = firstText(text(snapshot.get("displayName")), text(snapshot.get("name")));
|
||||||
|
AgentSkillSpec skillSpec = toSkillSpec(snapshot, skillId, displayName);
|
||||||
|
skills.add(skillSpec);
|
||||||
|
|
||||||
|
List<AgentToolBinding> syntheticBindings = new ArrayList<>();
|
||||||
|
Map<BigInteger, Map<String, Object>> mcpSnapshots = new LinkedHashMap<>();
|
||||||
|
for (Map<String, Object> item : bindingSnapshots(snapshot)) {
|
||||||
|
AgentToolBinding synthetic = toSyntheticBinding(skillId, displayName, item);
|
||||||
|
assertUniqueTarget(targetOwners, synthetic, displayName);
|
||||||
|
syntheticBindings.add(synthetic);
|
||||||
|
if ("MCP".equals(synthetic.getToolType())) {
|
||||||
|
mcpSnapshots.put(synthetic.getTargetId(), item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentToolRuntimeCompilation compiled = toolRuntimeCompiler.compileBindings(syntheticBindings);
|
||||||
|
List<String> ownedNames = new ArrayList<>();
|
||||||
|
for (AgentToolSpec spec : compiled.getToolSpecs()) {
|
||||||
|
assertRuntimeName(runtimeNames, spec.getName());
|
||||||
|
attachSkillMetadata(spec, skillId, displayName);
|
||||||
|
toolSpecs.add(spec);
|
||||||
|
ownedNames.add(spec.getName());
|
||||||
|
}
|
||||||
|
compiled.getToolInvokers().forEach((name, invoker) -> {
|
||||||
|
if (invokers.putIfAbsent(name, invoker) != null) {
|
||||||
|
throw new BusinessException("Agent Skill Tool 运行名冲突:" + name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (McpSpec spec : compiled.getMcpSpecs()) {
|
||||||
|
BigInteger targetId = new BigInteger(String.valueOf(spec.getMetadata().get("mcpId")));
|
||||||
|
Map<String, Object> item = mcpSnapshots.get(targetId);
|
||||||
|
configureSkillMcp(spec, item, skillId, displayName, runtimeNames, ownedNames);
|
||||||
|
mcpSpecs.add(spec);
|
||||||
|
}
|
||||||
|
toolBindings.put(skillId, ownedNames);
|
||||||
|
}
|
||||||
|
box.setSkills(skills);
|
||||||
|
box.setToolBindings(toolBindings);
|
||||||
|
result.setSkillBoxSpec(box);
|
||||||
|
result.setToolSpecs(toolSpecs);
|
||||||
|
result.setMcpSpecs(mcpSpecs);
|
||||||
|
result.setToolInvokers(invokers);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将冻结投影转成 AgentSkillSpec。
|
||||||
|
*/
|
||||||
|
private AgentSkillSpec toSkillSpec(Map<String, Object> snapshot, String skillId, String displayName) {
|
||||||
|
AgentSkillSpec spec = new AgentSkillSpec();
|
||||||
|
spec.setSkillId(skillId);
|
||||||
|
spec.setName(requiredText(snapshot, "name", "Skill 运行快照缺少名称"));
|
||||||
|
spec.setDescription(requiredText(snapshot, "description", "Skill 运行快照缺少描述"));
|
||||||
|
spec.setSkillContent(requiredText(snapshot, "skillContent", "Skill 运行快照缺少指令"));
|
||||||
|
spec.setSource(requiredText(snapshot, "source", "Skill 运行快照缺少来源"));
|
||||||
|
Object resources = snapshot.get("resources");
|
||||||
|
spec.setResources(resources instanceof Map<?, ?> ? objectMapper.convertValue(resources, STRING_MAP_TYPE) : Map.of());
|
||||||
|
spec.getMetadata().put("displayName", displayName);
|
||||||
|
spec.getMetadata().put("skillSnapshotHash", snapshot.get("skillSnapshotHash"));
|
||||||
|
spec.getMetadata().put("skillRuntimeSnapshotHash", snapshot.get("skillRuntimeSnapshotHash"));
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建可复用公共 Tool 编译器的服务端绑定。
|
||||||
|
*/
|
||||||
|
private AgentToolBinding toSyntheticBinding(String skillId,
|
||||||
|
String displayName,
|
||||||
|
Map<String, Object> item) {
|
||||||
|
String type = requiredText(item, "toolType", "Skill Tool 快照缺少类型").toUpperCase(Locale.ROOT);
|
||||||
|
if (!Set.of("WORKFLOW", "PLUGIN", "MCP").contains(type)) {
|
||||||
|
throw new BusinessException("Skill Tool 快照类型不支持:" + type);
|
||||||
|
}
|
||||||
|
BigInteger targetId = bigInteger(item.get("targetId"), "Skill Tool 快照缺少目标 ID");
|
||||||
|
AgentToolBinding binding = new AgentToolBinding();
|
||||||
|
binding.setToolType(type);
|
||||||
|
binding.setTargetId(targetId);
|
||||||
|
binding.setEnabled(true);
|
||||||
|
binding.setHitlEnabled(Boolean.TRUE.equals(item.get("hitlEnabled")));
|
||||||
|
binding.setSortNo(number(item.get("sortNo"), 0));
|
||||||
|
Object resource = item.get("resourceSnapshot");
|
||||||
|
if (!(resource instanceof Map<?, ?>)) {
|
||||||
|
throw new BusinessException("Skill Tool 冻结资源快照缺失:" + displayName);
|
||||||
|
}
|
||||||
|
binding.setResourceSnapshot(toStringMap(resource));
|
||||||
|
if (!"MCP".equals(type)) {
|
||||||
|
binding.setToolName("skill_" + safeSegment(skillId) + "_"
|
||||||
|
+ type.toLowerCase(Locale.ROOT) + "_" + targetId);
|
||||||
|
}
|
||||||
|
return binding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为 Skill MCP 写入冻结白名单、稳定别名和可信归属。
|
||||||
|
*/
|
||||||
|
private void configureSkillMcp(McpSpec spec,
|
||||||
|
Map<String, Object> item,
|
||||||
|
String skillId,
|
||||||
|
String displayName,
|
||||||
|
Set<String> runtimeNames,
|
||||||
|
List<String> ownedNames) {
|
||||||
|
if (item == null) {
|
||||||
|
throw new BusinessException("Skill MCP 冻结快照缺失:" + displayName);
|
||||||
|
}
|
||||||
|
List<McpToolManifestEntry> manifest = objectMapper.convertValue(
|
||||||
|
item.get("mcpToolManifest"), MCP_MANIFEST_TYPE);
|
||||||
|
if (manifest == null || manifest.isEmpty()) {
|
||||||
|
throw new BusinessException("Skill MCP 冻结 Tool 清单为空:" + displayName);
|
||||||
|
}
|
||||||
|
String manifestHash = requiredText(item, "mcpToolManifestHash", "Skill MCP 冻结清单 hash 缺失");
|
||||||
|
String mcpId = String.valueOf(item.get("targetId"));
|
||||||
|
Map<String, String> aliases = new LinkedHashMap<>();
|
||||||
|
manifest.stream().sorted(java.util.Comparator.comparing(McpToolManifestEntry::getName))
|
||||||
|
.forEach(entry -> {
|
||||||
|
String rawName = entry.getName();
|
||||||
|
String alias = "skill_" + safeSegment(skillId) + "_mcp_" + safeSegment(mcpId)
|
||||||
|
+ "_" + safeSegment(rawName) + "_" + shortHash(rawName);
|
||||||
|
assertRuntimeName(runtimeNames, alias);
|
||||||
|
aliases.put(rawName, alias);
|
||||||
|
ownedNames.add(alias);
|
||||||
|
});
|
||||||
|
spec.setName("skill_" + safeSegment(skillId) + "_mcp_" + safeSegment(mcpId));
|
||||||
|
spec.setGroupName(spec.getName());
|
||||||
|
spec.setSkillId(skillId);
|
||||||
|
spec.setFrozenToolManifest(manifest);
|
||||||
|
spec.setFrozenToolManifestHash(manifestHash);
|
||||||
|
spec.setEnableTools(manifest.stream().map(McpToolManifestEntry::getName).toList());
|
||||||
|
spec.setToolAliases(aliases);
|
||||||
|
spec.setToolNamePrefix(null);
|
||||||
|
spec.getMetadata().put("skillId", skillId);
|
||||||
|
spec.getMetadata().put("skillDisplayName", displayName);
|
||||||
|
attachApprovalMetadata(spec.getApprovalRequest(), skillId, displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void attachSkillMetadata(AgentToolSpec spec, String skillId, String displayName) {
|
||||||
|
spec.getMetadata().put("skillId", skillId);
|
||||||
|
spec.getMetadata().put("skillDisplayName", displayName);
|
||||||
|
attachApprovalMetadata(spec.getApprovalRequest(), skillId, displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void attachApprovalMetadata(AgentToolApprovalRequest request, String skillId, String displayName) {
|
||||||
|
if (request == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
request.getMetadata().put("skillId", skillId);
|
||||||
|
request.getMetadata().put("skillDisplayName", displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> directTargetOwners(Agent agent) {
|
||||||
|
Map<String, String> owners = new HashMap<>();
|
||||||
|
if (agent == null || agent.getToolBindings() == null) {
|
||||||
|
return owners;
|
||||||
|
}
|
||||||
|
for (AgentToolBinding binding : agent.getToolBindings()) {
|
||||||
|
if (binding == null || !Boolean.TRUE.equals(binding.getEnabled()) || binding.getTargetId() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
owners.put(binding.getToolType().toUpperCase(Locale.ROOT) + ":" + binding.getTargetId(), "Agent 直接工具");
|
||||||
|
}
|
||||||
|
return owners;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertUniqueTarget(Map<String, String> owners,
|
||||||
|
AgentToolBinding binding,
|
||||||
|
String displayName) {
|
||||||
|
String key = binding.getToolType().toUpperCase(Locale.ROOT) + ":" + binding.getTargetId();
|
||||||
|
String existing = owners.putIfAbsent(key, displayName);
|
||||||
|
if (existing != null) {
|
||||||
|
throw new BusinessException("工具资源重复:" + existing + " 与 " + displayName + " 引用了 " + key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Map<String, Object>> bindingSnapshots(Map<String, Object> snapshot) {
|
||||||
|
Object value = snapshot.get("toolBindings");
|
||||||
|
if (!(value instanceof List<?> list)) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<Map<String, Object>> result = new ArrayList<>();
|
||||||
|
for (Object item : list) {
|
||||||
|
if (!(item instanceof Map<?, ?>)) {
|
||||||
|
throw new BusinessException("Skill Tool 运行快照格式错误");
|
||||||
|
}
|
||||||
|
result.add(toStringMap(item));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasCompleteSnapshots(List<AgentSkillBinding> bindings) {
|
||||||
|
return bindings.stream().allMatch(binding -> binding != null
|
||||||
|
&& binding.getResourceSnapshot() != null
|
||||||
|
&& !binding.getResourceSnapshot().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertRuntimeName(Set<String> names, String name) {
|
||||||
|
if (name == null || name.isBlank() || !names.add(name)) {
|
||||||
|
throw new BusinessException("Agent Skill Tool 运行名冲突:" + name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> toStringMap(Object value) {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
((Map<?, ?>) value).forEach((key, item) -> result.put(String.valueOf(key), item));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requiredText(Map<String, Object> source, String key, String message) {
|
||||||
|
String value = text(source.get(key));
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new BusinessException(message);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String text(Object value) { return value == null ? null : String.valueOf(value); }
|
||||||
|
|
||||||
|
private String firstText(String first, String second) {
|
||||||
|
return first == null || first.isBlank() ? second : first;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigInteger bigInteger(Object value, String message) {
|
||||||
|
if (value == null) {
|
||||||
|
throw new BusinessException(message);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new BigInteger(String.valueOf(value));
|
||||||
|
} catch (NumberFormatException exception) {
|
||||||
|
throw new BusinessException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer number(Object value, int fallback) {
|
||||||
|
return value instanceof Number number ? number.intValue() : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeSegment(String value) {
|
||||||
|
String normalized = String.valueOf(value == null ? "" : value).trim()
|
||||||
|
.replaceAll("[^A-Za-z0-9_-]", "_").replaceAll("_+", "_");
|
||||||
|
if (normalized.length() > 28) {
|
||||||
|
normalized = normalized.substring(0, 28);
|
||||||
|
}
|
||||||
|
return normalized.isBlank() ? "tool" : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String shortHash(String value) {
|
||||||
|
return SkillHashes.sha256Hex(String.valueOf(value).getBytes(StandardCharsets.UTF_8)).substring(0, 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
package tech.easyflow.agent.runtime.skill;
|
||||||
|
|
||||||
|
import com.easyagents.skill.util.SkillHashes;
|
||||||
|
import com.easyagents.skill.util.SkillPaths;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.agent.entity.Agent;
|
||||||
|
import tech.easyflow.agent.entity.AgentSkillBinding;
|
||||||
|
import tech.easyflow.agent.service.AgentDependencyAccessService;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.skill.entity.Skill;
|
||||||
|
import tech.easyflow.skill.service.SkillService;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将已发布 Skill 内容与平台 Tool 快照投影为 Agent 内部运行快照。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AgentSkillRuntimeProjector {
|
||||||
|
|
||||||
|
/** 单个 Agent 的 Skill 原始文本投影上限。 */
|
||||||
|
public static final long MAX_TEXT_BYTES = 8L * 1024L * 1024L;
|
||||||
|
/** 单个 Agent 的 Skill 绑定数量上限。 */
|
||||||
|
public static final int MAX_SKILL_COUNT = 20;
|
||||||
|
|
||||||
|
private final AgentDependencyAccessService dependencyAccessService;
|
||||||
|
private final SkillService skillService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Agent Skill 运行投影器。
|
||||||
|
*
|
||||||
|
* @param dependencyAccessService Agent 依赖权限服务
|
||||||
|
* @param skillService Skill 服务
|
||||||
|
* @param objectMapper JSON 映射器
|
||||||
|
*/
|
||||||
|
public AgentSkillRuntimeProjector(AgentDependencyAccessService dependencyAccessService,
|
||||||
|
SkillService skillService,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.dependencyAccessService = dependencyAccessService;
|
||||||
|
this.skillService = skillService;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验当前绑定并构建 Agent 发布用冻结 Skill 运行投影。
|
||||||
|
*
|
||||||
|
* @param agent Agent
|
||||||
|
* @param bindings Skill 草稿绑定
|
||||||
|
* @return 带内部运行快照及脱敏摘要的绑定副本
|
||||||
|
*/
|
||||||
|
public List<AgentSkillBinding> projectCurrentBindings(Agent agent,
|
||||||
|
List<AgentSkillBinding> bindings) {
|
||||||
|
if (bindings == null || bindings.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (bindings.size() > MAX_SKILL_COUNT) {
|
||||||
|
throw new BusinessException(409, 4092, "单个 Agent 最多可绑定 20 个 Skill");
|
||||||
|
}
|
||||||
|
Map<BigInteger, Skill> skills = loadSkillsInStableLockOrder(agent, bindings);
|
||||||
|
Set<BigInteger> unique = new HashSet<>();
|
||||||
|
List<AgentSkillBinding> projected = new ArrayList<>();
|
||||||
|
long totalBytes = 0L;
|
||||||
|
for (AgentSkillBinding binding : bindings) {
|
||||||
|
if (binding == null || binding.getSkillId() == null || !unique.add(binding.getSkillId())) {
|
||||||
|
throw new BusinessException(409, 4092, "同一 Skill 不能重复绑定");
|
||||||
|
}
|
||||||
|
Skill skill = skills.get(binding.getSkillId());
|
||||||
|
Projection projection = project(skill);
|
||||||
|
totalBytes = Math.addExact(totalBytes, projection.textBytes());
|
||||||
|
if (totalBytes > MAX_TEXT_BYTES) {
|
||||||
|
throw new BusinessException(409, 4092,
|
||||||
|
"Agent Skill 文本投影超过 8 MiB,请减少绑定或精简文本资源");
|
||||||
|
}
|
||||||
|
AgentSkillBinding copy = copyBinding(binding);
|
||||||
|
copy.setResourceSnapshot(projection.runtimeSnapshot());
|
||||||
|
copy.setResourceSummary(projection.summary());
|
||||||
|
projected.add(copy);
|
||||||
|
}
|
||||||
|
return projected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为详情页构建单个当前 Skill 的脱敏摘要。
|
||||||
|
*
|
||||||
|
* @param skill 已发布 Skill
|
||||||
|
* @param publishedRuntimeHash Agent 线上冻结的组合 hash,可为空
|
||||||
|
* @return 脱敏摘要
|
||||||
|
*/
|
||||||
|
public Map<String, Object> currentSummary(Skill skill, String publishedRuntimeHash) {
|
||||||
|
Projection projection = project(skill);
|
||||||
|
Map<String, Object> summary = new LinkedHashMap<>(projection.summary());
|
||||||
|
summary.put("hasUpdate", publishedRuntimeHash != null
|
||||||
|
&& !publishedRuntimeHash.equals(summary.get("skillRuntimeSnapshotHash")));
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 Agent 快照中已经冻结的 Skill 文本投影。
|
||||||
|
*
|
||||||
|
* @param bindings 冻结绑定
|
||||||
|
*/
|
||||||
|
public void assertFrozenBindings(List<AgentSkillBinding> bindings) {
|
||||||
|
if (bindings == null || bindings.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (bindings.size() > MAX_SKILL_COUNT) {
|
||||||
|
throw new BusinessException("Agent 发布快照中的 Skill 数量超过 20 个");
|
||||||
|
}
|
||||||
|
long totalBytes = 0L;
|
||||||
|
Set<String> ids = new HashSet<>();
|
||||||
|
for (AgentSkillBinding binding : bindings) {
|
||||||
|
Map<String, Object> snapshot = binding == null ? null : binding.getResourceSnapshot();
|
||||||
|
if (snapshot == null || snapshot.isEmpty()) {
|
||||||
|
throw new BusinessException("Agent Skill 运行快照为空");
|
||||||
|
}
|
||||||
|
String skillId = text(snapshot.get("skillId"));
|
||||||
|
if (skillId == null || !ids.add(skillId)) {
|
||||||
|
throw new BusinessException("Agent Skill 运行快照包含重复或空 Skill ID");
|
||||||
|
}
|
||||||
|
String declaredHash = text(snapshot.get("skillRuntimeSnapshotHash"));
|
||||||
|
Map<String, Object> canonical = new LinkedHashMap<>(snapshot);
|
||||||
|
canonical.remove("skillRuntimeSnapshotHash");
|
||||||
|
if (declaredHash == null || !declaredHash.equals(hash(canonical))) {
|
||||||
|
throw new BusinessException("Agent Skill 运行快照 hash 校验失败:" + skillId);
|
||||||
|
}
|
||||||
|
totalBytes = Math.addExact(totalBytes, frozenTextBytes(snapshot));
|
||||||
|
if (totalBytes > MAX_TEXT_BYTES) {
|
||||||
|
throw new BusinessException("Agent 发布快照中的 Skill 文本投影超过 8 MiB");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 Skill ID 锁顺序加载并复核权限,降低并发死锁概率。
|
||||||
|
*
|
||||||
|
* @param agent Agent
|
||||||
|
* @param bindings Skill 绑定
|
||||||
|
* @return Skill ID 到实体的映射
|
||||||
|
*/
|
||||||
|
private Map<BigInteger, Skill> loadSkillsInStableLockOrder(Agent agent,
|
||||||
|
List<AgentSkillBinding> bindings) {
|
||||||
|
List<BigInteger> ids = bindings.stream()
|
||||||
|
.filter(binding -> binding != null && binding.getSkillId() != null)
|
||||||
|
.map(AgentSkillBinding::getSkillId)
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
.toList();
|
||||||
|
Map<BigInteger, Skill> result = new LinkedHashMap<>();
|
||||||
|
for (BigInteger id : ids) {
|
||||||
|
result.put(id, dependencyAccessService.requireSkill(agent, id));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建单个 Skill 运行投影。
|
||||||
|
*
|
||||||
|
* @param skill 已发布 Skill
|
||||||
|
* @return 运行投影与摘要
|
||||||
|
*/
|
||||||
|
private Projection project(Skill skill) {
|
||||||
|
Map<String, Object> content = skill.getPublishedSnapshotJson();
|
||||||
|
skillService.assertPublishedAggregateHash(skill);
|
||||||
|
String skillContent = text(content.get("skillContent"));
|
||||||
|
Map<String, String> textResources = new TreeMap<>();
|
||||||
|
int binaryCount = 0;
|
||||||
|
long textBytes = utf8Length(skillContent);
|
||||||
|
Set<String> paths = new HashSet<>();
|
||||||
|
Object rawResources = content.get("resources");
|
||||||
|
if (rawResources instanceof List<?> resources) {
|
||||||
|
for (Object raw : resources) {
|
||||||
|
if (!(raw instanceof Map<?, ?> item)) {
|
||||||
|
throw new BusinessException("Skill 发布快照资源格式错误:" + skill.getName());
|
||||||
|
}
|
||||||
|
String path = normalizePath(text(item.get("path")));
|
||||||
|
if (!paths.add(path.toLowerCase(java.util.Locale.ROOT))) {
|
||||||
|
throw new BusinessException("Skill 发布快照资源路径重复:" + path);
|
||||||
|
}
|
||||||
|
if (!Boolean.TRUE.equals(item.get("text"))) {
|
||||||
|
binaryCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String value = text(item.get("textContent"));
|
||||||
|
if (value == null) {
|
||||||
|
throw new BusinessException("Skill 文本资源正文缺失:" + path);
|
||||||
|
}
|
||||||
|
textResources.put(path, value);
|
||||||
|
textBytes = Math.addExact(textBytes, utf8Length(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<String, Object> toolSnapshot = skill.getPublishedToolBindingsJson() == null
|
||||||
|
? Map.of() : skill.getPublishedToolBindingsJson();
|
||||||
|
String contentHash = text(content.get("snapshotHash"));
|
||||||
|
String toolHash = text(toolSnapshot.get("snapshotHash"));
|
||||||
|
// 新版发布快照冻结展示字段;历史快照显式回退当前行以保持兼容。
|
||||||
|
String displayName = firstText(text(content.get("displayName")),
|
||||||
|
firstText(skill.getDisplayName(), skill.getName()));
|
||||||
|
String visibilityScope = firstText(text(content.get("visibilityScope")),
|
||||||
|
skill.getVisibilityScope());
|
||||||
|
|
||||||
|
Map<String, Object> runtime = new LinkedHashMap<>();
|
||||||
|
runtime.put("schemaVersion", 1);
|
||||||
|
runtime.put("skillId", skill.getId().toString());
|
||||||
|
runtime.put("name", content.get("name"));
|
||||||
|
runtime.put("displayName", displayName);
|
||||||
|
runtime.put("description", content.get("description"));
|
||||||
|
runtime.put("skillContent", skillContent);
|
||||||
|
runtime.put("packageHash", content.get("packageHash"));
|
||||||
|
runtime.put("skillSnapshotHash", contentHash);
|
||||||
|
runtime.put("toolBindingsHash", toolHash == null ? "" : toolHash);
|
||||||
|
runtime.put("resources", textResources);
|
||||||
|
runtime.put("toolBindings", toolBindings(toolSnapshot));
|
||||||
|
runtime.put("source", "easyflow://skill/" + skill.getId());
|
||||||
|
String runtimeHash = hash(runtime);
|
||||||
|
runtime.put("skillRuntimeSnapshotHash", runtimeHash);
|
||||||
|
|
||||||
|
Map<String, Object> summary = new LinkedHashMap<>();
|
||||||
|
summary.put("skillId", skill.getId());
|
||||||
|
summary.put("displayName", displayName);
|
||||||
|
summary.put("description", content.get("description"));
|
||||||
|
summary.put("visibilityScope", visibilityScope);
|
||||||
|
summary.put("skillSnapshotHash", contentHash);
|
||||||
|
summary.put("toolBindingsHash", toolHash == null ? "" : toolHash);
|
||||||
|
summary.put("skillRuntimeSnapshotHash", runtimeHash);
|
||||||
|
summary.put("textBytes", textBytes);
|
||||||
|
summary.put("textResourceCount", textResources.size());
|
||||||
|
summary.put("binaryExcludedCount", binaryCount);
|
||||||
|
summary.put("toolCount", toolCount(toolSnapshot));
|
||||||
|
return new Projection(runtime, summary, textBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从平台 Tool 快照提取冻结绑定数组。
|
||||||
|
*
|
||||||
|
* @param toolSnapshot 平台 Tool 快照
|
||||||
|
* @return Tool 绑定数组
|
||||||
|
*/
|
||||||
|
private List<?> toolBindings(Map<String, Object> toolSnapshot) {
|
||||||
|
Object value = toolSnapshot.get("bindings");
|
||||||
|
return value instanceof List<?> list ? list : List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 汇总实际 Tool 数。
|
||||||
|
*
|
||||||
|
* @param toolSnapshot 平台 Tool 快照
|
||||||
|
* @return Tool 数量
|
||||||
|
*/
|
||||||
|
private int toolCount(Map<String, Object> toolSnapshot) {
|
||||||
|
int count = 0;
|
||||||
|
for (Object raw : toolBindings(toolSnapshot)) {
|
||||||
|
if (raw instanceof Map<?, ?> item && item.get("toolCount") instanceof Number number) {
|
||||||
|
count += number.intValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算冻结快照文本字节数。
|
||||||
|
*
|
||||||
|
* @param snapshot Skill 运行快照
|
||||||
|
* @return UTF-8 字节数
|
||||||
|
*/
|
||||||
|
private long frozenTextBytes(Map<String, Object> snapshot) {
|
||||||
|
long total = utf8Length(text(snapshot.get("skillContent")));
|
||||||
|
Object resources = snapshot.get("resources");
|
||||||
|
if (resources instanceof Map<?, ?> map) {
|
||||||
|
for (Object value : map.values()) {
|
||||||
|
total = Math.addExact(total, utf8Length(text(value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建无内部快照副作用的绑定副本。
|
||||||
|
*
|
||||||
|
* @param source 原绑定
|
||||||
|
* @return 绑定副本
|
||||||
|
*/
|
||||||
|
private AgentSkillBinding copyBinding(AgentSkillBinding source) {
|
||||||
|
AgentSkillBinding copy = new AgentSkillBinding();
|
||||||
|
copy.setId(source.getId());
|
||||||
|
copy.setTenantId(source.getTenantId());
|
||||||
|
copy.setAgentId(source.getAgentId());
|
||||||
|
copy.setSkillId(source.getSkillId());
|
||||||
|
copy.setSortNo(source.getSortNo());
|
||||||
|
copy.setCreated(source.getCreated());
|
||||||
|
copy.setCreatedBy(source.getCreatedBy());
|
||||||
|
copy.setModified(source.getModified());
|
||||||
|
copy.setModifiedBy(source.getModifiedBy());
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算内容与 Tool 的组合运行 hash。
|
||||||
|
*
|
||||||
|
* @param contentHash 内容快照 hash
|
||||||
|
* @param toolHash Tool 快照 hash
|
||||||
|
* @return 组合 SHA-256
|
||||||
|
*/
|
||||||
|
private String hash(Map<String, Object> value) {
|
||||||
|
try {
|
||||||
|
return SkillHashes.sha256Hex(objectMapper.writeValueAsBytes(canonicalizeJson(value)));
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw new BusinessException("Agent Skill 运行快照序列化失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将运行快照转换为稳定 JSON 结构,确保发布前 POJO 与落库后的 Map 产生相同 hash。
|
||||||
|
*
|
||||||
|
* @param value 原始快照值
|
||||||
|
* @return 按键排序且仅包含 JSON 基础类型的值
|
||||||
|
*/
|
||||||
|
private Object canonicalizeJson(Object value) {
|
||||||
|
if (value instanceof Map<?, ?> map) {
|
||||||
|
Map<String, Object> sorted = new TreeMap<>();
|
||||||
|
map.forEach((key, item) -> sorted.put(String.valueOf(key), canonicalizeJson(item)));
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
if (value instanceof List<?> list) {
|
||||||
|
return list.stream().map(this::canonicalizeJson).toList();
|
||||||
|
}
|
||||||
|
if (value == null || value instanceof String || value instanceof Number
|
||||||
|
|| value instanceof Boolean) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return canonicalizeJson(objectMapper.convertValue(value, Object.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范资源路径。
|
||||||
|
*
|
||||||
|
* @param path 原始路径
|
||||||
|
* @return 规范路径
|
||||||
|
*/
|
||||||
|
private String normalizePath(String path) {
|
||||||
|
if (path == null || path.isBlank()) {
|
||||||
|
throw new BusinessException("Skill 发布快照资源路径不能为空");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return SkillPaths.normalize(path);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw new BusinessException("Skill 发布快照资源路径不合法:" + path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取文本。
|
||||||
|
*
|
||||||
|
* @param value 原值
|
||||||
|
* @return 文本或 null
|
||||||
|
*/
|
||||||
|
private String text(Object value) {
|
||||||
|
return value == null ? null : String.valueOf(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取首个非空文本。
|
||||||
|
*
|
||||||
|
* @param first 首选值
|
||||||
|
* @param second 备选值
|
||||||
|
* @return 非空文本
|
||||||
|
*/
|
||||||
|
private String firstText(String first, String second) {
|
||||||
|
return first == null || first.isBlank() ? second : first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算 UTF-8 字节数。
|
||||||
|
*
|
||||||
|
* @param value 文本
|
||||||
|
* @return 字节数
|
||||||
|
*/
|
||||||
|
private long utf8Length(String value) {
|
||||||
|
return (value == null ? "" : value).getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个 Skill 的运行投影结果。
|
||||||
|
*
|
||||||
|
* @param runtimeSnapshot 内部运行快照
|
||||||
|
* @param summary 脱敏摘要
|
||||||
|
* @param textBytes 文本 UTF-8 字节数
|
||||||
|
*/
|
||||||
|
private record Projection(Map<String, Object> runtimeSnapshot,
|
||||||
|
Map<String, Object> summary,
|
||||||
|
long textBytes) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import com.easyagents.agent.runtime.tool.asynctool.AsyncToolSpecExpander;
|
|||||||
import com.easyagents.core.model.chat.tool.Parameter;
|
import com.easyagents.core.model.chat.tool.Parameter;
|
||||||
import com.easyagents.core.model.chat.tool.Tool;
|
import com.easyagents.core.model.chat.tool.Tool;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import tech.easyflow.agent.entity.Agent;
|
import tech.easyflow.agent.entity.Agent;
|
||||||
@@ -19,10 +21,12 @@ import tech.easyflow.agent.runtime.asynctool.PluginAsyncSubTools;
|
|||||||
import tech.easyflow.agent.runtime.asynctool.WorkflowAsyncSubTools;
|
import tech.easyflow.agent.runtime.asynctool.WorkflowAsyncSubTools;
|
||||||
import tech.easyflow.ai.easyagents.tool.ChatToolNameHelper;
|
import tech.easyflow.ai.easyagents.tool.ChatToolNameHelper;
|
||||||
import tech.easyflow.ai.entity.Mcp;
|
import tech.easyflow.ai.entity.Mcp;
|
||||||
|
import tech.easyflow.ai.entity.Plugin;
|
||||||
import tech.easyflow.ai.entity.PluginItem;
|
import tech.easyflow.ai.entity.PluginItem;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
import tech.easyflow.ai.service.McpService;
|
import tech.easyflow.ai.service.McpService;
|
||||||
import tech.easyflow.ai.service.PluginItemService;
|
import tech.easyflow.ai.service.PluginItemService;
|
||||||
|
import tech.easyflow.ai.service.PluginService;
|
||||||
import tech.easyflow.ai.service.WorkflowService;
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
@@ -39,6 +43,8 @@ import java.util.regex.Pattern;
|
|||||||
@Component
|
@Component
|
||||||
public class AgentToolRuntimeCompiler {
|
public class AgentToolRuntimeCompiler {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(AgentToolRuntimeCompiler.class);
|
||||||
|
private static final String TOOL_FAILURE_MESSAGE = "工具执行失败,请稍后重试";
|
||||||
private static final Pattern MCP_INPUT_PATTERN = Pattern.compile("\\$\\{input:([A-Za-z0-9_.-]+)}");
|
private static final Pattern MCP_INPUT_PATTERN = Pattern.compile("\\$\\{input:([A-Za-z0-9_.-]+)}");
|
||||||
private static final Pattern ASYNC_SAFE_NAME = Pattern.compile("^[a-z][a-z0-9_]*$");
|
private static final Pattern ASYNC_SAFE_NAME = Pattern.compile("^[a-z][a-z0-9_]*$");
|
||||||
|
|
||||||
@@ -47,6 +53,8 @@ public class AgentToolRuntimeCompiler {
|
|||||||
@Resource
|
@Resource
|
||||||
private PluginItemService pluginItemService;
|
private PluginItemService pluginItemService;
|
||||||
@Resource
|
@Resource
|
||||||
|
private PluginService pluginService;
|
||||||
|
@Resource
|
||||||
private McpService mcpService;
|
private McpService mcpService;
|
||||||
@Resource
|
@Resource
|
||||||
private ObjectMapper objectMapper;
|
private ObjectMapper objectMapper;
|
||||||
@@ -66,8 +74,21 @@ public class AgentToolRuntimeCompiler {
|
|||||||
* @return 工具编译结果
|
* @return 工具编译结果
|
||||||
*/
|
*/
|
||||||
public AgentToolRuntimeCompilation compile(Agent agent) {
|
public AgentToolRuntimeCompilation compile(Agent agent) {
|
||||||
|
return compileBindings(agent == null ? null : agent.getToolBindings());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编译一组服务端已规范化的工具绑定。
|
||||||
|
*
|
||||||
|
* <p>Agent 直接工具和 Skill 冻结工具共用该入口,避免 Workflow、Plugin、MCP
|
||||||
|
* 的快照解析、调用器与 HITL 规则形成两套实现。</p>
|
||||||
|
*
|
||||||
|
* @param bindings 工具绑定
|
||||||
|
* @return 工具编译结果
|
||||||
|
*/
|
||||||
|
public AgentToolRuntimeCompilation compileBindings(List<AgentToolBinding> bindings) {
|
||||||
AgentToolRuntimeCompilation compilation = new AgentToolRuntimeCompilation();
|
AgentToolRuntimeCompilation compilation = new AgentToolRuntimeCompilation();
|
||||||
if (agent == null || agent.getToolBindings() == null) {
|
if (bindings == null) {
|
||||||
return compilation;
|
return compilation;
|
||||||
}
|
}
|
||||||
List<AgentToolSpec> specs = new ArrayList<>();
|
List<AgentToolSpec> specs = new ArrayList<>();
|
||||||
@@ -76,7 +97,7 @@ public class AgentToolRuntimeCompiler {
|
|||||||
Map<BigInteger, McpSpec> mcpSpecMap = new LinkedHashMap<>();
|
Map<BigInteger, McpSpec> mcpSpecMap = new LinkedHashMap<>();
|
||||||
Set<String> compiledToolNames = new LinkedHashSet<>();
|
Set<String> compiledToolNames = new LinkedHashSet<>();
|
||||||
AsyncToolSpecExpander asyncExpander = new AsyncToolSpecExpander();
|
AsyncToolSpecExpander asyncExpander = new AsyncToolSpecExpander();
|
||||||
for (AgentToolBinding binding : agent.getToolBindings()) {
|
for (AgentToolBinding binding : bindings) {
|
||||||
if (!Boolean.TRUE.equals(binding.getEnabled())) {
|
if (!Boolean.TRUE.equals(binding.getEnabled())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -139,16 +160,17 @@ public class AgentToolRuntimeCompiler {
|
|||||||
Workflow workflow = requireWorkflow(binding);
|
Workflow workflow = requireWorkflow(binding);
|
||||||
Tool tool = workflowToolExecutor.buildTool(workflow);
|
Tool tool = workflowToolExecutor.buildTool(workflow);
|
||||||
AgentToolSpec spec = toToolSpec(tool, binding);
|
AgentToolSpec spec = toToolSpec(tool, binding);
|
||||||
AgentToolInvoker invoker = (arguments, context) -> invokeSafely(spec.getName(),
|
AgentToolInvoker invoker = (arguments, context) -> invokeSafely(spec.getName(), binding, context,
|
||||||
() -> workflowToolExecutor.execute(workflow, arguments).getResult());
|
() -> workflowToolExecutor.execute(workflow, arguments).getResult());
|
||||||
return new CompiledSyncTool(spec, invoker);
|
return new CompiledSyncTool(spec, invoker);
|
||||||
}
|
}
|
||||||
if (type == AgentToolType.PLUGIN) {
|
if (type == AgentToolType.PLUGIN) {
|
||||||
PluginItem pluginItem = requirePlugin(binding);
|
PluginRuntimeResource plugin = requirePlugin(binding);
|
||||||
Tool tool = pluginToolExecutor.buildTool(pluginItem);
|
PluginItem pluginItem = plugin.pluginItem();
|
||||||
|
Tool tool = pluginToolExecutor.buildTool(pluginItem, plugin.plugin());
|
||||||
AgentToolSpec spec = toToolSpec(tool, binding);
|
AgentToolSpec spec = toToolSpec(tool, binding);
|
||||||
AgentToolInvoker invoker = (arguments, context) -> invokeSafely(spec.getName(),
|
AgentToolInvoker invoker = (arguments, context) -> invokeSafely(spec.getName(), binding, context,
|
||||||
() -> pluginToolExecutor.execute(pluginItem, arguments).getResult());
|
() -> pluginToolExecutor.execute(pluginItem, plugin.plugin(), arguments).getResult());
|
||||||
return new CompiledSyncTool(spec, invoker);
|
return new CompiledSyncTool(spec, invoker);
|
||||||
}
|
}
|
||||||
throw new BusinessException("不支持的 Agent 工具类型:" + type.name());
|
throw new BusinessException("不支持的 Agent 工具类型:" + type.name());
|
||||||
@@ -166,12 +188,13 @@ public class AgentToolRuntimeCompiler {
|
|||||||
return spec;
|
return spec;
|
||||||
}
|
}
|
||||||
if (type == AgentToolType.PLUGIN) {
|
if (type == AgentToolType.PLUGIN) {
|
||||||
PluginItem pluginItem = requirePlugin(binding);
|
PluginRuntimeResource plugin = requirePlugin(binding);
|
||||||
Tool tool = pluginToolExecutor.buildTool(pluginItem);
|
PluginItem pluginItem = plugin.pluginItem();
|
||||||
|
Tool tool = pluginToolExecutor.buildTool(pluginItem, plugin.plugin());
|
||||||
String asyncName = asyncToolName(tool, binding, "plugin");
|
String asyncName = asyncToolName(tool, binding, "plugin");
|
||||||
String toolDisplayName = displayName(tool, pluginItem.getName());
|
String toolDisplayName = displayName(tool, pluginItem.getName());
|
||||||
AsyncToolSpec spec = baseAsyncSpec(asyncName, tool, binding, toolDisplayName);
|
AsyncToolSpec spec = baseAsyncSpec(asyncName, tool, binding, toolDisplayName);
|
||||||
spec.setSubTools(new PluginAsyncSubTools(pluginItem, asyncName, toolDisplayName,
|
spec.setSubTools(new PluginAsyncSubTools(pluginItem, plugin.plugin(), asyncName, toolDisplayName,
|
||||||
pluginToolExecutor, asyncToolTaskStore, agentAsyncToolExecutor));
|
pluginToolExecutor, asyncToolTaskStore, agentAsyncToolExecutor));
|
||||||
return spec;
|
return spec;
|
||||||
}
|
}
|
||||||
@@ -195,12 +218,27 @@ public class AgentToolRuntimeCompiler {
|
|||||||
return spec;
|
return spec;
|
||||||
}
|
}
|
||||||
|
|
||||||
private AgentToolResult invokeSafely(String toolName, ToolCall call) {
|
private AgentToolResult invokeSafely(String toolName,
|
||||||
|
AgentToolBinding binding,
|
||||||
|
AgentToolContext context,
|
||||||
|
ToolCall call) {
|
||||||
try {
|
try {
|
||||||
Object result = call.invoke();
|
Object result = call.invoke();
|
||||||
return AgentToolResult.success(result == null ? "" : String.valueOf(result));
|
return AgentToolResult.success(result == null ? "" : String.valueOf(result));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return AgentToolResult.failure(e.getMessage() == null ? "工具执行失败" : e.getMessage());
|
LOG.error("Agent Tool execution failed: toolName={}, toolType={}, targetId={}, bindingId={}, "
|
||||||
|
+ "agentId={}, sessionId={}, requestId={}, traceId={}, toolCallId={}",
|
||||||
|
toolName,
|
||||||
|
binding == null ? null : binding.getToolType(),
|
||||||
|
binding == null ? null : binding.getTargetId(),
|
||||||
|
binding == null ? null : binding.getId(),
|
||||||
|
context == null ? null : context.getAgentId(),
|
||||||
|
context == null ? null : context.getSessionId(),
|
||||||
|
context == null ? null : context.getRequestId(),
|
||||||
|
context == null ? null : context.getTraceId(),
|
||||||
|
context == null ? null : context.getToolCallId(),
|
||||||
|
e);
|
||||||
|
return AgentToolResult.failure(TOOL_FAILURE_MESSAGE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,12 +255,12 @@ public class AgentToolRuntimeCompiler {
|
|||||||
return workflow;
|
return workflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
private PluginItem requirePlugin(AgentToolBinding binding) {
|
private PluginRuntimeResource requirePlugin(AgentToolBinding binding) {
|
||||||
PluginItem pluginItem = snapshotOrCurrentPlugin(binding);
|
PluginRuntimeResource plugin = snapshotOrCurrentPlugin(binding);
|
||||||
if (pluginItem == null) {
|
if (plugin == null || plugin.pluginItem() == null || plugin.plugin() == null) {
|
||||||
throw new BusinessException("绑定插件不存在");
|
throw new BusinessException("绑定插件不存在");
|
||||||
}
|
}
|
||||||
return pluginItem;
|
return plugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
private AgentToolSpec toToolSpec(Tool tool, AgentToolBinding binding) {
|
private AgentToolSpec toToolSpec(Tool tool, AgentToolBinding binding) {
|
||||||
@@ -320,13 +358,20 @@ public class AgentToolRuntimeCompiler {
|
|||||||
return workflowService.getPublishedById(binding.getTargetId());
|
return workflowService.getPublishedById(binding.getTargetId());
|
||||||
}
|
}
|
||||||
|
|
||||||
private PluginItem snapshotOrCurrentPlugin(AgentToolBinding binding) {
|
private PluginRuntimeResource snapshotOrCurrentPlugin(AgentToolBinding binding) {
|
||||||
if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) {
|
if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) {
|
||||||
PluginItem pluginItem = objectMapper.convertValue(binding.getResourceSnapshot(), PluginItem.class);
|
Map<String, Object> snapshot = binding.getResourceSnapshot();
|
||||||
|
Object itemValue = snapshot.containsKey("pluginItem") ? snapshot.get("pluginItem") : snapshot;
|
||||||
|
PluginItem pluginItem = objectMapper.convertValue(itemValue, PluginItem.class);
|
||||||
pluginItem.setId(firstNonNull(pluginItem.getId(), binding.getTargetId()));
|
pluginItem.setId(firstNonNull(pluginItem.getId(), binding.getTargetId()));
|
||||||
return pluginItem;
|
Plugin plugin = snapshot.get("plugin") == null
|
||||||
|
? pluginService.getById(pluginItem.getPluginId())
|
||||||
|
: objectMapper.convertValue(snapshot.get("plugin"), Plugin.class);
|
||||||
|
return new PluginRuntimeResource(pluginItem, plugin);
|
||||||
}
|
}
|
||||||
return pluginItemService.getById(binding.getTargetId());
|
PluginItem pluginItem = pluginItemService.getById(binding.getTargetId());
|
||||||
|
Plugin plugin = pluginItem == null ? null : pluginService.getById(pluginItem.getPluginId());
|
||||||
|
return pluginItem == null ? null : new PluginRuntimeResource(pluginItem, plugin);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mcp snapshotOrCurrentMcp(AgentToolBinding binding) {
|
private Mcp snapshotOrCurrentMcp(AgentToolBinding binding) {
|
||||||
@@ -615,6 +660,10 @@ public class AgentToolRuntimeCompiler {
|
|||||||
private record CompiledSyncTool(AgentToolSpec spec, AgentToolInvoker invoker) {
|
private record CompiledSyncTool(AgentToolSpec spec, AgentToolInvoker invoker) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 冻结插件工具与父插件调用配置。 */
|
||||||
|
private record PluginRuntimeResource(PluginItem pluginItem, Plugin plugin) {
|
||||||
|
}
|
||||||
|
|
||||||
private interface ToolCall {
|
private interface ToolCall {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package tech.easyflow.agent.runtime.tool;
|
|||||||
|
|
||||||
import com.easyagents.core.model.chat.tool.Tool;
|
import com.easyagents.core.model.chat.tool.Tool;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import tech.easyflow.ai.entity.Plugin;
|
||||||
import tech.easyflow.ai.entity.PluginItem;
|
import tech.easyflow.ai.entity.PluginItem;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -22,6 +23,17 @@ public class PluginToolExecutor {
|
|||||||
return pluginItem.toFunction();
|
return pluginItem.toFunction();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用冻结的父插件配置构建工具声明和执行对象。
|
||||||
|
*
|
||||||
|
* @param pluginItem 插件工具快照
|
||||||
|
* @param plugin 父插件调用配置快照
|
||||||
|
* @return 工具声明来源
|
||||||
|
*/
|
||||||
|
public Tool buildTool(PluginItem pluginItem, Plugin plugin) {
|
||||||
|
return pluginItem.toFunction(plugin);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行 Plugin 工具。
|
* 执行 Plugin 工具。
|
||||||
*
|
*
|
||||||
@@ -33,4 +45,19 @@ public class PluginToolExecutor {
|
|||||||
Object result = buildTool(pluginItem).invoke(arguments == null ? Map.of() : arguments);
|
Object result = buildTool(pluginItem).invoke(arguments == null ? Map.of() : arguments);
|
||||||
return new AgentToolExecutionResult(result, null);
|
return new AgentToolExecutionResult(result, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用冻结父插件配置执行插件工具。
|
||||||
|
*
|
||||||
|
* @param pluginItem 插件工具快照
|
||||||
|
* @param plugin 父插件调用配置快照
|
||||||
|
* @param arguments 调用参数
|
||||||
|
* @return 执行结果
|
||||||
|
*/
|
||||||
|
public AgentToolExecutionResult execute(PluginItem pluginItem,
|
||||||
|
Plugin plugin,
|
||||||
|
Map<String, Object> arguments) {
|
||||||
|
Object result = buildTool(pluginItem, plugin).invoke(arguments == null ? Map.of() : arguments);
|
||||||
|
return new AgentToolExecutionResult(result, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package tech.easyflow.agent.runtime.tool;
|
|||||||
|
|
||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
import com.easyagents.core.model.chat.tool.Tool;
|
import com.easyagents.core.model.chat.tool.Tool;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import tech.easyflow.ai.easyagents.tool.WorkflowTool;
|
import tech.easyflow.ai.easyagents.tool.WorkflowTool;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.repository.FrozenWorkflowDefinitionRegistry;
|
||||||
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
|
||||||
@@ -16,14 +18,27 @@ import java.util.Map;
|
|||||||
public class WorkflowToolExecutor {
|
public class WorkflowToolExecutor {
|
||||||
|
|
||||||
private final ChainExecutor chainExecutor;
|
private final ChainExecutor chainExecutor;
|
||||||
|
private final FrozenWorkflowDefinitionRegistry frozenDefinitionRegistry;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 Workflow 工具执行器。
|
* 创建 Workflow 工具执行器。
|
||||||
*
|
*
|
||||||
* @param chainExecutor 工作流执行器
|
* @param chainExecutor 工作流执行器
|
||||||
*/
|
*/
|
||||||
public WorkflowToolExecutor(ChainExecutor chainExecutor) {
|
@Autowired
|
||||||
|
public WorkflowToolExecutor(ChainExecutor chainExecutor,
|
||||||
|
FrozenWorkflowDefinitionRegistry frozenDefinitionRegistry) {
|
||||||
this.chainExecutor = chainExecutor;
|
this.chainExecutor = chainExecutor;
|
||||||
|
this.frozenDefinitionRegistry = frozenDefinitionRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建仅供测试替身继承的执行器。
|
||||||
|
*
|
||||||
|
* @param chainExecutor 工作流执行器
|
||||||
|
*/
|
||||||
|
protected WorkflowToolExecutor(ChainExecutor chainExecutor) {
|
||||||
|
this(chainExecutor, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,11 +59,16 @@ public class WorkflowToolExecutor {
|
|||||||
* @return 执行结果
|
* @return 执行结果
|
||||||
*/
|
*/
|
||||||
public AgentToolExecutionResult execute(Workflow workflow, Map<String, Object> arguments) {
|
public AgentToolExecutionResult execute(Workflow workflow, Map<String, Object> arguments) {
|
||||||
Object result = chainExecutor.execute(definitionId(workflow), arguments == null ? Map.of() : arguments);
|
Object result = chainExecutor.executeWithoutSuspension(
|
||||||
|
definitionId(workflow), arguments == null ? Map.of() : arguments);
|
||||||
return new AgentToolExecutionResult(result, resolveBusinessExecutionId(result));
|
return new AgentToolExecutionResult(result, resolveBusinessExecutionId(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String definitionId(Workflow workflow) {
|
private String definitionId(Workflow workflow) {
|
||||||
|
if (frozenDefinitionRegistry != null && workflow != null
|
||||||
|
&& workflow.getContent() != null && !workflow.getContent().isBlank()) {
|
||||||
|
return frozenDefinitionRegistry.register(workflow);
|
||||||
|
}
|
||||||
return PublishedWorkflowDefinitionIds.published(String.valueOf(workflow == null ? null : workflow.getId()));
|
return PublishedWorkflowDefinitionIds.published(String.valueOf(workflow == null ? null : workflow.getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package tech.easyflow.agent.runtime.workspace;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.agent.config.AgentWorkspaceProperties;
|
||||||
|
import tech.easyflow.agent.runtime.AgentRunRegistry;
|
||||||
|
import tech.easyflow.agent.runtime.lock.AgentRunLock;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.FileVisitResult;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.SimpleFileVisitor;
|
||||||
|
import java.nio.file.attribute.BasicFileAttributes;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期且没有活动运行保护的本机会话工作区。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AgentWorkspaceCleanupService {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(AgentWorkspaceCleanupService.class);
|
||||||
|
private static final int MAX_DELETIONS_PER_RUN = 50;
|
||||||
|
|
||||||
|
private final AgentWorkspaceResolver resolver;
|
||||||
|
private final AgentWorkspaceProperties properties;
|
||||||
|
private final AgentRunRegistry runRegistry;
|
||||||
|
private final AgentRunLock agentRunLock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建工作区清理服务。
|
||||||
|
*
|
||||||
|
* @param resolver 工作区解析器
|
||||||
|
* @param properties 工作区配置
|
||||||
|
* @param runRegistry 活动运行注册表
|
||||||
|
* @param agentRunLock 会话级分布式运行锁
|
||||||
|
*/
|
||||||
|
public AgentWorkspaceCleanupService(AgentWorkspaceResolver resolver,
|
||||||
|
AgentWorkspaceProperties properties,
|
||||||
|
AgentRunRegistry runRegistry,
|
||||||
|
AgentRunLock agentRunLock) {
|
||||||
|
this.resolver = resolver;
|
||||||
|
this.properties = properties;
|
||||||
|
this.runRegistry = runRegistry;
|
||||||
|
this.agentRunLock = agentRunLock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按固定深度扫描会话目录并执行有界清理。
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelayString = "${easyflow.agent.workspace.cleanup-interval:30m}")
|
||||||
|
public void cleanup() {
|
||||||
|
Instant threshold = Instant.now().minus(properties.getRetention());
|
||||||
|
AtomicInteger deleted = new AtomicInteger();
|
||||||
|
try (var tenants = Files.list(resolver.getRealRoot())) {
|
||||||
|
tenants.filter(this::businessDirectory).forEach(tenant -> scanAgents(tenant, threshold, deleted));
|
||||||
|
} catch (IOException error) {
|
||||||
|
LOG.error("Scan Agent workspace root failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scanAgents(Path tenant, Instant threshold, AtomicInteger deleted) {
|
||||||
|
if (deleted.get() >= MAX_DELETIONS_PER_RUN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (var agents = Files.list(tenant)) {
|
||||||
|
agents.filter(this::businessDirectory).forEach(agent -> scanSessions(agent, threshold, deleted));
|
||||||
|
} catch (IOException error) {
|
||||||
|
LOG.error("Scan Agent workspace tenant directory failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scanSessions(Path agent, Instant threshold, AtomicInteger deleted) {
|
||||||
|
if (deleted.get() >= MAX_DELETIONS_PER_RUN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (var sessions = Files.list(agent)) {
|
||||||
|
sessions.filter(this::businessDirectory).forEach(session -> {
|
||||||
|
if (deleted.get() >= MAX_DELETIONS_PER_RUN || runRegistry.hasActiveSession(session.getFileName().toString())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AgentRunLock.Handle lockHandle = tryAcquireSessionLock(agent, session);
|
||||||
|
if (lockHandle == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (lockHandle) {
|
||||||
|
if (runRegistry.hasActiveSession(session.getFileName().toString())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Path activity = resolver.activityFile(session);
|
||||||
|
Instant lastActive = Files.exists(activity)
|
||||||
|
? Files.getLastModifiedTime(activity).toInstant()
|
||||||
|
: Files.getLastModifiedTime(session).toInstant();
|
||||||
|
if (lastActive.isAfter(threshold)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (runRegistry.hasActiveSession(session.getFileName().toString())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteTree(session);
|
||||||
|
Files.deleteIfExists(activity);
|
||||||
|
deleted.incrementAndGet();
|
||||||
|
} catch (IOException error) {
|
||||||
|
LOG.error("Clean expired Agent workspace failed", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (IOException error) {
|
||||||
|
LOG.error("Scan Agent workspace session directory failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentRunLock.Handle tryAcquireSessionLock(Path agent, Path session) {
|
||||||
|
try {
|
||||||
|
return agentRunLock.tryAcquire(
|
||||||
|
new BigInteger(agent.getFileName().toString()), session.getFileName().toString());
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
LOG.error("Acquire Agent workspace cleanup lock failed", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean businessDirectory(Path path) {
|
||||||
|
return Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)
|
||||||
|
&& !Files.isSymbolicLink(path)
|
||||||
|
&& !path.equals(resolver.getActivityRoot());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteTree(Path root) throws IOException {
|
||||||
|
Files.walkFileTree(root, new SimpleFileVisitor<>() {
|
||||||
|
@Override
|
||||||
|
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
|
||||||
|
Files.delete(file);
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FileVisitResult postVisitDirectory(Path directory, IOException error) throws IOException {
|
||||||
|
if (error != null) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
Files.delete(directory);
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package tech.easyflow.agent.runtime.workspace;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.agent.config.AgentWorkspaceProperties;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.LinkOption;
|
||||||
|
import java.nio.file.InvalidPathException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按租户、Agent 和运行会话分配本地工作区,并维护服务端活动标记。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AgentWorkspaceResolver {
|
||||||
|
|
||||||
|
private static final Pattern SESSION_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,127}");
|
||||||
|
private static final String ACTIVITY_DIRECTORY = ".easyflow-activity";
|
||||||
|
|
||||||
|
private final AgentWorkspaceProperties properties;
|
||||||
|
private Path realRoot;
|
||||||
|
private Path activityRoot;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建工作区解析器。
|
||||||
|
*
|
||||||
|
* @param properties 工作区配置
|
||||||
|
*/
|
||||||
|
public AgentWorkspaceResolver(AgentWorkspaceProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化并校验工作区根目录。
|
||||||
|
*/
|
||||||
|
@PostConstruct
|
||||||
|
public void initialize() {
|
||||||
|
try {
|
||||||
|
Path configured = Path.of(properties.getRoot()).toAbsolutePath().normalize();
|
||||||
|
Files.createDirectories(configured);
|
||||||
|
if (Files.isSymbolicLink(configured)) {
|
||||||
|
throw new IllegalStateException("Agent 工作区根目录不能是符号链接");
|
||||||
|
}
|
||||||
|
realRoot = configured.toRealPath(LinkOption.NOFOLLOW_LINKS);
|
||||||
|
activityRoot = realRoot.resolve(ACTIVITY_DIRECTORY);
|
||||||
|
if (Files.exists(activityRoot, LinkOption.NOFOLLOW_LINKS) && Files.isSymbolicLink(activityRoot)) {
|
||||||
|
throw new IllegalStateException("Agent 工作区活动目录不能是符号链接");
|
||||||
|
}
|
||||||
|
Files.createDirectories(activityRoot);
|
||||||
|
if (Files.isSymbolicLink(activityRoot)) {
|
||||||
|
throw new IllegalStateException("Agent 工作区活动目录不能是符号链接");
|
||||||
|
}
|
||||||
|
} catch (IOException error) {
|
||||||
|
throw new IllegalStateException("初始化 Agent 工作区根目录失败", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析并创建一个隔离的会话工作区。
|
||||||
|
*
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param agentId Agent ID
|
||||||
|
* @param runtimeSessionId Runtime 会话 ID
|
||||||
|
* @return 经过真实路径校验的绝对工作区
|
||||||
|
*/
|
||||||
|
public Path resolve(BigInteger tenantId, BigInteger agentId, String runtimeSessionId) {
|
||||||
|
if (tenantId == null || tenantId.signum() <= 0 || agentId == null || agentId.signum() <= 0) {
|
||||||
|
throw new BusinessException("Agent 工作区租户和 Agent 标识不完整");
|
||||||
|
}
|
||||||
|
if (runtimeSessionId == null || !SESSION_ID.matcher(runtimeSessionId).matches()) {
|
||||||
|
throw new BusinessException("Agent 工作区会话标识不合法");
|
||||||
|
}
|
||||||
|
Path target = realRoot.resolve(tenantId.toString()).resolve(agentId.toString())
|
||||||
|
.resolve(runtimeSessionId).normalize();
|
||||||
|
if (!target.startsWith(realRoot)) {
|
||||||
|
throw new BusinessException("Agent 工作区路径越界");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
createSafeDirectories(target);
|
||||||
|
Path realTarget = target.toRealPath(LinkOption.NOFOLLOW_LINKS);
|
||||||
|
if (!realTarget.startsWith(realRoot) || Files.isSymbolicLink(realTarget)) {
|
||||||
|
throw new BusinessException("Agent 工作区路径越界");
|
||||||
|
}
|
||||||
|
touch(realTarget);
|
||||||
|
return realTarget;
|
||||||
|
} catch (IOException error) {
|
||||||
|
throw new BusinessException(500, 500, "创建 Agent 会话工作区失败", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全解析工作区内一个已经存在的普通文件。
|
||||||
|
*
|
||||||
|
* @param workspace 会话工作区
|
||||||
|
* @param relativePath 模型提交的相对路径
|
||||||
|
* @return 文件真实绝对路径
|
||||||
|
*/
|
||||||
|
public Path resolveExistingFile(Path workspace, String relativePath) {
|
||||||
|
if (workspace == null || relativePath == null || relativePath.isBlank()
|
||||||
|
|| relativePath.indexOf('\0') >= 0 || relativePath.startsWith("~")) {
|
||||||
|
throw new BusinessException("WORKSPACE_PATH_FORBIDDEN: 文件路径必须是工作区相对路径");
|
||||||
|
}
|
||||||
|
Path raw;
|
||||||
|
try {
|
||||||
|
raw = Path.of(relativePath);
|
||||||
|
} catch (InvalidPathException error) {
|
||||||
|
throw new BusinessException("WORKSPACE_PATH_FORBIDDEN: 文件路径格式不合法");
|
||||||
|
}
|
||||||
|
if (raw.isAbsolute()) {
|
||||||
|
throw new BusinessException("WORKSPACE_PATH_FORBIDDEN: 禁止绝对路径");
|
||||||
|
}
|
||||||
|
for (Path segment : raw) {
|
||||||
|
if ("..".equals(segment.toString())) {
|
||||||
|
throw new BusinessException("WORKSPACE_PATH_FORBIDDEN: 禁止路径回退");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Path realWorkspace = workspace.toRealPath(LinkOption.NOFOLLOW_LINKS);
|
||||||
|
Path candidate = realWorkspace.resolve(raw).normalize();
|
||||||
|
if (!candidate.startsWith(realWorkspace) || !Files.exists(candidate, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
|
throw new BusinessException("WORKSPACE_FILE_NOT_FOUND: 工作区文件不存在");
|
||||||
|
}
|
||||||
|
rejectSymlinkChain(realWorkspace, candidate);
|
||||||
|
Path realFile = candidate.toRealPath(LinkOption.NOFOLLOW_LINKS);
|
||||||
|
if (!realFile.startsWith(realWorkspace) || !Files.isRegularFile(realFile, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
|
throw new BusinessException("WORKSPACE_FILE_TYPE_UNSUPPORTED: 只允许普通文件");
|
||||||
|
}
|
||||||
|
rejectUnixHardlink(realFile);
|
||||||
|
touch(realWorkspace);
|
||||||
|
return realFile;
|
||||||
|
} catch (BusinessException error) {
|
||||||
|
throw error;
|
||||||
|
} catch (IOException error) {
|
||||||
|
throw new BusinessException(500, 500, "解析工作区文件失败", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新工作区的可信活动时间。
|
||||||
|
*
|
||||||
|
* @param workspace 会话工作区
|
||||||
|
*/
|
||||||
|
public void touch(Path workspace) {
|
||||||
|
try {
|
||||||
|
Files.writeString(activityFile(workspace), Instant.now().toString(),
|
||||||
|
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
|
||||||
|
} catch (IOException error) {
|
||||||
|
throw new BusinessException(500, 500, "更新 Agent 工作区活动状态失败", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 工作区真实根目录 */
|
||||||
|
public Path getRealRoot() { return realRoot; }
|
||||||
|
/** @return 活动标记目录 */
|
||||||
|
public Path getActivityRoot() { return activityRoot; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得指定工作区的活动标记文件。
|
||||||
|
*
|
||||||
|
* @param workspace 会话工作区
|
||||||
|
* @return 位于模型工作区之外的标记文件
|
||||||
|
*/
|
||||||
|
public Path activityFile(Path workspace) {
|
||||||
|
Path relative = realRoot.relativize(workspace.toAbsolutePath().normalize());
|
||||||
|
String digest = sha256(relative.toString());
|
||||||
|
return activityRoot.resolve(digest + ".activity");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createSafeDirectories(Path target) throws IOException {
|
||||||
|
Path current = realRoot;
|
||||||
|
for (Path segment : realRoot.relativize(target)) {
|
||||||
|
current = current.resolve(segment);
|
||||||
|
if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
|
if (Files.isSymbolicLink(current) || !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
|
throw new BusinessException("Agent 工作区路径链不是安全目录");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Files.createDirectory(current);
|
||||||
|
} catch (java.nio.file.FileAlreadyExistsException ignored) {
|
||||||
|
if (Files.isSymbolicLink(current) || !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
|
throw new BusinessException("Agent 工作区路径链不是安全目录");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rejectSymlinkChain(Path root, Path target) {
|
||||||
|
Path current = root;
|
||||||
|
for (Path segment : root.relativize(target)) {
|
||||||
|
current = current.resolve(segment);
|
||||||
|
if (Files.isSymbolicLink(current)) {
|
||||||
|
throw new BusinessException("WORKSPACE_PATH_FORBIDDEN: 路径链包含符号链接");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rejectUnixHardlink(Path file) throws IOException {
|
||||||
|
try {
|
||||||
|
Object value = Files.getAttribute(file, "unix:nlink", LinkOption.NOFOLLOW_LINKS);
|
||||||
|
if (value instanceof Number number && number.longValue() > 1L) {
|
||||||
|
throw new BusinessException("WORKSPACE_FILE_TYPE_UNSUPPORTED: 禁止发布硬链接文件");
|
||||||
|
}
|
||||||
|
} catch (UnsupportedOperationException ignored) {
|
||||||
|
// 非 Unix 文件系统没有 nlink 属性,仍保留普通文件与符号链接校验。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sha256(String value) {
|
||||||
|
try {
|
||||||
|
return HexFormat.of().formatHex(
|
||||||
|
java.security.MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||||
|
} catch (java.security.NoSuchAlgorithmException error) {
|
||||||
|
throw new IllegalStateException("当前 JVM 不支持 SHA-256", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ import tech.easyflow.system.enums.CategoryResourceType;
|
|||||||
import tech.easyflow.system.enums.ResourceAction;
|
import tech.easyflow.system.enums.ResourceAction;
|
||||||
import tech.easyflow.system.service.CategoryPermissionService;
|
import tech.easyflow.system.service.CategoryPermissionService;
|
||||||
import tech.easyflow.system.service.ResourceAccessService;
|
import tech.easyflow.system.service.ResourceAccessService;
|
||||||
|
import tech.easyflow.skill.entity.Skill;
|
||||||
|
import tech.easyflow.skill.service.SkillService;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -46,6 +48,7 @@ public class AgentDependencyAccessService {
|
|||||||
private final AgentCategoryService agentCategoryService;
|
private final AgentCategoryService agentCategoryService;
|
||||||
private final CategoryPermissionService categoryPermissionService;
|
private final CategoryPermissionService categoryPermissionService;
|
||||||
private final ResourceAccessService resourceAccessService;
|
private final ResourceAccessService resourceAccessService;
|
||||||
|
private final SkillService skillService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 Agent 依赖资源校验服务。
|
* 创建 Agent 依赖资源校验服务。
|
||||||
@@ -60,6 +63,7 @@ public class AgentDependencyAccessService {
|
|||||||
* @param agentCategoryService Agent 分类服务
|
* @param agentCategoryService Agent 分类服务
|
||||||
* @param categoryPermissionService 分类权限服务
|
* @param categoryPermissionService 分类权限服务
|
||||||
* @param resourceAccessService 资源权限服务
|
* @param resourceAccessService 资源权限服务
|
||||||
|
* @param skillService Skill 服务
|
||||||
*/
|
*/
|
||||||
public AgentDependencyAccessService(ModelService modelService,
|
public AgentDependencyAccessService(ModelService modelService,
|
||||||
WorkflowService workflowService,
|
WorkflowService workflowService,
|
||||||
@@ -70,7 +74,8 @@ public class AgentDependencyAccessService {
|
|||||||
DocumentCollectionService documentCollectionService,
|
DocumentCollectionService documentCollectionService,
|
||||||
AgentCategoryService agentCategoryService,
|
AgentCategoryService agentCategoryService,
|
||||||
CategoryPermissionService categoryPermissionService,
|
CategoryPermissionService categoryPermissionService,
|
||||||
ResourceAccessService resourceAccessService) {
|
ResourceAccessService resourceAccessService,
|
||||||
|
SkillService skillService) {
|
||||||
this.modelService = modelService;
|
this.modelService = modelService;
|
||||||
this.workflowService = workflowService;
|
this.workflowService = workflowService;
|
||||||
this.pluginItemService = pluginItemService;
|
this.pluginItemService = pluginItemService;
|
||||||
@@ -81,6 +86,7 @@ public class AgentDependencyAccessService {
|
|||||||
this.agentCategoryService = agentCategoryService;
|
this.agentCategoryService = agentCategoryService;
|
||||||
this.categoryPermissionService = categoryPermissionService;
|
this.categoryPermissionService = categoryPermissionService;
|
||||||
this.resourceAccessService = resourceAccessService;
|
this.resourceAccessService = resourceAccessService;
|
||||||
|
this.skillService = skillService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,6 +141,17 @@ public class AgentDependencyAccessService {
|
|||||||
* @return 插件工具
|
* @return 插件工具
|
||||||
*/
|
*/
|
||||||
public PluginItem requirePluginItem(Agent agent, BigInteger pluginItemId) {
|
public PluginItem requirePluginItem(Agent agent, BigInteger pluginItemId) {
|
||||||
|
return requirePluginResource(agent, pluginItemId).pluginItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验并锁定插件工具及其父插件,返回同一事务中的完整调用资源。
|
||||||
|
*
|
||||||
|
* @param agent Agent
|
||||||
|
* @param pluginItemId 插件工具 ID
|
||||||
|
* @return 插件项与父插件
|
||||||
|
*/
|
||||||
|
public PluginResource requirePluginResource(Agent agent, BigInteger pluginItemId) {
|
||||||
PluginItem current = pluginItemService.getById(pluginItemId);
|
PluginItem current = pluginItemService.getById(pluginItemId);
|
||||||
if (current == null || current.getPluginId() == null) {
|
if (current == null || current.getPluginId() == null) {
|
||||||
throw new BusinessException("绑定插件不存在");
|
throw new BusinessException("绑定插件不存在");
|
||||||
@@ -153,7 +170,7 @@ public class AgentDependencyAccessService {
|
|||||||
}
|
}
|
||||||
assertSameTenant(agent, plugin.getTenantId(), "无权限绑定该插件");
|
assertSameTenant(agent, plugin.getTenantId(), "无权限绑定该插件");
|
||||||
pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), plugin.getId(), "无权限绑定该插件");
|
pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), plugin.getId(), "无权限绑定该插件");
|
||||||
return pluginItem;
|
return new PluginResource(pluginItem, plugin);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,6 +191,30 @@ public class AgentDependencyAccessService {
|
|||||||
return mcp;
|
return mcp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验并锁定 Agent 可使用的已发布 Skill。
|
||||||
|
*
|
||||||
|
* <p>Skill 发布阶段已经完成底层 Tool 权限与 MCP 清单检测。Agent 保存阶段只消费冻结快照,
|
||||||
|
* 避免在数据库事务中执行外部 MCP I/O;快照内容及组合 hash 由运行投影器继续校验。</p>
|
||||||
|
*
|
||||||
|
* @param agent Agent
|
||||||
|
* @param skillId Skill ID
|
||||||
|
* @return 已发布 Skill
|
||||||
|
*/
|
||||||
|
public Skill requireSkill(Agent agent, BigInteger skillId) {
|
||||||
|
Skill skill = skillService.getOne(QueryWrapper.create()
|
||||||
|
.eq(Skill::getId, skillId)
|
||||||
|
.forUpdate());
|
||||||
|
if (skill == null || PublishStatus.from(skill.getPublishStatus()) != PublishStatus.PUBLISHED
|
||||||
|
|| skill.getPublishedSnapshotJson() == null || skill.getPublishedSnapshotJson().isEmpty()) {
|
||||||
|
throw new BusinessException("绑定 Skill 不存在或未发布");
|
||||||
|
}
|
||||||
|
assertSameTenant(agent, skill.getTenantId(), "无权限绑定该 Skill");
|
||||||
|
resourceAccessService.assertAccess(
|
||||||
|
CategoryResourceType.SKILL, skill, ResourceAction.USE, "无权限绑定该 Skill");
|
||||||
|
return skill;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验并锁定知识库。
|
* 校验并锁定知识库。
|
||||||
*
|
*
|
||||||
@@ -233,4 +274,13 @@ public class AgentDependencyAccessService {
|
|||||||
throw new BusinessException(message);
|
throw new BusinessException(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件运行依赖聚合。
|
||||||
|
*
|
||||||
|
* @param pluginItem 插件工具
|
||||||
|
* @param plugin 父插件调用配置
|
||||||
|
*/
|
||||||
|
public record PluginResource(PluginItem pluginItem, Plugin plugin) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import tech.easyflow.agent.entity.Agent;
|
import tech.easyflow.agent.entity.Agent;
|
||||||
import tech.easyflow.agent.security.AgentVisibilityQueryHelper;
|
import tech.easyflow.agent.security.AgentVisibilityQueryHelper;
|
||||||
import tech.easyflow.agent.vo.AgentOptionView;
|
import tech.easyflow.agent.vo.AgentOptionView;
|
||||||
@@ -28,8 +29,12 @@ import tech.easyflow.common.web.exceptions.BusinessException;
|
|||||||
import tech.easyflow.system.enums.CategoryResourceType;
|
import tech.easyflow.system.enums.CategoryResourceType;
|
||||||
import tech.easyflow.system.enums.ResourceAction;
|
import tech.easyflow.system.enums.ResourceAction;
|
||||||
import tech.easyflow.system.service.ResourceAccessService;
|
import tech.easyflow.system.service.ResourceAccessService;
|
||||||
|
import tech.easyflow.system.service.CategoryPermissionService;
|
||||||
|
import tech.easyflow.skill.entity.Skill;
|
||||||
|
import tech.easyflow.skill.service.SkillService;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -54,9 +59,11 @@ public class AgentOptionQueryService {
|
|||||||
private final PluginItemService pluginItemService;
|
private final PluginItemService pluginItemService;
|
||||||
private final PluginVisibilityService pluginVisibilityService;
|
private final PluginVisibilityService pluginVisibilityService;
|
||||||
private final McpService mcpService;
|
private final McpService mcpService;
|
||||||
|
private final SkillService skillService;
|
||||||
private final AgentVisibilityQueryHelper agentVisibilityQueryHelper;
|
private final AgentVisibilityQueryHelper agentVisibilityQueryHelper;
|
||||||
private final ResourceAccessService resourceAccessService;
|
private final ResourceAccessService resourceAccessService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private CategoryPermissionService categoryPermissionService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 Agent 安全选项查询服务。
|
* 创建 Agent 安全选项查询服务。
|
||||||
@@ -69,6 +76,7 @@ public class AgentOptionQueryService {
|
|||||||
* @param pluginItemService 插件工具服务
|
* @param pluginItemService 插件工具服务
|
||||||
* @param pluginVisibilityService 插件可见性服务
|
* @param pluginVisibilityService 插件可见性服务
|
||||||
* @param mcpService MCP 服务
|
* @param mcpService MCP 服务
|
||||||
|
* @param skillService Skill 服务
|
||||||
* @param agentVisibilityQueryHelper Agent 可见性查询助手
|
* @param agentVisibilityQueryHelper Agent 可见性查询助手
|
||||||
* @param resourceAccessService 资源访问服务
|
* @param resourceAccessService 资源访问服务
|
||||||
* @param objectMapper JSON 映射器
|
* @param objectMapper JSON 映射器
|
||||||
@@ -81,6 +89,7 @@ public class AgentOptionQueryService {
|
|||||||
PluginItemService pluginItemService,
|
PluginItemService pluginItemService,
|
||||||
PluginVisibilityService pluginVisibilityService,
|
PluginVisibilityService pluginVisibilityService,
|
||||||
McpService mcpService,
|
McpService mcpService,
|
||||||
|
SkillService skillService,
|
||||||
AgentVisibilityQueryHelper agentVisibilityQueryHelper,
|
AgentVisibilityQueryHelper agentVisibilityQueryHelper,
|
||||||
ResourceAccessService resourceAccessService,
|
ResourceAccessService resourceAccessService,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
@@ -92,6 +101,7 @@ public class AgentOptionQueryService {
|
|||||||
this.pluginItemService = pluginItemService;
|
this.pluginItemService = pluginItemService;
|
||||||
this.pluginVisibilityService = pluginVisibilityService;
|
this.pluginVisibilityService = pluginVisibilityService;
|
||||||
this.mcpService = mcpService;
|
this.mcpService = mcpService;
|
||||||
|
this.skillService = skillService;
|
||||||
this.agentVisibilityQueryHelper = agentVisibilityQueryHelper;
|
this.agentVisibilityQueryHelper = agentVisibilityQueryHelper;
|
||||||
this.resourceAccessService = resourceAccessService;
|
this.resourceAccessService = resourceAccessService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
@@ -133,12 +143,113 @@ public class AgentOptionQueryService {
|
|||||||
return new AgentResourceOptionsView(
|
return new AgentResourceOptionsView(
|
||||||
listModelOptions(account),
|
listModelOptions(account),
|
||||||
listKnowledgeOptions(account),
|
listKnowledgeOptions(account),
|
||||||
|
listSkillOptions(account),
|
||||||
listWorkflowOptions(account),
|
listWorkflowOptions(account),
|
||||||
listPluginToolOptions(account),
|
listPluginToolOptions(account),
|
||||||
listMcpOptions(account)
|
listMcpOptions(account),
|
||||||
|
new AgentResourceOptionsView.Capabilities(
|
||||||
|
categoryPermissionService != null && categoryPermissionService.isSuperAdmin(account))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注入平台超级管理员判定服务。
|
||||||
|
*
|
||||||
|
* @param categoryPermissionService 分类权限服务
|
||||||
|
*/
|
||||||
|
@Autowired
|
||||||
|
public void setCategoryPermissionService(CategoryPermissionService categoryPermissionService) {
|
||||||
|
this.categoryPermissionService = categoryPermissionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前账号可使用的已发布 Skill 安全选项。
|
||||||
|
*
|
||||||
|
* @param account 当前登录账号
|
||||||
|
* @return Skill 选项
|
||||||
|
*/
|
||||||
|
private List<AgentResourceOptionsView.SkillOption> listSkillOptions(LoginAccount account) {
|
||||||
|
return skillService.list(QueryWrapper.create()
|
||||||
|
.eq(Skill::getTenantId, account.getTenantId())
|
||||||
|
.eq(Skill::getPublishStatus, PublishStatus.PUBLISHED.getCode())
|
||||||
|
.orderBy(Skill::getModified, false)
|
||||||
|
.orderBy(Skill::getDisplayName, true))
|
||||||
|
.stream()
|
||||||
|
.filter(skill -> resourceAccessService.canAccess(
|
||||||
|
CategoryResourceType.SKILL, skill, ResourceAction.USE))
|
||||||
|
.map(this::toSkillOption)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Skill 发布数据投影为不含正文、资源内容和连接配置的选择项。
|
||||||
|
*
|
||||||
|
* @param skill Skill
|
||||||
|
* @return 安全选择项
|
||||||
|
*/
|
||||||
|
private AgentResourceOptionsView.SkillOption toSkillOption(Skill skill) {
|
||||||
|
Map<String, Object> publishedSnapshot = skill.getPublishedSnapshotJson();
|
||||||
|
List<?> resources = listValue(publishedSnapshot, "resources");
|
||||||
|
int textCount = 0;
|
||||||
|
int binaryCount = 0;
|
||||||
|
long textBytes = utf8Length(textValue(publishedSnapshot, "skillContent"));
|
||||||
|
for (Object raw : resources) {
|
||||||
|
if (!(raw instanceof Map<?, ?> resource)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Boolean.TRUE.equals(resource.get("text"))) {
|
||||||
|
textCount++;
|
||||||
|
textBytes = Math.addExact(textBytes,
|
||||||
|
utf8Length(resource.get("textContent") == null
|
||||||
|
? null : String.valueOf(resource.get("textContent"))));
|
||||||
|
} else {
|
||||||
|
binaryCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int toolCount = 0;
|
||||||
|
for (Object raw : listValue(skill.getPublishedToolBindingsJson(), "bindings")) {
|
||||||
|
if (raw instanceof Map<?, ?> binding && binding.get("toolCount") instanceof Number number) {
|
||||||
|
toolCount += Math.max(0, number.intValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new AgentResourceOptionsView.SkillOption(
|
||||||
|
skill.getId(),
|
||||||
|
publishedText(publishedSnapshot, "displayName", skill.getDisplayName()),
|
||||||
|
publishedText(publishedSnapshot, "description", skill.getDescription()),
|
||||||
|
publishedText(publishedSnapshot, "visibilityScope", skill.getVisibilityScope()),
|
||||||
|
skill.getSnapshotHash(), toolCount, textBytes, textCount, binaryCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取发布快照中的展示字段,旧快照缺少字段时兼容当前行。
|
||||||
|
*
|
||||||
|
* @param snapshot 发布内容快照
|
||||||
|
* @param key 字段名
|
||||||
|
* @param legacyFallback 历史快照回退值
|
||||||
|
* @return 冻结展示值
|
||||||
|
*/
|
||||||
|
private String publishedText(Map<String, Object> snapshot, String key, String legacyFallback) {
|
||||||
|
if (snapshot == null || !snapshot.containsKey(key)) {
|
||||||
|
return legacyFallback;
|
||||||
|
}
|
||||||
|
Object value = snapshot.get(key);
|
||||||
|
return value == null ? null : String.valueOf(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String textValue(Map<String, Object> source, String key) {
|
||||||
|
Object value = source == null ? null : source.get(key);
|
||||||
|
return value == null ? null : String.valueOf(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long utf8Length(String value) {
|
||||||
|
return value == null ? 0L : value.getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<?> listValue(Map<String, Object> source, String key) {
|
||||||
|
Object value = source == null ? null : source.get(key);
|
||||||
|
return value instanceof List<?> list ? list : List.of();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询当前账号可用于 Agent 会话的知识库安全选项。
|
* 查询当前账号可用于 Agent 会话的知识库安全选项。
|
||||||
*
|
*
|
||||||
@@ -228,7 +339,6 @@ public class AgentOptionQueryService {
|
|||||||
return workflowService.list(QueryWrapper.create()
|
return workflowService.list(QueryWrapper.create()
|
||||||
.eq(Workflow::getTenantId, account.getTenantId())
|
.eq(Workflow::getTenantId, account.getTenantId())
|
||||||
.eq(Workflow::getPublishStatus, PublishStatus.PUBLISHED.getCode())
|
.eq(Workflow::getPublishStatus, PublishStatus.PUBLISHED.getCode())
|
||||||
.eq(Workflow::getStatus, 1)
|
|
||||||
.orderBy(Workflow::getModified, false))
|
.orderBy(Workflow::getModified, false))
|
||||||
.stream()
|
.stream()
|
||||||
.filter(item -> resourceAccessService.canAccess(
|
.filter(item -> resourceAccessService.canAccess(
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ package tech.easyflow.agent.service;
|
|||||||
|
|
||||||
import com.mybatisflex.core.service.IService;
|
import com.mybatisflex.core.service.IService;
|
||||||
import tech.easyflow.agent.entity.Agent;
|
import tech.easyflow.agent.entity.Agent;
|
||||||
|
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||||
|
import tech.easyflow.agent.entity.AgentSkillBinding;
|
||||||
|
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,6 +39,26 @@ public interface AgentService extends IService<Agent> {
|
|||||||
*/
|
*/
|
||||||
Agent updateDraft(Agent agent);
|
Agent updateDraft(Agent agent);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在一个事务中保存 Agent 草稿及发生变化的资源绑定。
|
||||||
|
*
|
||||||
|
* @param agent Agent 草稿
|
||||||
|
* @param toolBindings 工具绑定
|
||||||
|
* @param replaceToolBindings 是否替换工具绑定
|
||||||
|
* @param knowledgeBindings 知识库绑定
|
||||||
|
* @param replaceKnowledgeBindings 是否替换知识库绑定
|
||||||
|
* @param skillBindings Skill 绑定
|
||||||
|
* @param replaceSkillBindings 是否替换 Skill 绑定
|
||||||
|
* @return 保存后的 Agent 与本次替换的绑定
|
||||||
|
*/
|
||||||
|
Agent saveDraftGraph(Agent agent,
|
||||||
|
List<AgentToolBinding> toolBindings,
|
||||||
|
boolean replaceToolBindings,
|
||||||
|
List<AgentKnowledgeBinding> knowledgeBindings,
|
||||||
|
boolean replaceKnowledgeBindings,
|
||||||
|
List<AgentSkillBinding> skillBindings,
|
||||||
|
boolean replaceSkillBindings);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新 Agent 的可见范围。
|
* 更新 Agent 的可见范围。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package tech.easyflow.agent.service;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.service.IService;
|
||||||
|
import tech.easyflow.agent.entity.AgentSkillBinding;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent Skill 绑定服务。
|
||||||
|
*/
|
||||||
|
public interface AgentSkillBindingService extends IService<AgentSkillBinding> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子替换 Agent 的全部 Skill 绑定。
|
||||||
|
*
|
||||||
|
* @param agentId Agent ID
|
||||||
|
* @param bindings Skill 引用列表
|
||||||
|
* @return 规范化后的脱敏绑定摘要
|
||||||
|
*/
|
||||||
|
List<AgentSkillBinding> replaceBindings(BigInteger agentId, List<AgentSkillBinding> bindings);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询 Agent 的 Skill 草稿绑定。
|
||||||
|
*
|
||||||
|
* @param agentId Agent ID
|
||||||
|
* @return 稳定排序的绑定
|
||||||
|
*/
|
||||||
|
List<AgentSkillBinding> listBindings(BigInteger agentId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询 Agent 的 Skill 脱敏绑定摘要。
|
||||||
|
*
|
||||||
|
* @param agentId Agent ID
|
||||||
|
* @return 稳定排序的绑定摘要
|
||||||
|
*/
|
||||||
|
List<AgentSkillBinding> listSummaries(BigInteger agentId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package tech.easyflow.agent.service.impl;
|
||||||
|
|
||||||
|
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||||
|
import tech.easyflow.agent.entity.AgentSkillBinding;
|
||||||
|
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||||
|
import tech.easyflow.agent.enums.AgentToolType;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比较 Agent 绑定的可持久化业务字段,忽略 ID、审计字段与展示摘要。
|
||||||
|
*/
|
||||||
|
final class AgentBindingSemanticComparator {
|
||||||
|
|
||||||
|
private static final String DEFAULT_RETRIEVAL_MODE = "HYBRID";
|
||||||
|
|
||||||
|
private AgentBindingSemanticComparator() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断工具绑定整组替换是否会产生业务变化。
|
||||||
|
*
|
||||||
|
* @param current 当前持久化绑定
|
||||||
|
* @param requested 客户端请求绑定
|
||||||
|
* @return 业务字段完全一致时返回 {@code true}
|
||||||
|
*/
|
||||||
|
static boolean sameTools(List<AgentToolBinding> current, List<AgentToolBinding> requested) {
|
||||||
|
List<AgentToolBinding> left = safeList(current);
|
||||||
|
List<AgentToolBinding> right = safeList(requested);
|
||||||
|
if (left.size() != right.size()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int index = 0; index < left.size(); index++) {
|
||||||
|
AgentToolBinding persisted = left.get(index);
|
||||||
|
AgentToolBinding incoming = right.get(index);
|
||||||
|
if (persisted == null || incoming == null
|
||||||
|
|| !Objects.equals(normalizeToolType(persisted.getToolType()), normalizeToolType(incoming.getToolType()))
|
||||||
|
|| !Objects.equals(persisted.getTargetId(), incoming.getTargetId())
|
||||||
|
|| !Objects.equals(text(persisted.getToolName()), text(incoming.getToolName()))
|
||||||
|
|| !Objects.equals(enabled(persisted.getEnabled()), enabled(incoming.getEnabled()))
|
||||||
|
|| !Objects.equals(Boolean.TRUE.equals(persisted.getHitlEnabled()),
|
||||||
|
Boolean.TRUE.equals(incoming.getHitlEnabled()))
|
||||||
|
|| !Objects.equals(map(persisted.getHitlConfigJson()), map(incoming.getHitlConfigJson()))
|
||||||
|
|| !Objects.equals(map(persisted.getOptionsJson()), map(incoming.getOptionsJson()))
|
||||||
|
|| !Objects.equals(persisted.getSortNo(), sortNo(incoming.getSortNo(), index))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断知识库绑定整组替换是否会产生业务变化。
|
||||||
|
*
|
||||||
|
* @param current 当前持久化绑定
|
||||||
|
* @param requested 客户端请求绑定
|
||||||
|
* @return 业务字段完全一致时返回 {@code true}
|
||||||
|
*/
|
||||||
|
static boolean sameKnowledges(List<AgentKnowledgeBinding> current,
|
||||||
|
List<AgentKnowledgeBinding> requested) {
|
||||||
|
List<AgentKnowledgeBinding> left = safeList(current);
|
||||||
|
List<AgentKnowledgeBinding> right = safeList(requested);
|
||||||
|
if (left.size() != right.size()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int index = 0; index < left.size(); index++) {
|
||||||
|
AgentKnowledgeBinding persisted = left.get(index);
|
||||||
|
AgentKnowledgeBinding incoming = right.get(index);
|
||||||
|
if (persisted == null || incoming == null
|
||||||
|
|| !Objects.equals(persisted.getKnowledgeId(), incoming.getKnowledgeId())
|
||||||
|
|| !Objects.equals(retrievalMode(persisted.getRetrievalMode()),
|
||||||
|
retrievalMode(incoming.getRetrievalMode()))
|
||||||
|
|| !Objects.equals(enabled(persisted.getEnabled()), enabled(incoming.getEnabled()))
|
||||||
|
|| !Objects.equals(map(persisted.getOptionsJson()), map(incoming.getOptionsJson()))
|
||||||
|
|| !Objects.equals(persisted.getSortNo(), sortNo(incoming.getSortNo(), index))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断 Skill 绑定顺序是否发生变化。
|
||||||
|
*
|
||||||
|
* @param current 当前持久化绑定
|
||||||
|
* @param requested 客户端请求绑定
|
||||||
|
* @return Skill ID 与稳定顺序完全一致时返回 {@code true}
|
||||||
|
*/
|
||||||
|
static boolean sameSkills(List<AgentSkillBinding> current, List<AgentSkillBinding> requested) {
|
||||||
|
List<AgentSkillBinding> left = safeList(current);
|
||||||
|
List<AgentSkillBinding> right = safeList(requested);
|
||||||
|
if (left.size() != right.size()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int index = 0; index < left.size(); index++) {
|
||||||
|
AgentSkillBinding persisted = left.get(index);
|
||||||
|
AgentSkillBinding incoming = right.get(index);
|
||||||
|
if (persisted == null || incoming == null
|
||||||
|
|| !Objects.equals(persisted.getSkillId(), incoming.getSkillId())
|
||||||
|
|| !Objects.equals(persisted.getSortNo(), index)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeToolType(String value) {
|
||||||
|
try {
|
||||||
|
return AgentToolType.from(value).name();
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String retrievalMode(String value) {
|
||||||
|
return value == null || value.isBlank()
|
||||||
|
? DEFAULT_RETRIEVAL_MODE : value.trim().toUpperCase(java.util.Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(String value) {
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Boolean enabled(Boolean value) {
|
||||||
|
return value == null || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer sortNo(Integer value, int index) {
|
||||||
|
return value == null ? index : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> map(Map<String, Object> value) {
|
||||||
|
return value == null ? Collections.emptyMap() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> List<T> safeList(List<T> value) {
|
||||||
|
return value == null ? Collections.emptyList() : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,10 @@ public class AgentKnowledgeBindingServiceImpl extends ServiceImpl<AgentKnowledge
|
|||||||
Agent agent = requireAgentForUpdate(agentId);
|
Agent agent = requireAgentForUpdate(agentId);
|
||||||
resourceAccessService.assertAccess(
|
resourceAccessService.assertAccess(
|
||||||
CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent");
|
CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent");
|
||||||
|
List<AgentKnowledgeBinding> current = listAll(agentId);
|
||||||
|
if (AgentBindingSemanticComparator.sameKnowledges(current, bindings)) {
|
||||||
|
return enabledBindings(current);
|
||||||
|
}
|
||||||
validateBindings(agent, bindings);
|
validateBindings(agent, bindings);
|
||||||
remove(QueryWrapper.create().where("agent_id = ?", agentId));
|
remove(QueryWrapper.create().where("agent_id = ?", agentId));
|
||||||
if (bindings == null || bindings.isEmpty()) {
|
if (bindings == null || bindings.isEmpty()) {
|
||||||
@@ -66,7 +70,7 @@ public class AgentKnowledgeBindingServiceImpl extends ServiceImpl<AgentKnowledge
|
|||||||
applyBindingDefaults(agent, bindings.get(i), i);
|
applyBindingDefaults(agent, bindings.get(i), i);
|
||||||
}
|
}
|
||||||
saveBatch(bindings);
|
saveBatch(bindings);
|
||||||
return listEnabled(agentId);
|
return enabledBindings(bindings);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +85,30 @@ public class AgentKnowledgeBindingServiceImpl extends ServiceImpl<AgentKnowledge
|
|||||||
.orderBy("sort_no asc, id asc"));
|
.orderBy("sort_no asc, id asc"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询 Agent 的全部知识库绑定,用于整组语义比较。
|
||||||
|
*
|
||||||
|
* @param agentId Agent ID
|
||||||
|
* @return 稳定排序的全部绑定
|
||||||
|
*/
|
||||||
|
private List<AgentKnowledgeBinding> listAll(BigInteger agentId) {
|
||||||
|
return list(QueryWrapper.create()
|
||||||
|
.where("agent_id = ?", agentId)
|
||||||
|
.orderBy("sort_no asc, id asc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从已加载或已写入的绑定中筛选启用项,避免替换后再次查询。
|
||||||
|
*
|
||||||
|
* @param bindings 知识库绑定
|
||||||
|
* @return 启用绑定
|
||||||
|
*/
|
||||||
|
private List<AgentKnowledgeBinding> enabledBindings(List<AgentKnowledgeBinding> bindings) {
|
||||||
|
return bindings.stream()
|
||||||
|
.filter(binding -> binding != null && binding.getEnabled() != Boolean.FALSE)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 锁定并加载待修改的 Agent。
|
* 锁定并加载待修改的 Agent。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -255,7 +255,29 @@ public class AgentResourceBindingProviderImpl implements AgentResourceBindingPro
|
|||||||
AgentToolType toolType,
|
AgentToolType toolType,
|
||||||
BigInteger resourceId) {
|
BigInteger resourceId) {
|
||||||
return snapshotListContains(snapshot, "toolBindings", resourceId, toolType.name())
|
return snapshotListContains(snapshot, "toolBindings", resourceId, toolType.name())
|
||||||
|| snapshotListContains(snapshot, "toolSummaries", resourceId, toolType.name());
|
|| snapshotListContains(snapshot, "toolSummaries", resourceId, toolType.name())
|
||||||
|
|| nestedSkillBindingsContain(snapshot, toolType, resourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean nestedSkillBindingsContain(Map<String, Object> snapshot,
|
||||||
|
AgentToolType toolType,
|
||||||
|
BigInteger resourceId) {
|
||||||
|
Object rawBindings = snapshot == null ? null : snapshot.get("skillBindings");
|
||||||
|
if (!(rawBindings instanceof List<?> bindings)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (Object raw : bindings) {
|
||||||
|
if (!(raw instanceof Map<?, ?> binding)
|
||||||
|
|| !(binding.get("resourceSnapshot") instanceof Map<?, ?> resourceSnapshot)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Object rawTools = resourceSnapshot.get("toolBindings");
|
||||||
|
if (rawTools instanceof List<?> tools
|
||||||
|
&& tools.stream().anyMatch(item -> matchesResourceBinding(item, resourceId, toolType.name()))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,18 +7,26 @@ import com.mybatisflex.spring.service.impl.ServiceImpl;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import tech.easyflow.agent.config.AgentInteractionConfigSupport;
|
import tech.easyflow.agent.config.AgentInteractionConfigSupport;
|
||||||
|
import tech.easyflow.agent.config.AgentBuiltinToolsConfigResolver;
|
||||||
|
import tech.easyflow.agent.config.AgentBuiltinToolsConfig;
|
||||||
import tech.easyflow.agent.entity.Agent;
|
import tech.easyflow.agent.entity.Agent;
|
||||||
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||||
import tech.easyflow.agent.entity.AgentToolBinding;
|
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||||
|
import tech.easyflow.agent.entity.AgentSkillBinding;
|
||||||
import tech.easyflow.agent.mapper.AgentMapper;
|
import tech.easyflow.agent.mapper.AgentMapper;
|
||||||
import tech.easyflow.agent.runtime.AgentRuntimeCompiler;
|
import tech.easyflow.agent.runtime.AgentRuntimeCompiler;
|
||||||
import tech.easyflow.agent.service.AgentDependencyAccessService;
|
import tech.easyflow.agent.service.AgentDependencyAccessService;
|
||||||
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
|
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
|
||||||
import tech.easyflow.agent.service.AgentService;
|
import tech.easyflow.agent.service.AgentService;
|
||||||
import tech.easyflow.agent.service.AgentToolBindingService;
|
import tech.easyflow.agent.service.AgentToolBindingService;
|
||||||
|
import tech.easyflow.agent.service.AgentSkillBindingService;
|
||||||
|
import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeProjector;
|
||||||
import tech.easyflow.agent.support.AgentBindingLockExecutor;
|
import tech.easyflow.agent.support.AgentBindingLockExecutor;
|
||||||
import tech.easyflow.ai.entity.*;
|
import tech.easyflow.ai.entity.*;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.repository.AgentWorkflowSnapshotFactory;
|
||||||
import tech.easyflow.ai.enums.PublishStatus;
|
import tech.easyflow.ai.enums.PublishStatus;
|
||||||
|
import tech.easyflow.ai.mcp.McpConnectionSnapshotFactory;
|
||||||
|
import tech.easyflow.ai.plugin.PluginConnectionSnapshotFactory;
|
||||||
import tech.easyflow.ai.service.*;
|
import tech.easyflow.ai.service.*;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
@@ -26,7 +34,9 @@ import tech.easyflow.common.web.exceptions.BusinessException;
|
|||||||
import tech.easyflow.system.enums.CategoryResourceType;
|
import tech.easyflow.system.enums.CategoryResourceType;
|
||||||
import tech.easyflow.system.enums.ResourceAction;
|
import tech.easyflow.system.enums.ResourceAction;
|
||||||
import tech.easyflow.system.enums.VisibilityScope;
|
import tech.easyflow.system.enums.VisibilityScope;
|
||||||
|
import tech.easyflow.system.entity.SysLog;
|
||||||
import tech.easyflow.system.service.ResourceAccessService;
|
import tech.easyflow.system.service.ResourceAccessService;
|
||||||
|
import tech.easyflow.system.service.SysLogService;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
@@ -43,12 +53,15 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
|||||||
|
|
||||||
private static final TypeReference<List<AgentToolBinding>> TOOL_BINDING_LIST_TYPE = new TypeReference<>() {};
|
private static final TypeReference<List<AgentToolBinding>> TOOL_BINDING_LIST_TYPE = new TypeReference<>() {};
|
||||||
private static final TypeReference<List<AgentKnowledgeBinding>> KNOWLEDGE_BINDING_LIST_TYPE = new TypeReference<>() {};
|
private static final TypeReference<List<AgentKnowledgeBinding>> KNOWLEDGE_BINDING_LIST_TYPE = new TypeReference<>() {};
|
||||||
|
private static final TypeReference<List<AgentSkillBinding>> SKILL_BINDING_LIST_TYPE = new TypeReference<>() {};
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private AgentToolBindingService agentToolBindingService;
|
private AgentToolBindingService agentToolBindingService;
|
||||||
@Resource
|
@Resource
|
||||||
private AgentKnowledgeBindingService agentKnowledgeBindingService;
|
private AgentKnowledgeBindingService agentKnowledgeBindingService;
|
||||||
@Resource
|
@Resource
|
||||||
|
private AgentSkillBindingService agentSkillBindingService;
|
||||||
|
@Resource
|
||||||
private ModelService modelService;
|
private ModelService modelService;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowService workflowService;
|
private WorkflowService workflowService;
|
||||||
@@ -57,6 +70,10 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
|||||||
@Resource
|
@Resource
|
||||||
private McpService mcpService;
|
private McpService mcpService;
|
||||||
@Resource
|
@Resource
|
||||||
|
private McpConnectionSnapshotFactory mcpConnectionSnapshotFactory;
|
||||||
|
@Resource
|
||||||
|
private PluginConnectionSnapshotFactory pluginConnectionSnapshotFactory;
|
||||||
|
@Resource
|
||||||
private DocumentCollectionService documentCollectionService;
|
private DocumentCollectionService documentCollectionService;
|
||||||
@Resource
|
@Resource
|
||||||
private ResourceAccessService resourceAccessService;
|
private ResourceAccessService resourceAccessService;
|
||||||
@@ -68,6 +85,14 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
|||||||
private AgentBindingLockExecutor agentBindingLockExecutor;
|
private AgentBindingLockExecutor agentBindingLockExecutor;
|
||||||
@Resource
|
@Resource
|
||||||
private AgentRuntimeCompiler agentRuntimeCompiler;
|
private AgentRuntimeCompiler agentRuntimeCompiler;
|
||||||
|
@Resource
|
||||||
|
private AgentSkillRuntimeProjector agentSkillRuntimeProjector;
|
||||||
|
@Resource
|
||||||
|
private AgentWorkflowSnapshotFactory agentWorkflowSnapshotFactory;
|
||||||
|
@Resource
|
||||||
|
private AgentBuiltinToolsConfigResolver agentBuiltinToolsConfigResolver;
|
||||||
|
@Resource
|
||||||
|
private SysLogService sysLogService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
@@ -76,8 +101,11 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
|||||||
public Agent getDetail(BigInteger id) {
|
public Agent getDetail(BigInteger id) {
|
||||||
Agent agent = requireAgent(id);
|
Agent agent = requireAgent(id);
|
||||||
resourceAccessService.assertAccess(CategoryResourceType.AGENT, agent, ResourceAction.READ, "无权限查看该 Agent");
|
resourceAccessService.assertAccess(CategoryResourceType.AGENT, agent, ResourceAction.READ, "无权限查看该 Agent");
|
||||||
|
agent.setExecutionConfigJson(
|
||||||
|
agentBuiltinToolsConfigResolver.normalizeForDraftRead(agent.getExecutionConfigJson()));
|
||||||
agent.setToolBindings(agentToolBindingService.listEnabled(id));
|
agent.setToolBindings(agentToolBindingService.listEnabled(id));
|
||||||
agent.setKnowledgeBindings(agentKnowledgeBindingService.listEnabled(id));
|
agent.setKnowledgeBindings(agentKnowledgeBindingService.listEnabled(id));
|
||||||
|
agent.setSkillBindings(agentSkillBindingService.listSummaries(id));
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,9 +116,15 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
|||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public Agent saveDraft(Agent agent) {
|
public Agent saveDraft(Agent agent) {
|
||||||
applyDraftDefaults(agent);
|
applyDraftDefaults(agent);
|
||||||
validateDraft(agent);
|
validateDraft(agent, null);
|
||||||
|
boolean shellApprovalDisabled = agentBuiltinToolsConfigResolver
|
||||||
|
.isShellApprovalDisableTransition(agent.getExecutionConfigJson(), null);
|
||||||
save(agent);
|
save(agent);
|
||||||
return getDetail(agent.getId());
|
if (shellApprovalDisabled) {
|
||||||
|
recordShellApprovalDisabled(agent.getId(), "saveDraft",
|
||||||
|
AgentBuiltinToolsConfig.newAgentDefaults().shell());
|
||||||
|
}
|
||||||
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,13 +141,48 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
|||||||
resourceAccessService.assertAccess(
|
resourceAccessService.assertAccess(
|
||||||
CategoryResourceType.AGENT, existing, ResourceAction.MANAGE, "无权限管理该 Agent");
|
CategoryResourceType.AGENT, existing, ResourceAction.MANAGE, "无权限管理该 Agent");
|
||||||
agent.setTenantId(existing.getTenantId());
|
agent.setTenantId(existing.getTenantId());
|
||||||
validateDraft(agent);
|
Map<String, Object> existingExecutionConfig = existing.getExecutionConfigJson();
|
||||||
|
validateDraft(agent, existingExecutionConfig);
|
||||||
|
boolean shellApprovalDisabled = agentBuiltinToolsConfigResolver
|
||||||
|
.isShellApprovalDisableTransition(agent.getExecutionConfigJson(), existingExecutionConfig);
|
||||||
|
AgentBuiltinToolsConfig.ToolSwitch previousShell = agentBuiltinToolsConfigResolver
|
||||||
|
.resolveDraftRuntime(existingExecutionConfig).shell();
|
||||||
applyDraftUpdate(existing, agent);
|
applyDraftUpdate(existing, agent);
|
||||||
updateById(existing);
|
updateById(existing);
|
||||||
return getDetail(existing.getId());
|
if (shellApprovalDisabled) {
|
||||||
|
recordShellApprovalDisabled(existing.getId(), "updateDraft", previousShell);
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public Agent saveDraftGraph(Agent agent,
|
||||||
|
List<AgentToolBinding> toolBindings,
|
||||||
|
boolean replaceToolBindings,
|
||||||
|
List<AgentKnowledgeBinding> knowledgeBindings,
|
||||||
|
boolean replaceKnowledgeBindings,
|
||||||
|
List<AgentSkillBinding> skillBindings,
|
||||||
|
boolean replaceSkillBindings) {
|
||||||
|
Agent saved = agent != null && agent.getId() != null
|
||||||
|
? updateDraft(agent) : saveDraft(agent);
|
||||||
|
BigInteger agentId = saved.getId();
|
||||||
|
if (replaceToolBindings) {
|
||||||
|
saved.setToolBindings(agentToolBindingService.replaceBindings(agentId, toolBindings));
|
||||||
|
}
|
||||||
|
if (replaceKnowledgeBindings) {
|
||||||
|
saved.setKnowledgeBindings(agentKnowledgeBindingService.replaceBindings(agentId, knowledgeBindings));
|
||||||
|
}
|
||||||
|
if (replaceSkillBindings) {
|
||||||
|
saved.setSkillBindings(agentSkillBindingService.replaceBindings(agentId, skillBindings));
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*/
|
*/
|
||||||
@@ -174,7 +243,10 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
|||||||
CategoryResourceType.AGENT, detail, ResourceAction.MANAGE, "无权限管理该 Agent");
|
CategoryResourceType.AGENT, detail, ResourceAction.MANAGE, "无权限管理该 Agent");
|
||||||
detail.setToolBindings(agentToolBindingService.listEnabled(agentId));
|
detail.setToolBindings(agentToolBindingService.listEnabled(agentId));
|
||||||
detail.setKnowledgeBindings(agentKnowledgeBindingService.listEnabled(agentId));
|
detail.setKnowledgeBindings(agentKnowledgeBindingService.listEnabled(agentId));
|
||||||
validateDraft(detail);
|
detail.setSkillBindings(agentSkillBindingService.listBindings(agentId));
|
||||||
|
validateDraft(detail, detail.getExecutionConfigJson());
|
||||||
|
List<AgentSkillBinding> projectedSkillBindings =
|
||||||
|
agentSkillRuntimeProjector.projectCurrentBindings(detail, detail.getSkillBindings());
|
||||||
Map<String, Object> snapshot = new LinkedHashMap<>();
|
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||||
snapshot.put("id", detail.getId());
|
snapshot.put("id", detail.getId());
|
||||||
snapshot.put("tenantId", detail.getTenantId());
|
snapshot.put("tenantId", detail.getTenantId());
|
||||||
@@ -194,12 +266,15 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
|||||||
snapshot.put("visibilityScope", detail.getVisibilityScope());
|
snapshot.put("visibilityScope", detail.getVisibilityScope());
|
||||||
snapshot.put("toolBindings", snapshotToolBindings(detail, detail.getToolBindings()));
|
snapshot.put("toolBindings", snapshotToolBindings(detail, detail.getToolBindings()));
|
||||||
snapshot.put("knowledgeBindings", snapshotKnowledgeBindings(detail, detail.getKnowledgeBindings()));
|
snapshot.put("knowledgeBindings", snapshotKnowledgeBindings(detail, detail.getKnowledgeBindings()));
|
||||||
|
snapshot.put("skillBindings", projectedSkillBindings);
|
||||||
snapshot.put("basicSummary", basicSummary(detail));
|
snapshot.put("basicSummary", basicSummary(detail));
|
||||||
snapshot.put("modelSummary", modelSummary(detail.getModelId()));
|
snapshot.put("modelSummary", modelSummary(detail.getModelId()));
|
||||||
snapshot.put("parameterSummary", parameterSummary(detail));
|
snapshot.put("parameterSummary", parameterSummary(detail));
|
||||||
snapshot.put("promptSummary", promptSummary(detail));
|
snapshot.put("promptSummary", promptSummary(detail));
|
||||||
snapshot.put("toolSummaries", toolSummaries(detail.getToolBindings()));
|
snapshot.put("toolSummaries", toolSummaries(detail.getToolBindings()));
|
||||||
snapshot.put("knowledgeSummaries", knowledgeSummaries(detail.getKnowledgeBindings()));
|
snapshot.put("knowledgeSummaries", knowledgeSummaries(detail.getKnowledgeBindings()));
|
||||||
|
snapshot.put("skillSummaries", projectedSkillBindings.stream()
|
||||||
|
.map(AgentSkillBinding::getResourceSummary).toList());
|
||||||
snapshot.put("snapshotAt", new Date());
|
snapshot.put("snapshotAt", new Date());
|
||||||
// 发布前完整编译一次,提前暴露工具运行名冲突和运行定义错误。
|
// 发布前完整编译一次,提前暴露工具运行名冲突和运行定义错误。
|
||||||
agentRuntimeCompiler.compile(fromSnapshot(snapshot));
|
agentRuntimeCompiler.compile(fromSnapshot(snapshot));
|
||||||
@@ -222,10 +297,14 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
|||||||
agent.setModelId(toBigInteger(snapshot.get("modelId")));
|
agent.setModelId(toBigInteger(snapshot.get("modelId")));
|
||||||
agent.setCategoryId(toBigInteger(snapshot.get("categoryId")));
|
agent.setCategoryId(toBigInteger(snapshot.get("categoryId")));
|
||||||
agent.setPublishStatus(PublishStatus.PUBLISHED.getCode());
|
agent.setPublishStatus(PublishStatus.PUBLISHED.getCode());
|
||||||
|
agent.setExecutionConfigJson(
|
||||||
|
agentBuiltinToolsConfigResolver.normalizeForPublishedRuntime(agent.getExecutionConfigJson()));
|
||||||
agent.setInteractionConfigJson(AgentInteractionConfigSupport.normalize(agent.getInteractionConfigJson()));
|
agent.setInteractionConfigJson(AgentInteractionConfigSupport.normalize(agent.getInteractionConfigJson()));
|
||||||
agent.setPublishedSnapshotJson(snapshot);
|
agent.setPublishedSnapshotJson(snapshot);
|
||||||
agent.setToolBindings(objectMapper.convertValue(snapshot.get("toolBindings"), TOOL_BINDING_LIST_TYPE));
|
agent.setToolBindings(objectMapper.convertValue(snapshot.get("toolBindings"), TOOL_BINDING_LIST_TYPE));
|
||||||
agent.setKnowledgeBindings(objectMapper.convertValue(snapshot.get("knowledgeBindings"), KNOWLEDGE_BINDING_LIST_TYPE));
|
agent.setKnowledgeBindings(objectMapper.convertValue(snapshot.get("knowledgeBindings"), KNOWLEDGE_BINDING_LIST_TYPE));
|
||||||
|
agent.setSkillBindings(snapshot.get("skillBindings") == null ? List.of()
|
||||||
|
: objectMapper.convertValue(snapshot.get("skillBindings"), SKILL_BINDING_LIST_TYPE));
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +332,7 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
|||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateDraft(Agent agent) {
|
private void validateDraft(Agent agent, Map<String, Object> existingExecutionConfig) {
|
||||||
if (agent == null) {
|
if (agent == null) {
|
||||||
throw new BusinessException("Agent 不能为空");
|
throw new BusinessException("Agent 不能为空");
|
||||||
}
|
}
|
||||||
@@ -264,7 +343,9 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
|||||||
agentDependencyAccessService.validateCategory(agent);
|
agentDependencyAccessService.validateCategory(agent);
|
||||||
agent.setVisibilityScope(VisibilityScope.fromOrDefault(agent.getVisibilityScope(), VisibilityScope.PRIVATE).name());
|
agent.setVisibilityScope(VisibilityScope.fromOrDefault(agent.getVisibilityScope(), VisibilityScope.PRIVATE).name());
|
||||||
agent.setInteractionConfigJson(AgentInteractionConfigSupport.normalize(agent.getInteractionConfigJson()));
|
agent.setInteractionConfigJson(AgentInteractionConfigSupport.normalize(agent.getInteractionConfigJson()));
|
||||||
agent.setExecutionConfigJson(normalizeExecutionConfig(agent.getExecutionConfigJson()));
|
Map<String, Object> executionConfig = normalizeExecutionConfig(agent.getExecutionConfigJson());
|
||||||
|
agent.setExecutionConfigJson(agentBuiltinToolsConfigResolver.normalizeForDraftSave(
|
||||||
|
executionConfig, existingExecutionConfig, requireCurrentLoginAccount()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -356,6 +437,33 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
|||||||
existing.setModifiedBy(account.getId());
|
existing.setModifiedBy(account.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 持久化 Shell 审批关闭这一高风险配置变更的专用审计记录。
|
||||||
|
*
|
||||||
|
* @param agentId Agent ID
|
||||||
|
* @param actionMethod 触发变更的服务方法
|
||||||
|
* @param previousShell 变更前 Shell 配置
|
||||||
|
*/
|
||||||
|
private void recordShellApprovalDisabled(BigInteger agentId,
|
||||||
|
String actionMethod,
|
||||||
|
AgentBuiltinToolsConfig.ToolSwitch previousShell) {
|
||||||
|
LoginAccount account = requireCurrentLoginAccount();
|
||||||
|
SysLog log = new SysLog();
|
||||||
|
log.setAccountId(account.getId());
|
||||||
|
log.setActionName("关闭 Agent Shell 调用审批");
|
||||||
|
log.setActionType("SECURITY_CONFIG_CHANGE");
|
||||||
|
log.setActionClass(AgentServiceImpl.class.getName());
|
||||||
|
log.setActionMethod(actionMethod);
|
||||||
|
log.setActionUrl("/api/v1/agent/" + ("saveDraft".equals(actionMethod) ? "save" : "update"));
|
||||||
|
log.setActionBody("{\"agentId\":\"" + agentId
|
||||||
|
+ "\",\"setting\":\"shell\",\"before\":{\"enabled\":"
|
||||||
|
+ previousShell.enabled() + ",\"approvalRequired\":" + previousShell.approvalRequired()
|
||||||
|
+ "},\"after\":{\"enabled\":true,\"approvalRequired\":false}}");
|
||||||
|
log.setStatus(1);
|
||||||
|
log.setCreated(new Date());
|
||||||
|
sysLogService.save(log);
|
||||||
|
}
|
||||||
|
|
||||||
private Map<String, Object> modelSummary(BigInteger modelId) {
|
private Map<String, Object> modelSummary(BigInteger modelId) {
|
||||||
Model model = modelService.getModelInstance(modelId);
|
Model model = modelService.getModelInstance(modelId);
|
||||||
Map<String, Object> summary = new LinkedHashMap<>();
|
Map<String, Object> summary = new LinkedHashMap<>();
|
||||||
@@ -432,14 +540,19 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
|||||||
private Map<String, Object> toolResourceSnapshot(Agent agent, AgentToolBinding binding) {
|
private Map<String, Object> toolResourceSnapshot(Agent agent, AgentToolBinding binding) {
|
||||||
if ("WORKFLOW".equalsIgnoreCase(binding.getToolType())) {
|
if ("WORKFLOW".equalsIgnoreCase(binding.getToolType())) {
|
||||||
Workflow workflow = agentDependencyAccessService.requireWorkflow(agent, binding.getTargetId());
|
Workflow workflow = agentDependencyAccessService.requireWorkflow(agent, binding.getTargetId());
|
||||||
return objectMapper.convertValue(workflow, new TypeReference<Map<String, Object>>() {});
|
return agentWorkflowSnapshotFactory.snapshot(workflow);
|
||||||
}
|
}
|
||||||
if ("PLUGIN".equalsIgnoreCase(binding.getToolType())) {
|
if ("PLUGIN".equalsIgnoreCase(binding.getToolType())) {
|
||||||
PluginItem pluginItem = agentDependencyAccessService.requirePluginItem(agent, binding.getTargetId());
|
AgentDependencyAccessService.PluginResource resource =
|
||||||
return objectMapper.convertValue(pluginItem, new TypeReference<Map<String, Object>>() {});
|
agentDependencyAccessService.requirePluginResource(agent, binding.getTargetId());
|
||||||
|
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||||
|
snapshot.put("pluginItem", objectMapper.convertValue(
|
||||||
|
resource.pluginItem(), new TypeReference<Map<String, Object>>() {}));
|
||||||
|
snapshot.put("plugin", pluginConnectionSnapshotFactory.snapshot(resource.plugin()));
|
||||||
|
return snapshot;
|
||||||
}
|
}
|
||||||
Mcp mcp = agentDependencyAccessService.requireMcp(agent, binding.getTargetId());
|
Mcp mcp = agentDependencyAccessService.requireMcp(agent, binding.getTargetId());
|
||||||
return objectMapper.convertValue(mcp, new TypeReference<Map<String, Object>>() {});
|
return mcpConnectionSnapshotFactory.snapshot(mcp);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<AgentKnowledgeBinding> snapshotKnowledgeBindings(
|
private List<AgentKnowledgeBinding> snapshotKnowledgeBindings(
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package tech.easyflow.agent.service.impl;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import com.mybatisflex.spring.service.impl.ServiceImpl;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import tech.easyflow.agent.entity.Agent;
|
||||||
|
import tech.easyflow.agent.entity.AgentSkillBinding;
|
||||||
|
import tech.easyflow.agent.mapper.AgentMapper;
|
||||||
|
import tech.easyflow.agent.mapper.AgentSkillBindingMapper;
|
||||||
|
import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeProjector;
|
||||||
|
import tech.easyflow.agent.service.AgentSkillBindingService;
|
||||||
|
import tech.easyflow.agent.support.AgentBindingLockExecutor;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.skill.entity.Skill;
|
||||||
|
import tech.easyflow.skill.service.SkillService;
|
||||||
|
import tech.easyflow.system.enums.CategoryResourceType;
|
||||||
|
import tech.easyflow.system.enums.ResourceAction;
|
||||||
|
import tech.easyflow.system.service.ResourceAccessService;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent Skill 绑定服务实现。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class AgentSkillBindingServiceImpl
|
||||||
|
extends ServiceImpl<AgentSkillBindingMapper, AgentSkillBinding>
|
||||||
|
implements AgentSkillBindingService {
|
||||||
|
|
||||||
|
private final AgentMapper agentMapper;
|
||||||
|
private final AgentBindingLockExecutor bindingLockExecutor;
|
||||||
|
private final AgentSkillRuntimeProjector runtimeProjector;
|
||||||
|
private final SkillService skillService;
|
||||||
|
private final ResourceAccessService resourceAccessService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Agent Skill 绑定服务。
|
||||||
|
*
|
||||||
|
* @param agentMapper Agent Mapper
|
||||||
|
* @param bindingLockExecutor Agent 绑定锁执行器
|
||||||
|
* @param runtimeProjector Skill 运行投影器
|
||||||
|
* @param skillService Skill 服务
|
||||||
|
* @param resourceAccessService 资源权限服务
|
||||||
|
*/
|
||||||
|
public AgentSkillBindingServiceImpl(AgentMapper agentMapper,
|
||||||
|
AgentBindingLockExecutor bindingLockExecutor,
|
||||||
|
AgentSkillRuntimeProjector runtimeProjector,
|
||||||
|
SkillService skillService,
|
||||||
|
ResourceAccessService resourceAccessService) {
|
||||||
|
this.agentMapper = agentMapper;
|
||||||
|
this.bindingLockExecutor = bindingLockExecutor;
|
||||||
|
this.runtimeProjector = runtimeProjector;
|
||||||
|
this.skillService = skillService;
|
||||||
|
this.resourceAccessService = resourceAccessService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public List<AgentSkillBinding> replaceBindings(BigInteger agentId,
|
||||||
|
List<AgentSkillBinding> bindings) {
|
||||||
|
return bindingLockExecutor.execute(agentId, () -> {
|
||||||
|
Agent agent = requireAgentForUpdate(agentId);
|
||||||
|
resourceAccessService.assertAccess(
|
||||||
|
CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent");
|
||||||
|
List<AgentSkillBinding> current = listBindings(agentId);
|
||||||
|
if (AgentBindingSemanticComparator.sameSkills(current, bindings)) {
|
||||||
|
return listSummaries(agentId);
|
||||||
|
}
|
||||||
|
List<AgentSkillBinding> normalized = normalize(agent, bindings);
|
||||||
|
// 在删除旧绑定前完成权限、包完整性、Tool 与 8 MiB 预算校验,失败时保留旧组。
|
||||||
|
List<AgentSkillBinding> projected = runtimeProjector.projectCurrentBindings(agent, normalized);
|
||||||
|
Map<BigInteger, Map<String, Object>> summaries = new HashMap<>();
|
||||||
|
for (AgentSkillBinding binding : projected) {
|
||||||
|
summaries.put(binding.getSkillId(), binding.getResourceSummary());
|
||||||
|
}
|
||||||
|
normalized.forEach(binding -> binding.setResourceSummary(summaries.get(binding.getSkillId())));
|
||||||
|
remove(QueryWrapper.create()
|
||||||
|
.eq(AgentSkillBinding::getTenantId, agent.getTenantId())
|
||||||
|
.eq(AgentSkillBinding::getAgentId, agentId));
|
||||||
|
if (!normalized.isEmpty()) {
|
||||||
|
saveBatch(normalized);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public List<AgentSkillBinding> listBindings(BigInteger agentId) {
|
||||||
|
if (agentId == null) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
return list(QueryWrapper.create()
|
||||||
|
.eq(AgentSkillBinding::getAgentId, agentId)
|
||||||
|
.orderBy(AgentSkillBinding::getSortNo, true)
|
||||||
|
.orderBy(AgentSkillBinding::getId, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public List<AgentSkillBinding> listSummaries(BigInteger agentId) {
|
||||||
|
List<AgentSkillBinding> bindings = listBindings(agentId);
|
||||||
|
Agent agent = agentMapper.selectOneById(agentId);
|
||||||
|
Map<BigInteger, String> publishedHashes = publishedRuntimeHashes(agent);
|
||||||
|
for (AgentSkillBinding binding : bindings) {
|
||||||
|
Skill skill = skillService.getById(binding.getSkillId());
|
||||||
|
if (skill == null) {
|
||||||
|
binding.setResourceSummary(Map.of(
|
||||||
|
"skillId", binding.getSkillId(),
|
||||||
|
"displayName", "已失效技能",
|
||||||
|
"available", false));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
binding.setResourceSummary(runtimeProjector.currentSummary(
|
||||||
|
skill, publishedHashes.get(binding.getSkillId())));
|
||||||
|
binding.getResourceSummary().put("available", true);
|
||||||
|
}
|
||||||
|
return bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范客户端绑定并写入服务端归属、排序和审计字段。
|
||||||
|
*
|
||||||
|
* @param agent Agent
|
||||||
|
* @param bindings 客户端绑定
|
||||||
|
* @return 规范绑定
|
||||||
|
*/
|
||||||
|
private List<AgentSkillBinding> normalize(Agent agent, List<AgentSkillBinding> bindings) {
|
||||||
|
if (bindings == null || bindings.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (bindings.size() > AgentSkillRuntimeProjector.MAX_SKILL_COUNT) {
|
||||||
|
throw new BusinessException(409, 4092, "单个 Agent 最多可绑定 20 个 Skill");
|
||||||
|
}
|
||||||
|
Set<BigInteger> unique = new HashSet<>();
|
||||||
|
List<AgentSkillBinding> result = new ArrayList<>();
|
||||||
|
LoginAccount account = requireAccount();
|
||||||
|
Date now = new Date();
|
||||||
|
for (int index = 0; index < bindings.size(); index++) {
|
||||||
|
AgentSkillBinding source = bindings.get(index);
|
||||||
|
if (source == null || source.getSkillId() == null) {
|
||||||
|
throw new BusinessException("Agent Skill 绑定参数不完整");
|
||||||
|
}
|
||||||
|
if (!unique.add(source.getSkillId())) {
|
||||||
|
throw new BusinessException(409, 4092, "同一 Skill 不能重复绑定");
|
||||||
|
}
|
||||||
|
AgentSkillBinding binding = new AgentSkillBinding();
|
||||||
|
binding.setTenantId(agent.getTenantId());
|
||||||
|
binding.setAgentId(agent.getId());
|
||||||
|
binding.setSkillId(source.getSkillId());
|
||||||
|
binding.setSortNo(index);
|
||||||
|
binding.setCreated(now);
|
||||||
|
binding.setCreatedBy(account.getId());
|
||||||
|
binding.setModified(now);
|
||||||
|
binding.setModifiedBy(account.getId());
|
||||||
|
result.add(binding);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询并锁定 Agent。
|
||||||
|
*
|
||||||
|
* @param agentId Agent ID
|
||||||
|
* @return Agent
|
||||||
|
*/
|
||||||
|
private Agent requireAgentForUpdate(BigInteger agentId) {
|
||||||
|
if (agentId == null) {
|
||||||
|
throw new BusinessException("Agent ID 不能为空");
|
||||||
|
}
|
||||||
|
Agent agent = agentMapper.selectOneByQuery(QueryWrapper.create()
|
||||||
|
.eq(Agent::getId, agentId)
|
||||||
|
.forUpdate());
|
||||||
|
if (agent == null) {
|
||||||
|
throw new BusinessException(404, 404, "Agent 不存在");
|
||||||
|
}
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Agent 当前线上冻结的 Skill 组合 hash。
|
||||||
|
*
|
||||||
|
* @param agentId Agent ID
|
||||||
|
* @param skillId Skill ID
|
||||||
|
* @return 组合 hash 或 null
|
||||||
|
*/
|
||||||
|
private Map<BigInteger, String> publishedRuntimeHashes(Agent agent) {
|
||||||
|
Map<BigInteger, String> hashes = new HashMap<>();
|
||||||
|
Map<String, Object> snapshot = agent == null ? null : agent.getPublishedSnapshotJson();
|
||||||
|
Object rawBindings = snapshot == null ? null : snapshot.get("skillBindings");
|
||||||
|
if (!(rawBindings instanceof List<?> items)) {
|
||||||
|
return hashes;
|
||||||
|
}
|
||||||
|
for (Object raw : items) {
|
||||||
|
if (!(raw instanceof Map<?, ?> item) || item.get("skillId") == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Object resource = item.get("resourceSnapshot");
|
||||||
|
if (resource instanceof Map<?, ?> resourceMap) {
|
||||||
|
Object hash = resourceMap.get("skillRuntimeSnapshotHash");
|
||||||
|
if (hash != null) {
|
||||||
|
hashes.put(new BigInteger(String.valueOf(item.get("skillId"))), String.valueOf(hash));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hashes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前登录账号。
|
||||||
|
*
|
||||||
|
* @return 登录账号
|
||||||
|
*/
|
||||||
|
private LoginAccount requireAccount() {
|
||||||
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
|
if (account == null || account.getId() == null) {
|
||||||
|
throw new BusinessException(401, 401, "当前登录状态失效,请重新登录后再试");
|
||||||
|
}
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package tech.easyflow.agent.service.impl;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.agent.entity.Agent;
|
||||||
|
import tech.easyflow.agent.entity.AgentSkillBinding;
|
||||||
|
import tech.easyflow.agent.service.AgentService;
|
||||||
|
import tech.easyflow.agent.service.AgentSkillBindingService;
|
||||||
|
import tech.easyflow.skill.service.SkillReferenceProvider;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 草稿和有效发布快照中的 Skill 引用提供者。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AgentSkillReferenceProvider implements SkillReferenceProvider {
|
||||||
|
|
||||||
|
private final AgentService agentService;
|
||||||
|
private final AgentSkillBindingService bindingService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建引用提供者。
|
||||||
|
*
|
||||||
|
* @param agentService Agent 服务
|
||||||
|
* @param bindingService Agent Skill 绑定服务
|
||||||
|
*/
|
||||||
|
public AgentSkillReferenceProvider(AgentService agentService,
|
||||||
|
AgentSkillBindingService bindingService) {
|
||||||
|
this.agentService = agentService;
|
||||||
|
this.bindingService = bindingService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public List<String> listReferences(BigInteger skillId) {
|
||||||
|
Set<BigInteger> ids = new LinkedHashSet<>();
|
||||||
|
for (AgentSkillBinding binding : bindingService.list(QueryWrapper.create()
|
||||||
|
.eq(AgentSkillBinding::getSkillId, skillId))) {
|
||||||
|
ids.add(binding.getAgentId());
|
||||||
|
}
|
||||||
|
for (Agent agent : agentService.list(QueryWrapper.create()
|
||||||
|
.select(Agent::getId, Agent::getPublishedSnapshotJson)
|
||||||
|
.isNotNull(Agent::getPublishedSnapshotJson))) {
|
||||||
|
if (containsSkill(agent.getPublishedSnapshotJson(), skillId)) {
|
||||||
|
ids.add(agent.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
for (Agent agent : agentService.listByIds(ids)) {
|
||||||
|
result.add("智能体“" + (agent.getName() == null ? "未命名智能体" : agent.getName()) + "”");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean containsSkill(Map<String, Object> snapshot, BigInteger skillId) {
|
||||||
|
Object raw = snapshot == null ? null : snapshot.get("skillBindings");
|
||||||
|
if (!(raw instanceof List<?> bindings)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return bindings.stream().anyMatch(item -> item instanceof Map<?, ?> binding
|
||||||
|
&& skillId.toString().equals(String.valueOf(binding.get("skillId"))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,10 @@ public class AgentToolBindingServiceImpl extends ServiceImpl<AgentToolBindingMap
|
|||||||
Agent agent = requireAgentForUpdate(agentId);
|
Agent agent = requireAgentForUpdate(agentId);
|
||||||
resourceAccessService.assertAccess(
|
resourceAccessService.assertAccess(
|
||||||
CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent");
|
CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent");
|
||||||
|
List<AgentToolBinding> current = listAll(agentId);
|
||||||
|
if (AgentBindingSemanticComparator.sameTools(current, bindings)) {
|
||||||
|
return enabledBindings(current);
|
||||||
|
}
|
||||||
validateBindings(agent, bindings);
|
validateBindings(agent, bindings);
|
||||||
remove(QueryWrapper.create().where("agent_id = ?", agentId));
|
remove(QueryWrapper.create().where("agent_id = ?", agentId));
|
||||||
if (bindings == null || bindings.isEmpty()) {
|
if (bindings == null || bindings.isEmpty()) {
|
||||||
@@ -64,7 +68,7 @@ public class AgentToolBindingServiceImpl extends ServiceImpl<AgentToolBindingMap
|
|||||||
applyBindingDefaults(agent, bindings.get(i), i);
|
applyBindingDefaults(agent, bindings.get(i), i);
|
||||||
}
|
}
|
||||||
saveBatch(bindings);
|
saveBatch(bindings);
|
||||||
return listEnabled(agentId);
|
return enabledBindings(bindings);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +83,30 @@ public class AgentToolBindingServiceImpl extends ServiceImpl<AgentToolBindingMap
|
|||||||
.orderBy("sort_no asc, id asc"));
|
.orderBy("sort_no asc, id asc"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询 Agent 的全部工具绑定,用于整组语义比较。
|
||||||
|
*
|
||||||
|
* @param agentId Agent ID
|
||||||
|
* @return 稳定排序的全部绑定
|
||||||
|
*/
|
||||||
|
private List<AgentToolBinding> listAll(BigInteger agentId) {
|
||||||
|
return list(QueryWrapper.create()
|
||||||
|
.where("agent_id = ?", agentId)
|
||||||
|
.orderBy("sort_no asc, id asc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从已加载或已写入的绑定中筛选启用项,避免替换后再次查询。
|
||||||
|
*
|
||||||
|
* @param bindings 工具绑定
|
||||||
|
* @return 启用绑定
|
||||||
|
*/
|
||||||
|
private List<AgentToolBinding> enabledBindings(List<AgentToolBinding> bindings) {
|
||||||
|
return bindings.stream()
|
||||||
|
.filter(binding -> binding != null && binding.getEnabled() != Boolean.FALSE)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 锁定并加载待修改的 Agent。
|
* 锁定并加载待修改的 Agent。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -8,18 +8,56 @@ import java.util.List;
|
|||||||
*
|
*
|
||||||
* @param models 模型选项
|
* @param models 模型选项
|
||||||
* @param knowledges 知识库选项
|
* @param knowledges 知识库选项
|
||||||
|
* @param skills Skill 选项
|
||||||
* @param workflows 工作流选项
|
* @param workflows 工作流选项
|
||||||
* @param pluginTools 插件工具选项
|
* @param pluginTools 插件工具选项
|
||||||
* @param mcps MCP 选项
|
* @param mcps MCP 选项
|
||||||
|
* @param capabilities 当前账号的 Agent 设计能力
|
||||||
*/
|
*/
|
||||||
public record AgentResourceOptionsView(
|
public record AgentResourceOptionsView(
|
||||||
List<ModelOption> models,
|
List<ModelOption> models,
|
||||||
List<ResourceOption> knowledges,
|
List<ResourceOption> knowledges,
|
||||||
|
List<SkillOption> skills,
|
||||||
List<ResourceOption> workflows,
|
List<ResourceOption> workflows,
|
||||||
List<PluginToolOption> pluginTools,
|
List<PluginToolOption> pluginTools,
|
||||||
List<McpOption> mcps
|
List<McpOption> mcps,
|
||||||
|
Capabilities capabilities
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 设计器的服务端权限能力。
|
||||||
|
*
|
||||||
|
* @param canDisableShellApproval 是否允许关闭 Shell 调用前审批
|
||||||
|
*/
|
||||||
|
public record Capabilities(boolean canDisableShellApproval) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已发布 Skill 安全选择项。
|
||||||
|
*
|
||||||
|
* @param id Skill ID
|
||||||
|
* @param displayName 展示名称
|
||||||
|
* @param description 用途描述
|
||||||
|
* @param visibilityScope 使用范围
|
||||||
|
* @param snapshotHash 发布组合快照 hash
|
||||||
|
* @param toolCount 冻结 Tool 数量
|
||||||
|
* @param textBytes Skill 文本投影 UTF-8 字节数
|
||||||
|
* @param textResourceCount 文本资源数量
|
||||||
|
* @param binaryResourceCount 二进制资源数量
|
||||||
|
*/
|
||||||
|
public record SkillOption(
|
||||||
|
BigInteger id,
|
||||||
|
String displayName,
|
||||||
|
String description,
|
||||||
|
String visibilityScope,
|
||||||
|
String snapshotHash,
|
||||||
|
int toolCount,
|
||||||
|
long textBytes,
|
||||||
|
int textResourceCount,
|
||||||
|
int binaryResourceCount
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 模型安全选择项。
|
* 模型安全选择项。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package tech.easyflow.agent.config;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.system.service.CategoryPermissionService;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 内置工具默认值、兼容语义和 Shell 高风险确认测试。
|
||||||
|
*/
|
||||||
|
public class AgentBuiltinToolsConfigResolverTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证新草稿缺失配置时五项启用且仅 Shell 默认审批。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void draftDefaultsShouldEnableFiveToolsAndApproveShellOnly() {
|
||||||
|
AgentBuiltinToolsConfigResolver resolver = resolver(false);
|
||||||
|
|
||||||
|
AgentBuiltinToolsConfig config = resolver.resolveDraftRuntime(Map.of());
|
||||||
|
|
||||||
|
Assert.assertTrue(config.read().enabled());
|
||||||
|
Assert.assertTrue(config.write().enabled());
|
||||||
|
Assert.assertTrue(config.patch().enabled());
|
||||||
|
Assert.assertTrue(config.shell().enabled());
|
||||||
|
Assert.assertTrue(config.artifactPublish().enabled());
|
||||||
|
Assert.assertFalse(config.read().approvalRequired());
|
||||||
|
Assert.assertTrue(config.shell().approvalRequired());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证旧发布快照缺失内置工具配置时保持全部禁用。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void legacyPublishedSnapshotShouldDisableAllBuiltinTools() {
|
||||||
|
AgentBuiltinToolsConfig config = resolver(false).resolvePublishedRuntime(Map.of());
|
||||||
|
|
||||||
|
Assert.assertFalse(config.read().enabled());
|
||||||
|
Assert.assertFalse(config.write().enabled());
|
||||||
|
Assert.assertFalse(config.patch().enabled());
|
||||||
|
Assert.assertFalse(config.shell().enabled());
|
||||||
|
Assert.assertFalse(config.artifactPublish().enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证普通账号伪造确认字段仍无法关闭 Shell 审批。
|
||||||
|
*/
|
||||||
|
@Test(expected = BusinessException.class)
|
||||||
|
public void ordinaryUserShouldNotDisableShellApprovalWithForgedConfirmation() {
|
||||||
|
resolver(false).normalizeForDraftSave(shellApprovalDisabled(true), Map.of(), new LoginAccount());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证平台超管确认后可关闭审批,且一次性确认字段不会持久化。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void superAdminShouldDisableShellApprovalAfterConfirmationAndStripSignal() {
|
||||||
|
Map<String, Object> normalized = resolver(true).normalizeForDraftSave(
|
||||||
|
shellApprovalDisabled(true), Map.of(), new LoginAccount());
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> builtin = (Map<String, Object>) normalized.get("builtinTools");
|
||||||
|
Assert.assertFalse(builtin.containsKey("shellApprovalRiskConfirmed"));
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> shell = (Map<String, Object>) builtin.get("shell");
|
||||||
|
Assert.assertEquals(Boolean.FALSE, shell.get("approvalRequired"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证显式未知结构版本不会按 v1 静默解释。
|
||||||
|
*/
|
||||||
|
@Test(expected = BusinessException.class)
|
||||||
|
public void futureSchemaVersionShouldBeRejected() {
|
||||||
|
resolver(false).resolveDraftRuntime(Map.of(
|
||||||
|
"builtinTools", Map.of("schemaVersion", 2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证缺失版本号继续按 v1 兼容,并能识别真实关闭审批变更。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void missingSchemaVersionShouldUseV1AndDetectApprovalTransition() {
|
||||||
|
AgentBuiltinToolsConfigResolver resolver = resolver(true);
|
||||||
|
Map<String, Object> source = shellApprovalDisabled(true);
|
||||||
|
|
||||||
|
Assert.assertTrue(resolver.isShellApprovalDisableTransition(source, Map.of()));
|
||||||
|
Assert.assertEquals(AgentBuiltinToolsConfig.SCHEMA_VERSION,
|
||||||
|
resolver.resolveDraftRuntime(source).toMap().get("schemaVersion"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 验证 builtinTools 标量不会静默回退默认值。 */
|
||||||
|
@Test(expected = BusinessException.class)
|
||||||
|
public void scalarBuiltinToolsShouldBeRejected() {
|
||||||
|
resolver(false).resolveDraftRuntime(Map.of("builtinTools", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 验证工具项标量不会静默回退默认值。 */
|
||||||
|
@Test(expected = BusinessException.class)
|
||||||
|
public void scalarToolConfigShouldBeRejected() {
|
||||||
|
resolver(false).resolveDraftRuntime(Map.of("builtinTools", Map.of("read", true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 验证布尔字段的字符串形式不会被宽松接受。 */
|
||||||
|
@Test(expected = BusinessException.class)
|
||||||
|
public void stringBooleanShouldBeRejected() {
|
||||||
|
resolver(false).resolveDraftRuntime(Map.of(
|
||||||
|
"builtinTools", Map.of("read", Map.of("enabled", "true"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 验证 schemaVersion 必须使用数值类型。 */
|
||||||
|
@Test(expected = BusinessException.class)
|
||||||
|
public void stringSchemaVersionShouldBeRejected() {
|
||||||
|
resolver(false).resolveDraftRuntime(Map.of(
|
||||||
|
"builtinTools", Map.of("schemaVersion", "1")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentBuiltinToolsConfigResolver resolver(boolean superAdmin) {
|
||||||
|
CategoryPermissionService permissions = Mockito.mock(CategoryPermissionService.class);
|
||||||
|
Mockito.when(permissions.isSuperAdmin(Mockito.any())).thenReturn(superAdmin);
|
||||||
|
return new AgentBuiltinToolsConfigResolver(permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> shellApprovalDisabled(boolean confirmed) {
|
||||||
|
Map<String, Object> builtin = new LinkedHashMap<>();
|
||||||
|
builtin.put("shell", Map.of("enabled", true, "approvalRequired", false));
|
||||||
|
builtin.put("shellApprovalRiskConfirmed", confirmed);
|
||||||
|
return Map.of("builtinTools", builtin);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,6 +113,29 @@ public class AgentRuntimeCommandConsumerTest {
|
|||||||
Assert.assertEquals("cmd-expire", resultRegistry.lastSuccessCommandId);
|
Assert.assertEquals("cmd-expire", resultRegistry.lastSuccessCommandId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证跨节点 AG-UI 审批命令在 owner 节点走脱敏决议入口。
|
||||||
|
*
|
||||||
|
* @throws Exception 消息序列化异常
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void consumerShouldRouteAguiApprovalToResolvedEventPath() throws Exception {
|
||||||
|
AgentRuntimeProperties properties = new AgentRuntimeProperties();
|
||||||
|
properties.setInstanceId("node-a");
|
||||||
|
RecordingAgentRunService service = new RecordingAgentRunService();
|
||||||
|
RecordingCommandResultRegistry resultRegistry = new RecordingCommandResultRegistry();
|
||||||
|
AgentRuntimeCommandConsumer consumer = new AgentRuntimeCommandConsumer(
|
||||||
|
new ObjectMapper(), properties, new MQProperties(), service, resultRegistry);
|
||||||
|
AgentRuntimeCommandMessage command = command("cmd-agui", "node-a");
|
||||||
|
command.setApprovalId("approval-public");
|
||||||
|
|
||||||
|
consumer.handle(List.of(message(command)));
|
||||||
|
|
||||||
|
Assert.assertEquals(1, service.aguiApproveCount);
|
||||||
|
Assert.assertEquals("approval-public", service.lastApprovalId);
|
||||||
|
Assert.assertEquals(0, service.approveCount);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证 Agent 集群取消命令只取消目标节点的对应 Agent 运行。
|
* 验证 Agent 集群取消命令只取消目标节点的对应 Agent 运行。
|
||||||
*
|
*
|
||||||
@@ -163,10 +186,12 @@ public class AgentRuntimeCommandConsumerTest {
|
|||||||
private static final class RecordingAgentRunService extends AgentRunService {
|
private static final class RecordingAgentRunService extends AgentRunService {
|
||||||
|
|
||||||
private int approveCount;
|
private int approveCount;
|
||||||
|
private int aguiApproveCount;
|
||||||
private int expireCount;
|
private int expireCount;
|
||||||
private String lastRequestId;
|
private String lastRequestId;
|
||||||
private String lastReason;
|
private String lastReason;
|
||||||
private String lastCancelledAgentId;
|
private String lastCancelledAgentId;
|
||||||
|
private String lastApprovalId;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void approveRuntimeLocal(String requestId, String resumeToken, BigInteger operatorId, String userId) {
|
public void approveRuntimeLocal(String requestId, String resumeToken, BigInteger operatorId, String userId) {
|
||||||
@@ -174,6 +199,17 @@ public class AgentRuntimeCommandConsumerTest {
|
|||||||
lastRequestId = requestId;
|
lastRequestId = requestId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void approveAguiRuntimeLocal(String requestId,
|
||||||
|
String resumeToken,
|
||||||
|
String approvalId,
|
||||||
|
BigInteger operatorId,
|
||||||
|
String userId) {
|
||||||
|
aguiApproveCount++;
|
||||||
|
lastRequestId = requestId;
|
||||||
|
lastApprovalId = approvalId;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void expireApprovalLocal(String requestId, String resumeToken, String reason) {
|
public void expireApprovalLocal(String requestId, String resumeToken, String reason) {
|
||||||
expireCount++;
|
expireCount++;
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ public class AgentApprovalSubjectHandlerTest {
|
|||||||
toolBindingService,
|
toolBindingService,
|
||||||
knowledgeBindingService,
|
knowledgeBindingService,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
immediateLockExecutor(),
|
immediateLockExecutor(),
|
||||||
runRegistry,
|
runRegistry,
|
||||||
pendingService,
|
pendingService,
|
||||||
@@ -151,6 +152,7 @@ public class AgentApprovalSubjectHandlerTest {
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
immediateLockExecutor(),
|
immediateLockExecutor(),
|
||||||
mock(AgentRunRegistry.class),
|
mock(AgentRunRegistry.class),
|
||||||
mock(AgentHitlPendingService.class),
|
mock(AgentHitlPendingService.class),
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package tech.easyflow.agent.runtime;
|
package tech.easyflow.agent.runtime;
|
||||||
|
|
||||||
|
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||||
|
import ch.qos.logback.core.read.ListAppender;
|
||||||
import com.easyagents.agent.runtime.AgentInitRequest;
|
import com.easyagents.agent.runtime.AgentInitRequest;
|
||||||
import com.easyagents.agent.runtime.AgentRuntime;
|
import com.easyagents.agent.runtime.AgentRuntime;
|
||||||
|
import com.easyagents.agent.runtime.AgentRuntimeContext;
|
||||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||||
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
||||||
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
|
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
|
||||||
@@ -12,6 +15,7 @@ import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSess
|
|||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.mockito.Mockito;
|
import org.mockito.Mockito;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import tech.easyflow.agent.entity.AgentHitlPending;
|
import tech.easyflow.agent.entity.AgentHitlPending;
|
||||||
import tech.easyflow.agent.entity.Agent;
|
import tech.easyflow.agent.entity.Agent;
|
||||||
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||||
@@ -25,6 +29,9 @@ import tech.easyflow.agent.runtime.document.AgentDocumentContext;
|
|||||||
import tech.easyflow.agent.runtime.lock.AgentRunLock;
|
import tech.easyflow.agent.runtime.lock.AgentRunLock;
|
||||||
import tech.easyflow.agent.runtime.media.AgentBoundMedia;
|
import tech.easyflow.agent.runtime.media.AgentBoundMedia;
|
||||||
import tech.easyflow.agent.runtime.media.AgentMediaService;
|
import tech.easyflow.agent.runtime.media.AgentMediaService;
|
||||||
|
import tech.easyflow.agent.runtime.output.AgentRunOutput;
|
||||||
|
import tech.easyflow.agent.runtime.output.LegacyAgentRunOutput;
|
||||||
|
import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeProjector;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
@@ -34,6 +41,7 @@ import tech.easyflow.core.chat.protocol.ChatType;
|
|||||||
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
|
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
|
||||||
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
|
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
|
||||||
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
||||||
|
import tech.easyflow.core.runtime.ChatRuntimeExtKeys;
|
||||||
import tech.easyflow.core.runtime.ChatRuntimeManager;
|
import tech.easyflow.core.runtime.ChatRuntimeManager;
|
||||||
import tech.easyflow.core.runtime.ChatRuntimeMessage;
|
import tech.easyflow.core.runtime.ChatRuntimeMessage;
|
||||||
import tech.easyflow.core.runtime.LegacyThinkingTagParser;
|
import tech.easyflow.core.runtime.LegacyThinkingTagParser;
|
||||||
@@ -64,9 +72,15 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
event.getPayload().put("agentId", "agent-1");
|
event.getPayload().put("agentId", "agent-1");
|
||||||
event.getPayload().put("toolName", "search");
|
event.getPayload().put("toolName", "search");
|
||||||
event.getPayload().put("toolType", "PLUGIN");
|
event.getPayload().put("toolType", "PLUGIN");
|
||||||
|
event.getMetadata().put("approvalId", "approval-1");
|
||||||
event.getPayload().put("approvalPrompt", "不应透出");
|
event.getPayload().put("approvalPrompt", "不应透出");
|
||||||
event.getPayload().put("toolInput", Map.of("keyword", "EasyFlow"));
|
event.getPayload().put("toolInput", Map.of(
|
||||||
|
"authorization", "sentinel-secret-authorization",
|
||||||
|
"callbackUrl", "https://example.test/callback?token=sentinel-secret-query",
|
||||||
|
"keyword", "EasyFlow",
|
||||||
|
"nested", Map.of("apiKey", "sentinel-secret-api-key")));
|
||||||
event.getPayload().put("approvalMetadata", Map.of(
|
event.getPayload().put("approvalMetadata", Map.of(
|
||||||
|
"credential", "sentinel-secret-metadata",
|
||||||
"risk", "low",
|
"risk", "low",
|
||||||
"prompt", "不应透出",
|
"prompt", "不应透出",
|
||||||
"toolType", "WORKFLOW"
|
"toolType", "WORKFLOW"
|
||||||
@@ -76,16 +90,21 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
new Class<?>[]{String.class, AgentRuntimeEvent.class}, "request-1", event);
|
new Class<?>[]{String.class, AgentRuntimeEvent.class}, "request-1", event);
|
||||||
|
|
||||||
Assert.assertEquals("request-1", payload.getRequestId());
|
Assert.assertEquals("request-1", payload.getRequestId());
|
||||||
Assert.assertEquals("token-1", payload.getResumeToken());
|
Assert.assertEquals("approval-1", payload.getApprovalId());
|
||||||
Assert.assertEquals("session-1", payload.getSessionId());
|
Assert.assertEquals("session-1", payload.getSessionId());
|
||||||
Assert.assertEquals("agent-1", payload.getAgentId());
|
Assert.assertEquals("agent-1", payload.getAgentId());
|
||||||
Assert.assertEquals("call-1", payload.getToolCallId());
|
Assert.assertEquals("call-1", payload.getToolCallId());
|
||||||
Assert.assertEquals("search", payload.getToolName());
|
Assert.assertEquals("search", payload.getToolName());
|
||||||
Assert.assertEquals("PLUGIN", payload.getToolType());
|
Assert.assertEquals("PLUGIN", payload.getToolType());
|
||||||
Assert.assertEquals("EasyFlow", payload.getInput().get("keyword"));
|
Assert.assertEquals("EasyFlow", payload.getInput().get("keyword"));
|
||||||
|
Assert.assertEquals("[已隐藏]", payload.getInput().get("authorization"));
|
||||||
|
Assert.assertEquals("https://example.test/callback?token=[已隐藏]",
|
||||||
|
payload.getInput().get("callbackUrl"));
|
||||||
|
Assert.assertFalse(payload.getInput().toString().contains("sentinel-secret"));
|
||||||
Assert.assertEquals("low", payload.getMetadata().get("risk"));
|
Assert.assertEquals("low", payload.getMetadata().get("risk"));
|
||||||
Assert.assertEquals("PLUGIN", payload.getMetadata().get("toolType"));
|
Assert.assertEquals("PLUGIN", payload.getMetadata().get("toolType"));
|
||||||
Assert.assertFalse(payload.getMetadata().containsKey("prompt"));
|
Assert.assertFalse(payload.getMetadata().containsKey("prompt"));
|
||||||
|
Assert.assertFalse(payload.getMetadata().toString().contains("sentinel-secret"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,14 +118,18 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_RESULT);
|
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_RESULT);
|
||||||
event.setToolCallId("call-runtime");
|
event.setToolCallId("call-runtime");
|
||||||
event.getPayload().put("toolName", "search");
|
event.getPayload().put("toolName", "search");
|
||||||
event.getPayload().put("text", "ok");
|
event.getPayload().put("text", "sentinel-secret-result");
|
||||||
|
event.getPayload().put("result", Map.of("token", "sentinel-secret-token"));
|
||||||
|
event.getMetadata().put("authorization", "sentinel-secret-metadata");
|
||||||
|
|
||||||
Map<String, Object> payload = invoke(service, "buildToolEventPayload",
|
Map<String, Object> payload = invoke(service, "buildToolEventPayload",
|
||||||
new Class<?>[]{AgentRuntimeEvent.class}, event);
|
new Class<?>[]{AgentRuntimeEvent.class}, event);
|
||||||
|
|
||||||
Assert.assertEquals("call-runtime", payload.get("toolCallId"));
|
Assert.assertEquals("call-runtime", payload.get("toolCallId"));
|
||||||
Assert.assertEquals("search", payload.get("toolName"));
|
Assert.assertEquals("search", payload.get("toolName"));
|
||||||
Assert.assertEquals("ok", payload.get("text"));
|
Assert.assertFalse(payload.containsKey("text"));
|
||||||
|
Assert.assertFalse(payload.containsKey("result"));
|
||||||
|
Assert.assertFalse(payload.toString().contains("sentinel-secret"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,7 +146,7 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
String reasoning = invoke(service, "stringPayload",
|
String reasoning = invoke(service, "stringPayload",
|
||||||
new Class<?>[]{AgentRuntimeEvent.class, String.class}, event, "reasoning");
|
new Class<?>[]{AgentRuntimeEvent.class, String.class}, event, "reasoning");
|
||||||
String fallback = invoke(service, "firstText",
|
String fallback = invoke(service, "firstText",
|
||||||
new Class<?>[]{String.class, String.class}, reasoning, "正文");
|
new Class<?>[]{String[].class}, (Object) new String[]{reasoning, "正文"});
|
||||||
|
|
||||||
Assert.assertEquals("思考中", fallback);
|
Assert.assertEquals("思考中", fallback);
|
||||||
}
|
}
|
||||||
@@ -142,7 +165,7 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
|
|
||||||
invoke(service, "handleRuntimeEvent",
|
invoke(service, "handleRuntimeEvent",
|
||||||
runtimeEventParameterTypes(),
|
runtimeEventParameterTypes(),
|
||||||
event, "request-1", emitter, new StringBuilder(), new ChatAssistantAccumulator(),
|
event, "request-1", legacyOutput(emitter), new StringBuilder(), new ChatAssistantAccumulator(),
|
||||||
chatContext(), new AtomicBoolean(false), false);
|
chatContext(), new AtomicBoolean(false), false);
|
||||||
|
|
||||||
Assert.assertEquals(1, emitter.envelopes.size());
|
Assert.assertEquals(1, emitter.envelopes.size());
|
||||||
@@ -169,7 +192,7 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
|
|
||||||
invoke(service, "handleRuntimeEvent",
|
invoke(service, "handleRuntimeEvent",
|
||||||
runtimeEventParameterTypes(),
|
runtimeEventParameterTypes(),
|
||||||
event, "request-1", emitter, answer, new ChatAssistantAccumulator(),
|
event, "request-1", legacyOutput(emitter), answer, new ChatAssistantAccumulator(),
|
||||||
chatContext(), new AtomicBoolean(false), false);
|
chatContext(), new AtomicBoolean(false), false);
|
||||||
|
|
||||||
Assert.assertEquals("正文增量", answer.toString());
|
Assert.assertEquals("正文增量", answer.toString());
|
||||||
@@ -201,7 +224,7 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
event.getPayload().put("text", delta);
|
event.getPayload().put("text", delta);
|
||||||
invoke(service, "handleRuntimeEvent",
|
invoke(service, "handleRuntimeEvent",
|
||||||
legacyRuntimeEventParameterTypes(),
|
legacyRuntimeEventParameterTypes(),
|
||||||
event, "request-legacy-thinking", emitter, answer, assistantAccumulator,
|
event, "request-legacy-thinking", legacyOutput(emitter), answer, assistantAccumulator,
|
||||||
parser, chatContext(), finished, false);
|
parser, chatContext(), finished, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +232,7 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
completed.getPayload().put("text", "<think>先分析</think>\n最终回答");
|
completed.getPayload().put("text", "<think>先分析</think>\n最终回答");
|
||||||
invoke(service, "handleRuntimeEvent",
|
invoke(service, "handleRuntimeEvent",
|
||||||
legacyRuntimeEventParameterTypes(),
|
legacyRuntimeEventParameterTypes(),
|
||||||
completed, "request-legacy-thinking", emitter, answer, assistantAccumulator,
|
completed, "request-legacy-thinking", legacyOutput(emitter), answer, assistantAccumulator,
|
||||||
parser, chatContext(), finished, false);
|
parser, chatContext(), finished, false);
|
||||||
|
|
||||||
StringBuilder reasoning = new StringBuilder();
|
StringBuilder reasoning = new StringBuilder();
|
||||||
@@ -256,7 +279,7 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
|
|
||||||
invoke(service, "handleRuntimeEvent",
|
invoke(service, "handleRuntimeEvent",
|
||||||
runtimeEventParameterTypes(),
|
runtimeEventParameterTypes(),
|
||||||
event, "request-1", emitter, new StringBuilder(), new ChatAssistantAccumulator(),
|
event, "request-1", legacyOutput(emitter), new StringBuilder(), new ChatAssistantAccumulator(),
|
||||||
chatContext(), new AtomicBoolean(false), false);
|
chatContext(), new AtomicBoolean(false), false);
|
||||||
|
|
||||||
Assert.assertEquals(1, emitter.envelopes.size());
|
Assert.assertEquals(1, emitter.envelopes.size());
|
||||||
@@ -269,6 +292,33 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
Assert.assertEquals("正在整理上下文", payload.get("label"));
|
Assert.assertEquals("正在整理上下文", payload.get("label"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证知识检索状态不会携带命中文档和内部 metadata。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void handleRuntimeEventShouldWhitelistKnowledgeStatusPayload() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
|
||||||
|
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL);
|
||||||
|
event.getPayload().put("documents", List.of(Map.of("chunkContent", "private chunk")));
|
||||||
|
event.getPayload().put("metadata", Map.of("sourceUri", "private://document"));
|
||||||
|
|
||||||
|
invoke(service, "handleRuntimeEvent",
|
||||||
|
runtimeEventParameterTypes(),
|
||||||
|
event, "request-knowledge", legacyOutput(emitter), new StringBuilder(),
|
||||||
|
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);
|
||||||
|
|
||||||
|
Assert.assertEquals(1, emitter.envelopes.size());
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
|
||||||
|
Assert.assertEquals(Map.of(
|
||||||
|
"label", "已检索知识库",
|
||||||
|
"status", "done",
|
||||||
|
"statusKey", "knowledge-retrieval"), payload);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证完成事件不会再次发送正文消息,只用于最终收口。
|
* 验证完成事件不会再次发送正文消息,只用于最终收口。
|
||||||
*
|
*
|
||||||
@@ -285,7 +335,7 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
|
|
||||||
invoke(service, "handleRuntimeEvent",
|
invoke(service, "handleRuntimeEvent",
|
||||||
runtimeEventParameterTypes(),
|
runtimeEventParameterTypes(),
|
||||||
event, "request-1", emitter, answer, new ChatAssistantAccumulator(),
|
event, "request-1", legacyOutput(emitter), answer, new ChatAssistantAccumulator(),
|
||||||
chatContext(), new AtomicBoolean(false), false);
|
chatContext(), new AtomicBoolean(false), false);
|
||||||
|
|
||||||
Assert.assertEquals("最终正文", answer.toString());
|
Assert.assertEquals("最终正文", answer.toString());
|
||||||
@@ -311,7 +361,7 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
"request-suspended",
|
"request-suspended",
|
||||||
"session-suspended",
|
"session-suspended",
|
||||||
new NoopRuntime(),
|
new NoopRuntime(),
|
||||||
emitter,
|
legacyOutput(emitter),
|
||||||
chatContext(),
|
chatContext(),
|
||||||
new StringBuilder(),
|
new StringBuilder(),
|
||||||
new ChatAssistantAccumulator(),
|
new ChatAssistantAccumulator(),
|
||||||
@@ -330,9 +380,9 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
runContext.markSuspended();
|
runContext.markSuspended();
|
||||||
|
|
||||||
invoke(service, "finishIfNeeded",
|
invoke(service, "finishIfNeeded",
|
||||||
new Class<?>[]{String.class, ChatSseEmitter.class, ChatRuntimeContext.class, StringBuilder.class,
|
new Class<?>[]{String.class, AgentRunOutput.class, ChatRuntimeContext.class, StringBuilder.class,
|
||||||
ChatAssistantAccumulator.class, AtomicBoolean.class, boolean.class},
|
ChatAssistantAccumulator.class, AtomicBoolean.class, boolean.class},
|
||||||
"request-suspended", emitter, chatContext(), new StringBuilder(),
|
"request-suspended", legacyOutput(emitter), chatContext(), new StringBuilder(),
|
||||||
new ChatAssistantAccumulator(), finished, false);
|
new ChatAssistantAccumulator(), finished, false);
|
||||||
|
|
||||||
Assert.assertFalse(finished.get());
|
Assert.assertFalse(finished.get());
|
||||||
@@ -340,6 +390,34 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
Assert.assertTrue(emitter.envelopes.isEmpty());
|
Assert.assertTrue(emitter.envelopes.isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证要求显式终态的协议遇到自然 EOF 时记录失败,避免数据库完成态与 UI 错误态矛盾。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void finishIfNeededShouldRecordFailureWhenProtocolTerminalIsMissing() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
RecordingChatRuntimeManager chatRuntimeManager = new RecordingChatRuntimeManager();
|
||||||
|
setField(service, "agentRunRegistry", new AgentRunRegistry());
|
||||||
|
setField(service, "chatRuntimeManager", chatRuntimeManager);
|
||||||
|
AgentRunOutput output = Mockito.mock(AgentRunOutput.class);
|
||||||
|
Mockito.when(output.canFinishSuccessfully()).thenReturn(false);
|
||||||
|
Mockito.when(output.emitViewEvent(Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(true);
|
||||||
|
AtomicBoolean finished = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
invoke(service, "finishIfNeeded",
|
||||||
|
new Class<?>[]{String.class, AgentRunOutput.class, ChatRuntimeContext.class, StringBuilder.class,
|
||||||
|
ChatAssistantAccumulator.class, AtomicBoolean.class, boolean.class},
|
||||||
|
"request-missing-terminal", output, chatContext(), new StringBuilder(),
|
||||||
|
new ChatAssistantAccumulator(), finished, true);
|
||||||
|
|
||||||
|
Assert.assertTrue(finished.get());
|
||||||
|
Assert.assertEquals(1, chatRuntimeManager.recordFailureCount);
|
||||||
|
Assert.assertEquals(0, chatRuntimeManager.recordCompletedCount);
|
||||||
|
Mockito.verify(output).complete();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证取消事件作为业务状态收口,不按系统错误发送。
|
* 验证取消事件作为业务状态收口,不按系统错误发送。
|
||||||
*
|
*
|
||||||
@@ -358,7 +436,7 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
|
|
||||||
invoke(service, "handleRuntimeEvent",
|
invoke(service, "handleRuntimeEvent",
|
||||||
runtimeEventParameterTypes(),
|
runtimeEventParameterTypes(),
|
||||||
event, "request-1", emitter, answer, new ChatAssistantAccumulator(),
|
event, "request-1", legacyOutput(emitter), answer, new ChatAssistantAccumulator(),
|
||||||
chatContext(), new AtomicBoolean(false), true);
|
chatContext(), new AtomicBoolean(false), true);
|
||||||
|
|
||||||
Assert.assertEquals(2, emitter.envelopes.size());
|
Assert.assertEquals(2, emitter.envelopes.size());
|
||||||
@@ -373,6 +451,8 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
Assert.assertEquals(ChatType.DONE, emitter.envelopes.get(1).getType());
|
Assert.assertEquals(ChatType.DONE, emitter.envelopes.get(1).getType());
|
||||||
Assert.assertEquals(1, chatRuntimeManager.recordAssistantCompletedCount);
|
Assert.assertEquals(1, chatRuntimeManager.recordAssistantCompletedCount);
|
||||||
Assert.assertEquals("取消前正文", chatRuntimeManager.lastAssistantMessage.getContentText());
|
Assert.assertEquals("取消前正文", chatRuntimeManager.lastAssistantMessage.getContentText());
|
||||||
|
Assert.assertEquals("CANCELLED",
|
||||||
|
chatRuntimeManager.lastAssistantMessage.getContentPayload().get("terminalStatus"));
|
||||||
Assert.assertEquals(1, chatRuntimeManager.recordFailureCount);
|
Assert.assertEquals(1, chatRuntimeManager.recordFailureCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,8 +503,8 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
"previewUrl", "/api/v1/agent/media/content?reference=formal:101:201:0:png"));
|
"previewUrl", "/api/v1/agent/media/content?reference=formal:101:201:0:png"));
|
||||||
|
|
||||||
boolean sent = invoke(service, "sendInputAccepted",
|
boolean sent = invoke(service, "sendInputAccepted",
|
||||||
new Class<?>[]{ChatSseEmitter.class, BigInteger.class, BigInteger.class, List.class, List.class},
|
new Class<?>[]{AgentRunOutput.class, BigInteger.class, BigInteger.class, List.class, List.class},
|
||||||
emitter, BigInteger.valueOf(101), BigInteger.valueOf(201), List.of(image), List.of());
|
legacyOutput(emitter), BigInteger.valueOf(101), BigInteger.valueOf(201), List.of(image), List.of());
|
||||||
|
|
||||||
Assert.assertTrue(sent);
|
Assert.assertTrue(sent);
|
||||||
Assert.assertEquals(1, emitter.envelopes.size());
|
Assert.assertEquals(1, emitter.envelopes.size());
|
||||||
@@ -444,6 +524,10 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
@Test
|
@Test
|
||||||
public void buildDraftAgentShouldGenerateRuntimeIdForUnsavedAgent() throws Exception {
|
public void buildDraftAgentShouldGenerateRuntimeIdForUnsavedAgent() throws Exception {
|
||||||
AgentRunService service = new AgentRunService();
|
AgentRunService service = new AgentRunService();
|
||||||
|
AgentSkillRuntimeProjector projector = Mockito.mock(AgentSkillRuntimeProjector.class);
|
||||||
|
Mockito.when(projector.projectCurrentBindings(Mockito.any(), Mockito.anyList()))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
setField(service, "agentSkillRuntimeProjector", projector);
|
||||||
AgentDraftChatRequest request = new AgentDraftChatRequest();
|
AgentDraftChatRequest request = new AgentDraftChatRequest();
|
||||||
Agent agent = new Agent();
|
Agent agent = new Agent();
|
||||||
agent.setModelId(BigInteger.valueOf(10));
|
agent.setModelId(BigInteger.valueOf(10));
|
||||||
@@ -543,11 +627,11 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
invoke(service, "startRuntime",
|
invoke(service, "startRuntime",
|
||||||
new Class<?>[]{Agent.class, AgentMessage.class, AgentDocumentContext.class, LoginAccount.class,
|
new Class<?>[]{Agent.class, AgentMessage.class, AgentDocumentContext.class, LoginAccount.class,
|
||||||
String.class, String.class,
|
String.class, String.class,
|
||||||
String.class, String.class, ChatRuntimeContext.class, ChatSseEmitter.class, boolean.class,
|
String.class, String.class, ChatRuntimeContext.class, AgentRunOutput.class, boolean.class,
|
||||||
AgentSessionStore.class, AgentRunLock.Handle.class},
|
AgentSessionStore.class, AgentRunLock.Handle.class},
|
||||||
agent, AgentMessage.text(AgentMessageRole.USER, "你好"), AgentDocumentContext.empty(), account,
|
agent, AgentMessage.text(AgentMessageRole.USER, "你好"), AgentDocumentContext.empty(), account,
|
||||||
"request-draft", "trace-draft", "agent-draft-100", "AGENT_DRAFT",
|
"request-draft", "trace-draft", "agent-draft-100", "AGENT_DRAFT",
|
||||||
chatContext(), new RecordingChatSseEmitter(), false, draftStore, null);
|
chatContext(), legacyOutput(new RecordingChatSseEmitter()), false, draftStore, null);
|
||||||
|
|
||||||
Assert.assertSame(draftStore, runtime.initRequest.getSessionStore());
|
Assert.assertSame(draftStore, runtime.initRequest.getSessionStore());
|
||||||
}
|
}
|
||||||
@@ -568,19 +652,107 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
|
|
||||||
invoke(service, "handleRuntimeEvent",
|
invoke(service, "handleRuntimeEvent",
|
||||||
runtimeEventParameterTypes(),
|
runtimeEventParameterTypes(),
|
||||||
draftEvent, "request-draft", new RecordingChatSseEmitter(), new StringBuilder(),
|
draftEvent, "request-draft", legacyOutput(new RecordingChatSseEmitter()), new StringBuilder(),
|
||||||
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);
|
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);
|
||||||
|
|
||||||
Assert.assertEquals(0, recorder.recordCount);
|
Assert.assertEquals(0, recorder.recordCount);
|
||||||
|
|
||||||
AgentRuntimeEvent formalEvent = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_CALL);
|
AgentRuntimeEvent formalEvent = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_CALL);
|
||||||
formalEvent.getPayload().put("toolName", "search");
|
formalEvent.getPayload().put("toolName", "search");
|
||||||
|
formalEvent.getPayload().put("input", Map.of("apiKey", "sentinel-secret-input"));
|
||||||
|
formalEvent.getMetadata().put("authorization", "sentinel-secret-metadata");
|
||||||
|
RecordingChatSseEmitter formalEmitter = new RecordingChatSseEmitter();
|
||||||
|
ChatAssistantAccumulator formalAccumulator = new ChatAssistantAccumulator();
|
||||||
|
ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger)
|
||||||
|
LoggerFactory.getLogger(AgentRunService.class);
|
||||||
|
ListAppender<ILoggingEvent> logAppender = new ListAppender<>();
|
||||||
|
logAppender.start();
|
||||||
|
logger.addAppender(logAppender);
|
||||||
|
try {
|
||||||
invoke(service, "handleRuntimeEvent",
|
invoke(service, "handleRuntimeEvent",
|
||||||
runtimeEventParameterTypes(),
|
runtimeEventParameterTypes(),
|
||||||
formalEvent, "request-formal", new RecordingChatSseEmitter(), new StringBuilder(),
|
formalEvent, "request-formal", legacyOutput(formalEmitter), new StringBuilder(),
|
||||||
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), true);
|
formalAccumulator, chatContext(), new AtomicBoolean(false), true);
|
||||||
|
} finally {
|
||||||
|
logger.detachAppender(logAppender);
|
||||||
|
logAppender.stop();
|
||||||
|
}
|
||||||
|
|
||||||
Assert.assertEquals(1, recorder.recordCount);
|
Assert.assertEquals(1, recorder.recordCount);
|
||||||
|
Assert.assertNotNull(recorder.lastEvent);
|
||||||
|
Assert.assertFalse(recorder.lastEvent.getPayload().toString().contains("sentinel-secret"));
|
||||||
|
Assert.assertFalse(recorder.lastEvent.getMetadata().toString().contains("sentinel-secret"));
|
||||||
|
Assert.assertFalse(formalEmitter.envelopes.stream()
|
||||||
|
.map(ChatEnvelope::getPayload)
|
||||||
|
.map(String::valueOf)
|
||||||
|
.anyMatch(payload -> payload.contains("sentinel-secret")));
|
||||||
|
Assert.assertFalse(formalAccumulator.buildPayload("").toString().contains("sentinel-secret"));
|
||||||
|
Assert.assertFalse(logAppender.list.stream()
|
||||||
|
.map(ILoggingEvent::getFormattedMessage)
|
||||||
|
.anyMatch(message -> message.contains("sentinel-secret")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 artifact_publish 内部投影会形成安全实时状态并进入 assistant 历史 payload。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void handleRuntimeEventShouldProjectArtifactToLiveAndHistory() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
setField(service, "agentRunRegistry", new AgentRunRegistry());
|
||||||
|
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
|
||||||
|
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
|
||||||
|
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_RESULT);
|
||||||
|
event.getPayload().put("artifactProjectionOnly", true);
|
||||||
|
event.getPayload().put("artifactPublished", Map.of(
|
||||||
|
"schemaVersion", 1,
|
||||||
|
"artifactId", "a1",
|
||||||
|
"fileName", "report.csv",
|
||||||
|
"mimeType", "text/csv",
|
||||||
|
"size", 12,
|
||||||
|
"sha256", "abc",
|
||||||
|
"downloadUrl", "/api/v1/agent/artifacts/a1/content",
|
||||||
|
"status", "AVAILABLE",
|
||||||
|
"objectKey", "private/object/key"));
|
||||||
|
|
||||||
|
invoke(service, "handleRuntimeEvent", runtimeEventParameterTypes(),
|
||||||
|
event, "request-artifact", legacyOutput(emitter), new StringBuilder(), accumulator,
|
||||||
|
chatContext(), new AtomicBoolean(false), false);
|
||||||
|
|
||||||
|
Assert.assertEquals(1, emitter.envelopes.size());
|
||||||
|
Assert.assertEquals(ChatType.STATUS, emitter.envelopes.get(0).getType());
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> live = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
|
||||||
|
Assert.assertEquals("artifact-published", live.get("statusKey"));
|
||||||
|
Assert.assertFalse(live.containsKey("objectKey"));
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> artifacts = (List<Map<String, Object>>) accumulator
|
||||||
|
.buildPayload("done").get("artifacts");
|
||||||
|
Assert.assertEquals("a1", artifacts.get(0).get("artifactId"));
|
||||||
|
Assert.assertFalse(artifacts.get(0).containsKey("objectKey"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证当前轮次 ID 由服务端聊天上下文注入 RuntimeContext,供产物账本可信绑定。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void buildAgentRuntimeContextShouldCarryTrustedRoundId() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
ChatRuntimeContext context = chatContext();
|
||||||
|
context.getExt().put(ChatRuntimeExtKeys.CURRENT_ROUND_ID, BigInteger.valueOf(200));
|
||||||
|
context.getExt().put(ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX, 2);
|
||||||
|
|
||||||
|
AgentRuntimeContext runtimeContext = invoke(service, "buildAgentRuntimeContext",
|
||||||
|
new Class<?>[]{ChatRuntimeContext.class, String.class, String.class},
|
||||||
|
context, "trace-1", "100");
|
||||||
|
|
||||||
|
Assert.assertEquals("200",
|
||||||
|
runtimeContext.getMetadata().get(ChatRuntimeExtKeys.CURRENT_ROUND_ID));
|
||||||
|
Assert.assertEquals(2,
|
||||||
|
runtimeContext.getMetadata().get(ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -648,14 +820,64 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
registry.register(runContext("request-draft", "agent-draft-tool", false));
|
registry.register(runContext("request-draft", "agent-draft-tool", false));
|
||||||
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED);
|
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED);
|
||||||
event.getPayload().put("resumeToken", "token-draft");
|
event.getPayload().put("resumeToken", "token-draft");
|
||||||
|
event.getPayload().put("toolName", "search");
|
||||||
|
event.getPayload().put("toolInput", Map.of(
|
||||||
|
"authorization", "sentinel-secret-authorization",
|
||||||
|
"keyword", "EasyFlow"));
|
||||||
|
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
|
||||||
|
|
||||||
invoke(service, "handleRuntimeEvent",
|
invoke(service, "handleRuntimeEvent",
|
||||||
runtimeEventParameterTypes(),
|
runtimeEventParameterTypes(),
|
||||||
event, "request-draft", new RecordingChatSseEmitter(), new StringBuilder(),
|
event, "request-draft", legacyOutput(emitter), new StringBuilder(),
|
||||||
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);
|
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);
|
||||||
|
|
||||||
Assert.assertTrue(registry.containsResumeTarget("request-draft", "token-draft"));
|
Assert.assertTrue(registry.containsResumeTarget("request-draft", "token-draft"));
|
||||||
Assert.assertEquals(0, pendingService.recordApprovalRequiredCount);
|
Assert.assertEquals(0, pendingService.recordApprovalRequiredCount);
|
||||||
|
Assert.assertEquals(1, emitter.envelopes.size());
|
||||||
|
AgentToolHitlPayload payload = (AgentToolHitlPayload) emitter.envelopes.get(0).getPayload();
|
||||||
|
Assert.assertNotNull(payload.getApprovalId());
|
||||||
|
Assert.assertFalse(payload.getApprovalId().isBlank());
|
||||||
|
Assert.assertEquals("EasyFlow", payload.getInput().get("keyword"));
|
||||||
|
Assert.assertEquals("[已隐藏]", payload.getInput().get("authorization"));
|
||||||
|
Assert.assertFalse(payload.getInput().toString().contains("sentinel-secret"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证正式工具审批事件在生成公开审批 ID 后才持久化,并移除内部恢复令牌与敏感参数。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void formalToolApprovalShouldPersistPublicApprovalIdOnly() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
AgentRunRegistry registry = new AgentRunRegistry();
|
||||||
|
RecordingAgentRunEventRecorder recorder = new RecordingAgentRunEventRecorder();
|
||||||
|
RecordingAgentHitlPendingService pendingService = new RecordingAgentHitlPendingService();
|
||||||
|
setField(service, "agentRunRegistry", registry);
|
||||||
|
setField(service, "agentRunEventRecorder", recorder);
|
||||||
|
setField(service, "agentHitlPendingService", pendingService);
|
||||||
|
registry.register(runContext("request-formal-tool", "session-formal-tool", true));
|
||||||
|
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED);
|
||||||
|
event.getPayload().put("resumeToken", "sentinel-secret-resume-token");
|
||||||
|
event.getPayload().put("toolName", "search");
|
||||||
|
event.getPayload().put("toolInput", Map.of(
|
||||||
|
"password", "sentinel-secret-password",
|
||||||
|
"keyword", "EasyFlow"));
|
||||||
|
|
||||||
|
invoke(service, "handleRuntimeEvent",
|
||||||
|
runtimeEventParameterTypes(),
|
||||||
|
event, "request-formal-tool", legacyOutput(new RecordingChatSseEmitter()), new StringBuilder(),
|
||||||
|
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), true);
|
||||||
|
|
||||||
|
Assert.assertEquals(1, recorder.recordCount);
|
||||||
|
Assert.assertNotNull(recorder.lastEvent);
|
||||||
|
Assert.assertNotNull(recorder.lastEvent.getMetadata().get("approvalId"));
|
||||||
|
Assert.assertFalse(recorder.lastEvent.getMetadata().get("approvalId").toString().isBlank());
|
||||||
|
Assert.assertFalse(recorder.lastEvent.getPayload().containsKey("resumeToken"));
|
||||||
|
Assert.assertEquals("EasyFlow",
|
||||||
|
((Map<?, ?>) recorder.lastEvent.getPayload().get("toolInput")).get("keyword"));
|
||||||
|
Assert.assertFalse(recorder.lastEvent.getPayload().toString().contains("sentinel-secret"));
|
||||||
|
Assert.assertEquals(1, pendingService.recordApprovalRequiredCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -768,6 +990,50 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
Assert.assertEquals("request-remote-expire", commandProducer.lastRequestId);
|
Assert.assertEquals("request-remote-expire", commandProducer.lastRequestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证本机审批过期会先发送决议事件,再恢复 runtime 的拒绝分支并清理公开审批 ID。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void expireApprovalLocalShouldResolveCardAndRejectRuntime() {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
AgentRunRegistry registry = new AgentRunRegistry();
|
||||||
|
RecordingAgentRuntime runtime = new RecordingAgentRuntime();
|
||||||
|
AgentRunOutput output = Mockito.mock(AgentRunOutput.class);
|
||||||
|
Mockito.when(output.emitViewEvent(Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(true);
|
||||||
|
setFieldUnchecked(service, "agentRunRegistry", registry);
|
||||||
|
registry.register(new AgentRunRegistry.AgentRunContext(
|
||||||
|
"request-local-expire",
|
||||||
|
"session-local-expire",
|
||||||
|
runtime,
|
||||||
|
output,
|
||||||
|
chatContext(),
|
||||||
|
new StringBuilder(),
|
||||||
|
new ChatAssistantAccumulator(),
|
||||||
|
new AtomicBoolean(false),
|
||||||
|
false,
|
||||||
|
new AgentRunRegistry.RunOwner("agent-1", "session-local-expire", "1"),
|
||||||
|
null,
|
||||||
|
event -> {
|
||||||
|
},
|
||||||
|
error -> {
|
||||||
|
},
|
||||||
|
() -> {
|
||||||
|
}));
|
||||||
|
registry.registerResumeToken("request-local-expire", "token-local-expire");
|
||||||
|
String approvalId = registry.registerApproval("request-local-expire", "token-local-expire");
|
||||||
|
|
||||||
|
service.expireApprovalLocal("request-local-expire", "token-local-expire", "审批已过期");
|
||||||
|
|
||||||
|
Assert.assertEquals(1, runtime.resumeCount);
|
||||||
|
Assert.assertNull(registry.findApprovalId("request-local-expire", "token-local-expire"));
|
||||||
|
Mockito.verify(output).emitViewEvent(
|
||||||
|
Mockito.eq(ChatDomain.TOOL),
|
||||||
|
Mockito.eq(ChatType.FORM_CANCEL),
|
||||||
|
Mockito.argThat(payload -> payload instanceof Map<?, ?> map
|
||||||
|
&& approvalId.equals(map.get("approvalId"))
|
||||||
|
&& "EXPIRED".equals(map.get("status"))));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证 owner 缺失时明确失败。
|
* 验证 owner 缺失时明确失败。
|
||||||
*
|
*
|
||||||
@@ -861,7 +1127,8 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
setField(service, "draftAgentSessionStore", draftStore);
|
setField(service, "draftAgentSessionStore", draftStore);
|
||||||
|
|
||||||
invoke(service, "clearDraftSessionInternal",
|
invoke(service, "clearDraftSessionInternal",
|
||||||
new Class<?>[]{String.class, String.class}, "agent-draft-clear", "1");
|
new Class<?>[]{String.class, String.class, String.class},
|
||||||
|
"agent-draft-clear", "1", "1");
|
||||||
|
|
||||||
Assert.assertEquals("agent-draft-clear", draftStore.deletedSessionKey);
|
Assert.assertEquals("agent-draft-clear", draftStore.deletedSessionKey);
|
||||||
Assert.assertEquals(0, pendingService.deleteByRuntimeSessionIdCount);
|
Assert.assertEquals(0, pendingService.deleteByRuntimeSessionIdCount);
|
||||||
@@ -878,7 +1145,8 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
|
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
|
||||||
|
|
||||||
Boolean sent = invoke(service, "sendSessionCreated",
|
Boolean sent = invoke(service, "sendSessionCreated",
|
||||||
new Class<?>[]{ChatSseEmitter.class, BigInteger.class}, emitter, BigInteger.valueOf(123));
|
new Class<?>[]{AgentRunOutput.class, BigInteger.class},
|
||||||
|
legacyOutput(emitter), BigInteger.valueOf(123));
|
||||||
|
|
||||||
Assert.assertTrue(sent);
|
Assert.assertTrue(sent);
|
||||||
Assert.assertEquals(1, emitter.envelopes.size());
|
Assert.assertEquals(1, emitter.envelopes.size());
|
||||||
@@ -934,7 +1202,7 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
"request-disconnected",
|
"request-disconnected",
|
||||||
"session-disconnected",
|
"session-disconnected",
|
||||||
new NoopRuntime(),
|
new NoopRuntime(),
|
||||||
new FailingChatSseEmitter(),
|
legacyOutput(new FailingChatSseEmitter()),
|
||||||
context,
|
context,
|
||||||
new StringBuilder(),
|
new StringBuilder(),
|
||||||
new ChatAssistantAccumulator(),
|
new ChatAssistantAccumulator(),
|
||||||
@@ -957,7 +1225,7 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
|
|
||||||
invoke(service, "handleRuntimeEvent",
|
invoke(service, "handleRuntimeEvent",
|
||||||
runtimeEventParameterTypes(),
|
runtimeEventParameterTypes(),
|
||||||
event, "request-disconnected", new FailingChatSseEmitter(), answer,
|
event, "request-disconnected", legacyOutput(new FailingChatSseEmitter()), answer,
|
||||||
assistantAccumulator, context, finished, true);
|
assistantAccumulator, context, finished, true);
|
||||||
|
|
||||||
Assert.assertTrue(finished.get());
|
Assert.assertTrue(finished.get());
|
||||||
@@ -981,14 +1249,22 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
field.set(target, value);
|
field.set(target, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void setFieldUnchecked(Object target, String fieldName, Object value) {
|
||||||
|
try {
|
||||||
|
setField(target, fieldName, value);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new AssertionError(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Class<?>[] runtimeEventParameterTypes() {
|
private Class<?>[] runtimeEventParameterTypes() {
|
||||||
return new Class<?>[]{AgentRuntimeEvent.class, String.class, ChatSseEmitter.class, StringBuilder.class,
|
return new Class<?>[]{AgentRuntimeEvent.class, String.class, AgentRunOutput.class, StringBuilder.class,
|
||||||
ChatAssistantAccumulator.class,
|
ChatAssistantAccumulator.class,
|
||||||
ChatRuntimeContext.class, AtomicBoolean.class, boolean.class};
|
ChatRuntimeContext.class, AtomicBoolean.class, boolean.class};
|
||||||
}
|
}
|
||||||
|
|
||||||
private Class<?>[] legacyRuntimeEventParameterTypes() {
|
private Class<?>[] legacyRuntimeEventParameterTypes() {
|
||||||
return new Class<?>[]{AgentRuntimeEvent.class, String.class, ChatSseEmitter.class, StringBuilder.class,
|
return new Class<?>[]{AgentRuntimeEvent.class, String.class, AgentRunOutput.class, StringBuilder.class,
|
||||||
ChatAssistantAccumulator.class, LegacyThinkingTagParser.class,
|
ChatAssistantAccumulator.class, LegacyThinkingTagParser.class,
|
||||||
ChatRuntimeContext.class, AtomicBoolean.class, boolean.class};
|
ChatRuntimeContext.class, AtomicBoolean.class, boolean.class};
|
||||||
}
|
}
|
||||||
@@ -998,7 +1274,7 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
requestId,
|
requestId,
|
||||||
sessionId,
|
sessionId,
|
||||||
new RecordingAgentRuntime(),
|
new RecordingAgentRuntime(),
|
||||||
new RecordingChatSseEmitter(),
|
legacyOutput(new RecordingChatSseEmitter()),
|
||||||
chatContext(),
|
chatContext(),
|
||||||
new StringBuilder(),
|
new StringBuilder(),
|
||||||
new ChatAssistantAccumulator(),
|
new ChatAssistantAccumulator(),
|
||||||
@@ -1015,6 +1291,10 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private AgentRunOutput legacyOutput(ChatSseEmitter emitter) {
|
||||||
|
return new LegacyAgentRunOutput(emitter);
|
||||||
|
}
|
||||||
|
|
||||||
private ChatRuntimeContext chatContext() {
|
private ChatRuntimeContext chatContext() {
|
||||||
ChatRuntimeContext context = new ChatRuntimeContext();
|
ChatRuntimeContext context = new ChatRuntimeContext();
|
||||||
context.setAssistantId(BigInteger.valueOf(100));
|
context.setAssistantId(BigInteger.valueOf(100));
|
||||||
@@ -1209,10 +1489,12 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
private static class RecordingAgentRunEventRecorder implements AgentRunEventRecorder {
|
private static class RecordingAgentRunEventRecorder implements AgentRunEventRecorder {
|
||||||
|
|
||||||
private int recordCount;
|
private int recordCount;
|
||||||
|
private AgentRuntimeEvent lastEvent;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void record(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) {
|
public void record(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) {
|
||||||
recordCount++;
|
recordCount++;
|
||||||
|
lastEvent = event;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,21 @@ import com.easyagents.agent.runtime.model.AgentGenerationOptions;
|
|||||||
import com.easyagents.agent.runtime.model.AgentHttpVersionPolicy;
|
import com.easyagents.agent.runtime.model.AgentHttpVersionPolicy;
|
||||||
import com.easyagents.agent.runtime.model.AgentMessageContentFormat;
|
import com.easyagents.agent.runtime.model.AgentMessageContentFormat;
|
||||||
import com.easyagents.agent.runtime.model.AgentModelSpec;
|
import com.easyagents.agent.runtime.model.AgentModelSpec;
|
||||||
|
import com.easyagents.agent.runtime.tool.AgentToolSpec;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.agent.config.AgentBuiltinToolsConfig;
|
||||||
import tech.easyflow.agent.entity.Agent;
|
import tech.easyflow.agent.entity.Agent;
|
||||||
import tech.easyflow.ai.entity.Model;
|
import tech.easyflow.ai.entity.Model;
|
||||||
import tech.easyflow.ai.service.ModelService;
|
import tech.easyflow.ai.service.ModelService;
|
||||||
|
|
||||||
import java.lang.reflect.Field;
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.InvocationTargetException;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -230,6 +236,65 @@ public class AgentRuntimeCompilerModelConfigTest {
|
|||||||
Assert.assertEquals(50, policy.getMaxAttachedMessageCount());
|
Assert.assertEquals(50, policy.getMaxAttachedMessageCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并直接 Tool 与 Skill Tool 后超过统一数量预算时应拒绝。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void runtimeToolBudgetShouldRejectMoreThanOneHundredTwentyEightTools() throws Exception {
|
||||||
|
AgentRuntimeCompiler compiler = compilerWithObjectMapper();
|
||||||
|
List<AgentToolSpec> tools = new ArrayList<>();
|
||||||
|
for (int index = 0; index < 129; index++) {
|
||||||
|
AgentToolSpec spec = new AgentToolSpec();
|
||||||
|
spec.setName("tool_" + index);
|
||||||
|
spec.setParametersSchema(Map.of("type", "object"));
|
||||||
|
tools.add(spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertBudgetFailure(compiler, tools, "数量超过 128");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并后的 Tool Schema 超过统一字节预算时应拒绝。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void runtimeToolBudgetShouldRejectOversizedSchemas() throws Exception {
|
||||||
|
AgentRuntimeCompiler compiler = compilerWithObjectMapper();
|
||||||
|
AgentToolSpec spec = new AgentToolSpec();
|
||||||
|
spec.setName("oversized_tool");
|
||||||
|
spec.setParametersSchema(Map.of(
|
||||||
|
"type", "object",
|
||||||
|
"description", "x".repeat(2 * 1024 * 1024)));
|
||||||
|
|
||||||
|
assertBudgetFailure(compiler, List.of(spec), "Schema 超过 2 MiB");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证产物发布工具会明确要求模型主动发布用户需要的最终文件。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void artifactPublishToolShouldRequirePublishingFinalDeliverables() throws Exception {
|
||||||
|
AgentRuntimeCompiler compiler = new AgentRuntimeCompiler();
|
||||||
|
Method method = AgentRuntimeCompiler.class.getDeclaredMethod(
|
||||||
|
"buildArtifactPublishSpec", AgentBuiltinToolsConfig.ToolSwitch.class);
|
||||||
|
method.setAccessible(true);
|
||||||
|
|
||||||
|
AgentToolSpec spec = (AgentToolSpec) method.invoke(
|
||||||
|
compiler, new AgentBuiltinToolsConfig.ToolSwitch(true, false));
|
||||||
|
|
||||||
|
Assert.assertTrue(spec.getDescription().contains("MUST call this tool"));
|
||||||
|
Assert.assertTrue(spec.getDescription().contains("Do not finish with only a workspace path"));
|
||||||
|
Assert.assertTrue(spec.getDescription().contains("Do not publish temporary files"));
|
||||||
|
Map<?, ?> properties = (Map<?, ?>) spec.getParametersSchema().get("properties");
|
||||||
|
Map<?, ?> path = (Map<?, ?>) properties.get("path");
|
||||||
|
Assert.assertTrue(String.valueOf(path.get("description")).contains("completed final file"));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建已注入模型服务的编译器。
|
* 创建已注入模型服务的编译器。
|
||||||
*
|
*
|
||||||
@@ -249,6 +314,43 @@ public class AgentRuntimeCompilerModelConfigTest {
|
|||||||
return compiler;
|
return compiler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建只注入 JSON 映射器的 Runtime 编译器。
|
||||||
|
*
|
||||||
|
* @return 编译器
|
||||||
|
* @throws Exception 反射注入失败时抛出
|
||||||
|
*/
|
||||||
|
private AgentRuntimeCompiler compilerWithObjectMapper() throws Exception {
|
||||||
|
AgentRuntimeCompiler compiler = new AgentRuntimeCompiler();
|
||||||
|
Field field = AgentRuntimeCompiler.class.getDeclaredField("objectMapper");
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(compiler, new ObjectMapper());
|
||||||
|
return compiler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断言统一 Tool 预算校验失败。
|
||||||
|
*
|
||||||
|
* @param compiler Runtime 编译器
|
||||||
|
* @param toolSpecs Tool 声明
|
||||||
|
* @param messageFragment 错误消息片段
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
private void assertBudgetFailure(AgentRuntimeCompiler compiler,
|
||||||
|
List<AgentToolSpec> toolSpecs,
|
||||||
|
String messageFragment) throws Exception {
|
||||||
|
Method method = AgentRuntimeCompiler.class.getDeclaredMethod(
|
||||||
|
"assertToolBudget", List.class, List.class);
|
||||||
|
method.setAccessible(true);
|
||||||
|
try {
|
||||||
|
method.invoke(compiler, toolSpecs, List.of());
|
||||||
|
Assert.fail("Expected Agent Runtime tool budget failure");
|
||||||
|
} catch (InvocationTargetException exception) {
|
||||||
|
Assert.assertTrue(exception.getCause().getMessage(),
|
||||||
|
exception.getCause().getMessage().contains(messageFragment));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建测试模型。
|
* 创建测试模型。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package tech.easyflow.agent.runtime.agui;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import io.agentscope.core.agui.model.AguiMessage;
|
||||||
|
import io.agentscope.core.agui.model.AguiTool;
|
||||||
|
import io.agentscope.core.agui.model.RunAgentInput;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.agent.entity.Agent;
|
||||||
|
import tech.easyflow.agent.runtime.AgentChatRequest;
|
||||||
|
import tech.easyflow.agent.runtime.AgentDraftChatRequest;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link AgentAguiRunInputMapper} 的输入裁剪与安全测试。
|
||||||
|
*/
|
||||||
|
public class AgentAguiRunInputMapperTest {
|
||||||
|
|
||||||
|
private final AgentAguiRunInputMapper mapper = new AgentAguiRunInputMapper(new ObjectMapper());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证正式入口只提取本轮用户消息和白名单 forwardedProps。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldMapSingleUserMessageAndWhitelistedInput() {
|
||||||
|
RunAgentInput input = input(
|
||||||
|
"123",
|
||||||
|
"run-1",
|
||||||
|
List.of(AguiMessage.userMessage("current", "新问题")),
|
||||||
|
Map.of("easyflow", Map.of("input", Map.of(
|
||||||
|
"imageUploadIds", List.of("image-1"),
|
||||||
|
"documentUploadIds", List.of("document-1")))));
|
||||||
|
|
||||||
|
AgentChatRequest request = mapper.toFormalRequest(BigInteger.TEN, input);
|
||||||
|
AgentAguiWireContext wireContext = mapper.wireContext(input);
|
||||||
|
|
||||||
|
Assert.assertEquals(BigInteger.TEN, request.getAgentId());
|
||||||
|
Assert.assertEquals(new BigInteger("123"), request.getSessionId());
|
||||||
|
Assert.assertEquals("新问题", request.getPrompt());
|
||||||
|
Assert.assertEquals(List.of("image-1"), request.getImageUploadIds());
|
||||||
|
Assert.assertEquals(List.of("document-1"), request.getDocumentUploadIds());
|
||||||
|
Assert.assertEquals("run-1", wireContext.runId());
|
||||||
|
Assert.assertEquals("current", wireContext.userMessageId());
|
||||||
|
Assert.assertEquals("新问题", wireContext.userMessageContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证草稿入口从 EasyFlow 命名空间恢复现有草稿请求。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldMapDraftSnapshot() {
|
||||||
|
Agent agent = new Agent();
|
||||||
|
agent.setName("草稿 Agent");
|
||||||
|
RunAgentInput input = input(
|
||||||
|
"agent-draft-123",
|
||||||
|
"run-2",
|
||||||
|
List.of(AguiMessage.userMessage("message-1", "试一下")),
|
||||||
|
Map.of("easyflow", Map.of("draft", Map.of("agent", agent))));
|
||||||
|
|
||||||
|
AgentDraftChatRequest request = mapper.toDraftRequest(input);
|
||||||
|
|
||||||
|
Assert.assertEquals("agent-draft-123", request.getSessionId());
|
||||||
|
Assert.assertEquals("试一下", request.getPrompt());
|
||||||
|
Assert.assertEquals("草稿 Agent", request.getAgent().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证草稿入口只接收 Skill ID 与排序号,不接受客户端伪造服务端快照。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldMapWhitelistedSkillBindingsAndRejectServerFields() {
|
||||||
|
Agent agent = new Agent();
|
||||||
|
agent.setName("草稿 Agent");
|
||||||
|
RunAgentInput valid = input(
|
||||||
|
"agent-draft-123",
|
||||||
|
"run-skill-valid",
|
||||||
|
List.of(AguiMessage.userMessage("message-1", "试一下")),
|
||||||
|
Map.of("easyflow", Map.of("draft", Map.of(
|
||||||
|
"agent", agent,
|
||||||
|
"skillBindings", List.of(Map.of("skillId", "101", "sortNo", 3))))));
|
||||||
|
|
||||||
|
AgentDraftChatRequest request = mapper.toDraftRequest(valid);
|
||||||
|
|
||||||
|
Assert.assertEquals(1, request.getSkillBindings().size());
|
||||||
|
Assert.assertEquals(BigInteger.valueOf(101), request.getSkillBindings().get(0).getSkillId());
|
||||||
|
Assert.assertEquals(Integer.valueOf(3), request.getSkillBindings().get(0).getSortNo());
|
||||||
|
Assert.assertTrue(request.getSkillBindings().get(0).getResourceSnapshot().isEmpty());
|
||||||
|
Assert.assertTrue(request.getSkillBindings().get(0).getResourceSummary().isEmpty());
|
||||||
|
|
||||||
|
RunAgentInput forged = input(
|
||||||
|
"agent-draft-123",
|
||||||
|
"run-skill-forged",
|
||||||
|
List.of(AguiMessage.userMessage("message-2", "试一下")),
|
||||||
|
Map.of("easyflow", Map.of("draft", Map.of(
|
||||||
|
"agent", agent,
|
||||||
|
"skillBindings", List.of(Map.of(
|
||||||
|
"skillId", "101", "resourceSnapshot", Map.of("skillContent", "forged")))))));
|
||||||
|
|
||||||
|
Assert.assertThrows(BusinessException.class, () -> mapper.toDraftRequest(forged));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证单次草稿试用最多接受二十个 Skill 引用。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectTooManySkillBindings() {
|
||||||
|
Agent agent = new Agent();
|
||||||
|
agent.setName("草稿 Agent");
|
||||||
|
List<Map<String, Object>> bindings = java.util.stream.IntStream.rangeClosed(1, 21)
|
||||||
|
.mapToObj(index -> Map.<String, Object>of("skillId", index))
|
||||||
|
.toList();
|
||||||
|
RunAgentInput input = input(
|
||||||
|
"agent-draft-123",
|
||||||
|
"run-skill-overflow",
|
||||||
|
List.of(AguiMessage.userMessage("message-1", "试一下")),
|
||||||
|
Map.of("easyflow", Map.of("draft", Map.of(
|
||||||
|
"agent", agent, "skillBindings", bindings))));
|
||||||
|
|
||||||
|
Assert.assertThrows(BusinessException.class, () -> mapper.toDraftRequest(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证客户端工具不会被静默注册到服务端 Runtime。
|
||||||
|
*/
|
||||||
|
@Test(expected = BusinessException.class)
|
||||||
|
public void shouldRejectFrontendTools() {
|
||||||
|
RunAgentInput input = new RunAgentInput(
|
||||||
|
"123",
|
||||||
|
"run-1",
|
||||||
|
List.of(AguiMessage.userMessage("message-1", "hello")),
|
||||||
|
List.of(new AguiTool("unsafe", "unsafe", Map.of())),
|
||||||
|
List.of(),
|
||||||
|
Map.of(),
|
||||||
|
Map.of());
|
||||||
|
|
||||||
|
mapper.toFormalRequest(BigInteger.ONE, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证客户端不能携带历史消息或 state 影响服务端会话状态。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectHistoryAndClientState() {
|
||||||
|
RunAgentInput history = input(
|
||||||
|
"123",
|
||||||
|
"run-history",
|
||||||
|
List.of(
|
||||||
|
AguiMessage.userMessage("old", "旧问题"),
|
||||||
|
AguiMessage.userMessage("current", "新问题")),
|
||||||
|
Map.of());
|
||||||
|
Assert.assertThrows(BusinessException.class,
|
||||||
|
() -> mapper.toFormalRequest(BigInteger.ONE, history));
|
||||||
|
|
||||||
|
RunAgentInput state = new RunAgentInput(
|
||||||
|
"123",
|
||||||
|
"run-state",
|
||||||
|
List.of(AguiMessage.userMessage("message-1", "hello")),
|
||||||
|
List.of(),
|
||||||
|
List.of(),
|
||||||
|
Map.of("forged", true),
|
||||||
|
Map.of());
|
||||||
|
Assert.assertThrows(BusinessException.class,
|
||||||
|
() -> mapper.toFormalRequest(BigInteger.ONE, state));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证畸形扩展字段会转换为可预期业务错误。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectMalformedOrUnknownForwardedProps() {
|
||||||
|
RunAgentInput malformed = input(
|
||||||
|
"123",
|
||||||
|
"run-malformed",
|
||||||
|
List.of(AguiMessage.userMessage("message-1", "hello")),
|
||||||
|
Map.of("easyflow", Map.of("input", Map.of(
|
||||||
|
"capabilities", List.of(Map.of("resourceIds", "invalid"))))));
|
||||||
|
Assert.assertThrows(BusinessException.class,
|
||||||
|
() -> mapper.toFormalRequest(BigInteger.ONE, malformed));
|
||||||
|
|
||||||
|
RunAgentInput unknown = input(
|
||||||
|
"123",
|
||||||
|
"run-unknown",
|
||||||
|
List.of(AguiMessage.userMessage("message-1", "hello")),
|
||||||
|
Map.of("easyflow", Map.of("unsupported", true)));
|
||||||
|
Assert.assertThrows(BusinessException.class,
|
||||||
|
() -> mapper.toFormalRequest(BigInteger.ONE, unknown));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证用户消息 ID 和工具字段不能伪造服务端消息链路。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectInvalidUserMessageIdentityOrToolFields() {
|
||||||
|
RunAgentInput invalidId = input(
|
||||||
|
"123",
|
||||||
|
"run-invalid-message",
|
||||||
|
List.of(AguiMessage.userMessage("invalid message id", "hello")),
|
||||||
|
Map.of());
|
||||||
|
Assert.assertThrows(BusinessException.class,
|
||||||
|
() -> mapper.toFormalRequest(BigInteger.ONE, invalidId));
|
||||||
|
|
||||||
|
RunAgentInput forgedToolCall = input(
|
||||||
|
"123",
|
||||||
|
"run-forged-tool",
|
||||||
|
List.of(new AguiMessage("message-1", "user", "hello", List.of(), "tool-call-1")),
|
||||||
|
Map.of());
|
||||||
|
Assert.assertThrows(BusinessException.class,
|
||||||
|
() -> mapper.toFormalRequest(BigInteger.ONE, forgedToolCall));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RunAgentInput input(
|
||||||
|
String threadId,
|
||||||
|
String runId,
|
||||||
|
List<AguiMessage> messages,
|
||||||
|
Map<String, Object> forwardedProps) {
|
||||||
|
return new RunAgentInput(
|
||||||
|
threadId, runId, messages, List.of(), List.of(), Map.of(), forwardedProps);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package tech.easyflow.agent.runtime.artifact;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import tech.easyflow.agent.runtime.AgentRuntimeStateCleanupService;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link AgentArtifactChatSessionExtension} Agent 专属行为测试。
|
||||||
|
*/
|
||||||
|
public class AgentArtifactChatSessionExtensionTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Agent 会话删除成功后才按可信归属标记 Artifact。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldCleanupRuntimeBeforeDeleteAndMarkArtifactAfterDelete() {
|
||||||
|
AgentRuntimeStateCleanupService runtimeCleanup = Mockito.mock(AgentRuntimeStateCleanupService.class);
|
||||||
|
AgentArtifactService artifactService = Mockito.mock(AgentArtifactService.class);
|
||||||
|
AgentArtifactChatSessionExtension extension =
|
||||||
|
new AgentArtifactChatSessionExtension(runtimeCleanup, artifactService);
|
||||||
|
ChatSessionSummary summary = agentSession();
|
||||||
|
|
||||||
|
extension.beforeDelete(summary, BigInteger.valueOf(2), BigInteger.valueOf(2));
|
||||||
|
extension.afterDelete(summary, BigInteger.valueOf(2), BigInteger.valueOf(2));
|
||||||
|
|
||||||
|
Mockito.verify(runtimeCleanup).clearChatSession(BigInteger.valueOf(4), BigInteger.valueOf(2));
|
||||||
|
Mockito.verify(artifactService).markSessionDeletePending(
|
||||||
|
BigInteger.ONE, BigInteger.valueOf(2), BigInteger.valueOf(3), BigInteger.valueOf(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证非 Agent 会话不匹配扩展。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldIgnoreNonAgentSession() {
|
||||||
|
AgentArtifactChatSessionExtension extension = new AgentArtifactChatSessionExtension(
|
||||||
|
Mockito.mock(AgentRuntimeStateCleanupService.class), Mockito.mock(AgentArtifactService.class));
|
||||||
|
ChatSessionSummary summary = agentSession();
|
||||||
|
summary.setAssistantCode("BOT");
|
||||||
|
|
||||||
|
Assert.assertFalse(extension.supports(summary));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证历史投影使用会话中的可信租户、用户、Agent 和会话归属。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldProjectUsingTrustedSessionIdentity() {
|
||||||
|
AgentArtifactService artifactService = Mockito.mock(AgentArtifactService.class);
|
||||||
|
AgentArtifactChatSessionExtension extension = new AgentArtifactChatSessionExtension(
|
||||||
|
Mockito.mock(AgentRuntimeStateCleanupService.class), artifactService);
|
||||||
|
List<ChatMessageRecord> records = List.of(new ChatMessageRecord());
|
||||||
|
|
||||||
|
extension.projectMessages(agentSession(), records);
|
||||||
|
|
||||||
|
Mockito.verify(artifactService).projectHistoryArtifacts(records,
|
||||||
|
BigInteger.ONE, BigInteger.valueOf(2), BigInteger.valueOf(3), BigInteger.valueOf(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChatSessionSummary agentSession() {
|
||||||
|
ChatSessionSummary summary = new ChatSessionSummary();
|
||||||
|
summary.setTenantId(BigInteger.ONE);
|
||||||
|
summary.setUserId(BigInteger.valueOf(2));
|
||||||
|
summary.setAssistantId(BigInteger.valueOf(3));
|
||||||
|
summary.setId(BigInteger.valueOf(4));
|
||||||
|
summary.setAssistantCode("AGENT");
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package tech.easyflow.agent.runtime.artifact;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import tech.easyflow.agent.entity.AgentArtifact;
|
||||||
|
import tech.easyflow.agent.mapper.AgentArtifactMapper;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link AgentArtifactCleanupScheduler} 超时发布恢复测试。
|
||||||
|
*/
|
||||||
|
public class AgentArtifactCleanupSchedulerTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证过期 PUBLISHING 记录经条件领取后立即进入幂等对象删除。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldClaimAndDeleteAbandonedPublishingArtifact() {
|
||||||
|
AgentArtifactMapper mapper = Mockito.mock(AgentArtifactMapper.class);
|
||||||
|
AgentArtifactService artifactService = Mockito.mock(AgentArtifactService.class);
|
||||||
|
AgentArtifact abandoned = publishingArtifact();
|
||||||
|
Mockito.when(mapper.selectListByQuery(Mockito.any()))
|
||||||
|
.thenReturn(List.of(), List.of(abandoned), List.of(), List.of());
|
||||||
|
Mockito.when(mapper.selectOrphanedFormalArtifacts(Mockito.anyInt())).thenReturn(List.of());
|
||||||
|
Mockito.when(mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1);
|
||||||
|
|
||||||
|
new AgentArtifactCleanupScheduler(mapper, artifactService).cleanup();
|
||||||
|
|
||||||
|
ArgumentCaptor<AgentArtifact> update = ArgumentCaptor.forClass(AgentArtifact.class);
|
||||||
|
Mockito.verify(mapper).updateByQuery(update.capture(), Mockito.any());
|
||||||
|
Assert.assertEquals(AgentArtifactStatus.DELETE_PENDING.name(), update.getValue().getStatus());
|
||||||
|
Assert.assertEquals("ARTIFACT_PUBLISH_TIMEOUT", update.getValue().getLastErrorCode());
|
||||||
|
Assert.assertNotNull(update.getValue().getNextRetryAt());
|
||||||
|
Mockito.verify(artifactService).deleteObject(abandoned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证条件领取失败时不会删除可能已被其他线程接管的对象。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldSkipDeleteWhenPublishingClaimLosesRace() {
|
||||||
|
AgentArtifactMapper mapper = Mockito.mock(AgentArtifactMapper.class);
|
||||||
|
AgentArtifactService artifactService = Mockito.mock(AgentArtifactService.class);
|
||||||
|
Mockito.when(mapper.selectListByQuery(Mockito.any()))
|
||||||
|
.thenReturn(List.of(), List.of(publishingArtifact()), List.of(), List.of());
|
||||||
|
Mockito.when(mapper.selectOrphanedFormalArtifacts(Mockito.anyInt())).thenReturn(List.of());
|
||||||
|
Mockito.when(mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(0);
|
||||||
|
|
||||||
|
new AgentArtifactCleanupScheduler(mapper, artifactService).cleanup();
|
||||||
|
|
||||||
|
Mockito.verify(artifactService, Mockito.never()).deleteObject(Mockito.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证会话删除已落库但 after hook 失败时可由孤儿扫描补偿删除。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldClaimAndDeleteFormalArtifactWhoseSessionIsUnavailable() {
|
||||||
|
AgentArtifactMapper mapper = Mockito.mock(AgentArtifactMapper.class);
|
||||||
|
AgentArtifactService artifactService = Mockito.mock(AgentArtifactService.class);
|
||||||
|
AgentArtifact orphan = publishingArtifact();
|
||||||
|
orphan.setStatus(AgentArtifactStatus.AVAILABLE.name());
|
||||||
|
Mockito.when(mapper.selectListByQuery(Mockito.any()))
|
||||||
|
.thenReturn(List.of(), List.of(), List.of(), List.of());
|
||||||
|
Mockito.when(mapper.selectOrphanedFormalArtifacts(Mockito.anyInt())).thenReturn(List.of(orphan));
|
||||||
|
Mockito.when(mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1);
|
||||||
|
|
||||||
|
new AgentArtifactCleanupScheduler(mapper, artifactService).cleanup();
|
||||||
|
|
||||||
|
ArgumentCaptor<AgentArtifact> update = ArgumentCaptor.forClass(AgentArtifact.class);
|
||||||
|
Mockito.verify(mapper).updateByQuery(update.capture(), Mockito.any());
|
||||||
|
Assert.assertEquals(AgentArtifactStatus.DELETE_PENDING.name(), update.getValue().getStatus());
|
||||||
|
Assert.assertEquals("ARTIFACT_SESSION_UNAVAILABLE", update.getValue().getLastErrorCode());
|
||||||
|
Mockito.verify(artifactService).deleteObject(orphan);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentArtifact publishingArtifact() {
|
||||||
|
AgentArtifact artifact = new AgentArtifact();
|
||||||
|
artifact.setId(BigInteger.valueOf(9));
|
||||||
|
artifact.setArtifactId("artifact-9");
|
||||||
|
artifact.setObjectKey("artifacts/9/content");
|
||||||
|
artifact.setStatus(AgentArtifactStatus.PUBLISHING.name());
|
||||||
|
artifact.setNextRetryAt(new Date(System.currentTimeMillis() - 1_000));
|
||||||
|
return artifact;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,598 @@
|
|||||||
|
package tech.easyflow.agent.runtime.artifact;
|
||||||
|
|
||||||
|
import com.easyagents.agent.runtime.AgentRuntimeContext;
|
||||||
|
import com.easyagents.agent.runtime.tool.AgentToolContext;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Rule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.rules.TemporaryFolder;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import tech.easyflow.agent.config.AgentWorkspaceProperties;
|
||||||
|
import tech.easyflow.agent.entity.AgentArtifact;
|
||||||
|
import tech.easyflow.agent.mapper.AgentArtifactMapper;
|
||||||
|
import tech.easyflow.agent.runtime.workspace.AgentWorkspaceResolver;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||||
|
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.core.runtime.ChatRuntimeExtKeys;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipOutputStream;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent Artifact 发布、安全返回和失败补偿测试。
|
||||||
|
*/
|
||||||
|
public class AgentArtifactServiceTest {
|
||||||
|
|
||||||
|
/** 临时工作区。 */
|
||||||
|
@Rule
|
||||||
|
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证发布主链流式计算摘要并只返回安全字段。
|
||||||
|
*
|
||||||
|
* @throws Exception 文件准备失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void publishShouldReturnSafeAvailableView() throws Exception {
|
||||||
|
Path file = file("report.csv", "name,value\na,1\n");
|
||||||
|
Fixture fixture = fixture(file);
|
||||||
|
Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
invocation.<java.io.InputStream>getArgument(1).readAllBytes();
|
||||||
|
return "etag-1";
|
||||||
|
});
|
||||||
|
Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1);
|
||||||
|
|
||||||
|
AgentArtifactView view = fixture.service.publish(
|
||||||
|
file.getParent(), "report.csv", null, AgentArtifactService.MODE_DRAFT, context("draft-session"));
|
||||||
|
|
||||||
|
Assert.assertEquals("AVAILABLE", view.status());
|
||||||
|
Assert.assertEquals(Files.size(file), view.size());
|
||||||
|
Assert.assertEquals(64, view.sha256().length());
|
||||||
|
Assert.assertEquals("text/plain", view.mimeType());
|
||||||
|
Map<String, Object> safe = view.toMap();
|
||||||
|
Assert.assertFalse(safe.containsKey("objectKey"));
|
||||||
|
Assert.assertFalse(safe.containsKey("storagePlatform"));
|
||||||
|
Assert.assertTrue(view.downloadUrl().contains(view.artifactId()));
|
||||||
|
ArgumentCaptor<AgentArtifact> inserted = ArgumentCaptor.forClass(AgentArtifact.class);
|
||||||
|
Mockito.verify(fixture.mapper).insert(inserted.capture());
|
||||||
|
Assert.assertTrue(inserted.getValue().getNextRetryAt().after(new java.util.Date()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证对象已写入但状态提交失败、补偿删除也失败时进入可重试删除失败态。
|
||||||
|
*
|
||||||
|
* @throws Exception 文件准备失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void publishShouldRecordDeleteFailedWhenCompensationDeleteFails() throws Exception {
|
||||||
|
Path file = file("report.txt", "content");
|
||||||
|
Fixture fixture = fixture(file);
|
||||||
|
Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
invocation.<java.io.InputStream>getArgument(1).readAllBytes();
|
||||||
|
return "etag-1";
|
||||||
|
});
|
||||||
|
Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(0);
|
||||||
|
Mockito.doThrow(new AgentArtifactOperationException("STORAGE", "delete failed", true))
|
||||||
|
.when(fixture.storage).delete(Mockito.anyString());
|
||||||
|
|
||||||
|
Assert.assertThrows(AgentArtifactOperationException.class, () -> fixture.service.publish(
|
||||||
|
file.getParent(), "report.txt", null, AgentArtifactService.MODE_DRAFT, context("draft-session")));
|
||||||
|
|
||||||
|
ArgumentCaptor<AgentArtifact> updates = ArgumentCaptor.forClass(AgentArtifact.class);
|
||||||
|
Mockito.verify(fixture.mapper, Mockito.atLeast(2)).updateByQuery(updates.capture(), Mockito.any());
|
||||||
|
AgentArtifact compensation = updates.getAllValues().get(updates.getAllValues().size() - 1);
|
||||||
|
Assert.assertEquals(AgentArtifactStatus.DELETE_FAILED.name(), compensation.getStatus());
|
||||||
|
Assert.assertNotNull(compensation.getNextRetryAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证对象可能已经写入但 put 响应抛错时仍会执行幂等补偿删除。
|
||||||
|
*
|
||||||
|
* @throws Exception 文件准备失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void publishShouldCompensateWhenPutThrowsAfterPossibleWrite() throws Exception {
|
||||||
|
Path file = file("uncertain.txt", "content");
|
||||||
|
Fixture fixture = fixture(file);
|
||||||
|
Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString()))
|
||||||
|
.thenThrow(new AgentArtifactOperationException("STORAGE_TIMEOUT", "response lost", true));
|
||||||
|
Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1);
|
||||||
|
|
||||||
|
Assert.assertThrows(AgentArtifactOperationException.class, () -> fixture.service.publish(
|
||||||
|
file.getParent(), "uncertain.txt", null,
|
||||||
|
AgentArtifactService.MODE_DRAFT, context("draft-session")));
|
||||||
|
|
||||||
|
Mockito.verify(fixture.storage).delete(Mockito.anyString());
|
||||||
|
ArgumentCaptor<AgentArtifact> update = ArgumentCaptor.forClass(AgentArtifact.class);
|
||||||
|
Mockito.verify(fixture.mapper).updateByQuery(update.capture(), Mockito.any());
|
||||||
|
Assert.assertEquals(AgentArtifactStatus.FAILED.name(), update.getValue().getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证展示扩展名不能伪造 MIME,未知二进制安全回退为 octet-stream。
|
||||||
|
*
|
||||||
|
* @throws Exception 文件准备失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void publishShouldDetectMimeFromContentInsteadOfExtension() throws Exception {
|
||||||
|
Path file = temporaryFolder.newFile("forged.png").toPath();
|
||||||
|
Files.write(file, new byte[]{0, 1, 2, 3});
|
||||||
|
Fixture fixture = fixture(file);
|
||||||
|
Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
invocation.<java.io.InputStream>getArgument(1).readAllBytes();
|
||||||
|
return "etag-1";
|
||||||
|
});
|
||||||
|
Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1);
|
||||||
|
|
||||||
|
AgentArtifactView view = fixture.service.publish(
|
||||||
|
file.getParent(), "forged.png", null, AgentArtifactService.MODE_DRAFT, context("draft-session"));
|
||||||
|
|
||||||
|
Assert.assertEquals("application/octet-stream", view.mimeType());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证正式产物只从可信 RuntimeContext 绑定当前聊天轮次。
|
||||||
|
*
|
||||||
|
* @throws Exception 文件准备失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void formalPublishShouldPersistTrustedRoundId() throws Exception {
|
||||||
|
Path file = file("report.txt", "content");
|
||||||
|
Fixture fixture = fixture(file);
|
||||||
|
Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
invocation.<java.io.InputStream>getArgument(1).readAllBytes();
|
||||||
|
return "etag-1";
|
||||||
|
});
|
||||||
|
Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1);
|
||||||
|
AgentToolContext context = context("100");
|
||||||
|
context.getRuntimeContext().getMetadata().put(ChatRuntimeExtKeys.CURRENT_ROUND_ID, "200");
|
||||||
|
context.getRuntimeContext().getMetadata().put(ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX, 1);
|
||||||
|
|
||||||
|
fixture.service.publish(file.getParent(), "report.txt", null,
|
||||||
|
AgentArtifactService.MODE_FORMAL, context);
|
||||||
|
|
||||||
|
ArgumentCaptor<AgentArtifact> inserted = ArgumentCaptor.forClass(AgentArtifact.class);
|
||||||
|
Mockito.verify(fixture.mapper).insert(inserted.capture());
|
||||||
|
Assert.assertEquals(BigInteger.valueOf(100), inserted.getValue().getChatSessionId());
|
||||||
|
Assert.assertEquals(BigInteger.valueOf(200), inserted.getValue().getRoundId());
|
||||||
|
Assert.assertEquals(Integer.valueOf(1), inserted.getValue().getVariantIndex());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证历史投影只查询一次账本,并以当前状态覆盖旧 AVAILABLE 快照。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void historyProjectionShouldUseCurrentLedgerStatusWithoutStorageFields() {
|
||||||
|
Fixture fixture = fixture(Path.of("unused"));
|
||||||
|
Mockito.when(fixture.mapper.selectListByQuery(Mockito.any())).thenReturn(List.of(
|
||||||
|
ledger("available", AgentArtifactStatus.AVAILABLE, BigInteger.valueOf(200)),
|
||||||
|
ledger("deleted", AgentArtifactStatus.DELETED, BigInteger.valueOf(200)),
|
||||||
|
ledger("delete-failed", AgentArtifactStatus.DELETE_FAILED, BigInteger.valueOf(200))));
|
||||||
|
ChatMessageRecord message = new ChatMessageRecord();
|
||||||
|
message.setSessionId(BigInteger.valueOf(100));
|
||||||
|
message.setRoundId(BigInteger.valueOf(200));
|
||||||
|
message.setSenderRole("assistant");
|
||||||
|
message.setVariantIndex(1);
|
||||||
|
message.setContentPayload(new LinkedHashMap<>(Map.of("artifacts", List.of(
|
||||||
|
oldView("available"), oldView("deleted"), oldView("delete-failed"), oldView("missing")))));
|
||||||
|
|
||||||
|
fixture.service.projectHistoryArtifacts(
|
||||||
|
List.of(message), BigInteger.ONE, BigInteger.valueOf(2), BigInteger.valueOf(3), BigInteger.valueOf(100));
|
||||||
|
|
||||||
|
Mockito.verify(fixture.mapper, Mockito.times(1)).selectListByQuery(Mockito.any());
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> projected =
|
||||||
|
(List<Map<String, Object>>) message.getContentPayload().get("artifacts");
|
||||||
|
Assert.assertEquals("AVAILABLE", projected.get(0).get("status"));
|
||||||
|
Assert.assertNotNull(projected.get(0).get("downloadUrl"));
|
||||||
|
Assert.assertEquals("DELETED", projected.get(1).get("status"));
|
||||||
|
Assert.assertNull(projected.get(1).get("downloadUrl"));
|
||||||
|
Assert.assertEquals("DELETE_FAILED", projected.get(2).get("status"));
|
||||||
|
Assert.assertNull(projected.get(2).get("downloadUrl"));
|
||||||
|
Assert.assertEquals("UNAVAILABLE", projected.get(3).get("status"));
|
||||||
|
Assert.assertNull(projected.get(3).get("downloadUrl"));
|
||||||
|
for (Map<String, Object> item : projected) {
|
||||||
|
Assert.assertFalse(item.containsKey("objectKey"));
|
||||||
|
Assert.assertFalse(item.containsKey("storagePlatform"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 AVAILABLE 已落账但消息 payload 尚未持久化时可按当前页轮次补回。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void historyProjectionShouldRecoverLedgerArtifactMissingFromPayload() {
|
||||||
|
Fixture fixture = fixture(Path.of("unused"));
|
||||||
|
Mockito.when(fixture.mapper.selectListByQuery(Mockito.any())).thenReturn(List.of(
|
||||||
|
ledger("recovered", AgentArtifactStatus.AVAILABLE, BigInteger.valueOf(201))));
|
||||||
|
ChatMessageRecord message = new ChatMessageRecord();
|
||||||
|
message.setSessionId(BigInteger.valueOf(100));
|
||||||
|
message.setRoundId(BigInteger.valueOf(201));
|
||||||
|
message.setSenderRole("assistant");
|
||||||
|
message.setVariantIndex(1);
|
||||||
|
message.setContentPayload(new LinkedHashMap<>(Map.of("answer", "done")));
|
||||||
|
|
||||||
|
fixture.service.projectHistoryArtifacts(
|
||||||
|
List.of(message), BigInteger.ONE, BigInteger.valueOf(2), BigInteger.valueOf(3), BigInteger.valueOf(100));
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> projected =
|
||||||
|
(List<Map<String, Object>>) message.getContentPayload().get("artifacts");
|
||||||
|
Assert.assertEquals(1, projected.size());
|
||||||
|
Assert.assertEquals("recovered", projected.get(0).get("artifactId"));
|
||||||
|
Assert.assertEquals("AVAILABLE", projected.get(0).get("status"));
|
||||||
|
Mockito.verify(fixture.mapper, Mockito.times(1)).selectListByQuery(Mockito.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证同一答案版本已有部分 payload 时仍可合并账本中已发布但尚未持久化的产物。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void historyProjectionShouldMergeMissingLedgerArtifactIntoExistingPayload() {
|
||||||
|
Fixture fixture = fixture(Path.of("unused"));
|
||||||
|
Mockito.when(fixture.mapper.selectListByQuery(Mockito.any())).thenReturn(List.of(
|
||||||
|
ledger("persisted", AgentArtifactStatus.AVAILABLE, BigInteger.valueOf(201)),
|
||||||
|
ledger("late", AgentArtifactStatus.AVAILABLE, BigInteger.valueOf(201))));
|
||||||
|
ChatMessageRecord message = assistantMessage(BigInteger.valueOf(201), 1);
|
||||||
|
message.setContentPayload(new LinkedHashMap<>(Map.of(
|
||||||
|
"artifacts", List.of(oldView("persisted")))));
|
||||||
|
|
||||||
|
fixture.service.projectHistoryArtifacts(
|
||||||
|
List.of(message), BigInteger.ONE, BigInteger.valueOf(2), BigInteger.valueOf(3), BigInteger.valueOf(100));
|
||||||
|
|
||||||
|
Assert.assertEquals(List.of("persisted", "late"), projectedArtifacts(message).stream()
|
||||||
|
.map(item -> String.valueOf(item.get("artifactId")))
|
||||||
|
.toList());
|
||||||
|
Mockito.verify(fixture.mapper, Mockito.times(1)).selectListByQuery(Mockito.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证同一轮重答的两个答案版本只恢复各自绑定的产物。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void historyProjectionShouldIsolateArtifactsByVariantIndex() {
|
||||||
|
Fixture fixture = fixture(Path.of("unused"));
|
||||||
|
AgentArtifact first = ledger("variant-one", AgentArtifactStatus.AVAILABLE, BigInteger.valueOf(201));
|
||||||
|
first.setVariantIndex(1);
|
||||||
|
AgentArtifact second = ledger("variant-two", AgentArtifactStatus.AVAILABLE, BigInteger.valueOf(201));
|
||||||
|
second.setVariantIndex(2);
|
||||||
|
Mockito.when(fixture.mapper.selectListByQuery(Mockito.any())).thenReturn(List.of(first, second));
|
||||||
|
ChatMessageRecord firstMessage = assistantMessage(BigInteger.valueOf(201), 1);
|
||||||
|
ChatMessageRecord secondMessage = assistantMessage(BigInteger.valueOf(201), 2);
|
||||||
|
|
||||||
|
fixture.service.projectHistoryArtifacts(List.of(firstMessage, secondMessage),
|
||||||
|
BigInteger.ONE, BigInteger.valueOf(2), BigInteger.valueOf(3), BigInteger.valueOf(100));
|
||||||
|
|
||||||
|
Assert.assertEquals("variant-one", projectedArtifacts(firstMessage).get(0).get("artifactId"));
|
||||||
|
Assert.assertEquals("variant-two", projectedArtifacts(secondMessage).get(0).get("artifactId"));
|
||||||
|
Mockito.verify(fixture.mapper, Mockito.times(1)).selectListByQuery(Mockito.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 MinIO 实际对象大小不一致时发布失败并幂等删除。
|
||||||
|
*
|
||||||
|
* @throws Exception 文件准备失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void publishShouldCompensateWhenStoredSizeMismatches() throws Exception {
|
||||||
|
Path file = file("size.txt", "content");
|
||||||
|
Fixture fixture = fixture(file);
|
||||||
|
Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString()))
|
||||||
|
.thenReturn("etag-1");
|
||||||
|
Mockito.when(fixture.storage.stat(Mockito.anyString()))
|
||||||
|
.thenReturn(new AgentArtifactObjectStorage.StoredObjectMetadata(1L, "etag-1"));
|
||||||
|
Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1);
|
||||||
|
|
||||||
|
Assert.assertThrows(AgentArtifactOperationException.class, () -> fixture.service.publish(
|
||||||
|
file.getParent(), file.getFileName().toString(), null,
|
||||||
|
AgentArtifactService.MODE_DRAFT, context("draft-session")));
|
||||||
|
|
||||||
|
Mockito.verify(fixture.storage).delete(Mockito.anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 MinIO 回读对象摘要不一致时发布失败并幂等删除。
|
||||||
|
*
|
||||||
|
* @throws Exception 文件准备失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void publishShouldCompensateWhenStoredHashMismatches() throws Exception {
|
||||||
|
Path file = file("hash.txt", "content");
|
||||||
|
Fixture fixture = fixture(file);
|
||||||
|
Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString()))
|
||||||
|
.thenReturn("etag-1");
|
||||||
|
Mockito.when(fixture.storage.open(Mockito.anyString()))
|
||||||
|
.thenReturn(new java.io.ByteArrayInputStream("changed".getBytes(StandardCharsets.UTF_8)));
|
||||||
|
Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1);
|
||||||
|
|
||||||
|
Assert.assertThrows(AgentArtifactOperationException.class, () -> fixture.service.publish(
|
||||||
|
file.getParent(), file.getFileName().toString(), null,
|
||||||
|
AgentArtifactService.MODE_DRAFT, context("draft-session")));
|
||||||
|
|
||||||
|
Mockito.verify(fixture.storage).delete(Mockito.anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 OOXML 文件依据 ZIP 内部结构识别 MIME。
|
||||||
|
*
|
||||||
|
* @throws Exception 文件准备失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void publishShouldRecognizeDocxFromZipStructure() throws Exception {
|
||||||
|
Path file = temporaryFolder.newFile("report.bin").toPath();
|
||||||
|
try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(file))) {
|
||||||
|
output.putNextEntry(new ZipEntry("[Content_Types].xml"));
|
||||||
|
output.write("<Types/>".getBytes(StandardCharsets.UTF_8));
|
||||||
|
output.closeEntry();
|
||||||
|
output.putNextEntry(new ZipEntry("word/document.xml"));
|
||||||
|
output.write("<document/>".getBytes(StandardCharsets.UTF_8));
|
||||||
|
output.closeEntry();
|
||||||
|
}
|
||||||
|
Fixture fixture = fixture(file);
|
||||||
|
Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
invocation.<java.io.InputStream>getArgument(1).readAllBytes();
|
||||||
|
return "etag-1";
|
||||||
|
});
|
||||||
|
Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1);
|
||||||
|
|
||||||
|
AgentArtifactView view = fixture.service.publish(file.getParent(), file.getFileName().toString(), null,
|
||||||
|
AgentArtifactService.MODE_DRAFT, context("draft-session"));
|
||||||
|
|
||||||
|
Assert.assertEquals("application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
view.mimeType());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证高压缩比条目仅通过 central directory 元数据拒绝,不展开正文进行 MIME 探测。
|
||||||
|
*
|
||||||
|
* @throws Exception 文件准备失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void publishShouldTreatZipBombLikeArchiveAsGenericZip() throws Exception {
|
||||||
|
Path file = temporaryFolder.newFile("compressed.bin").toPath();
|
||||||
|
try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(file))) {
|
||||||
|
output.putNextEntry(new ZipEntry("payload.bin"));
|
||||||
|
byte[] zeros = new byte[8192];
|
||||||
|
for (int index = 0; index < 1024; index++) {
|
||||||
|
output.write(zeros);
|
||||||
|
}
|
||||||
|
output.closeEntry();
|
||||||
|
output.putNextEntry(new ZipEntry("[Content_Types].xml"));
|
||||||
|
output.write("<Types/>".getBytes(StandardCharsets.UTF_8));
|
||||||
|
output.closeEntry();
|
||||||
|
output.putNextEntry(new ZipEntry("word/document.xml"));
|
||||||
|
output.write("<document/>".getBytes(StandardCharsets.UTF_8));
|
||||||
|
output.closeEntry();
|
||||||
|
}
|
||||||
|
Fixture fixture = fixture(file);
|
||||||
|
Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
invocation.<java.io.InputStream>getArgument(1).readAllBytes();
|
||||||
|
return "etag-1";
|
||||||
|
});
|
||||||
|
Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1);
|
||||||
|
|
||||||
|
AgentArtifactView view = fixture.service.publish(file.getParent(), file.getFileName().toString(), null,
|
||||||
|
AgentArtifactService.MODE_DRAFT, context("draft-session"));
|
||||||
|
|
||||||
|
Assert.assertEquals("application/zip", view.mimeType());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证海量空条目在 ZipFile 构造前由 EOCD/中央目录预检拒绝。
|
||||||
|
*
|
||||||
|
* @throws Exception 文件准备失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void publishShouldRejectExcessiveCentralDirectoryEntries() throws Exception {
|
||||||
|
Path file = temporaryFolder.newFile("many-entries.bin").toPath();
|
||||||
|
try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(file))) {
|
||||||
|
output.putNextEntry(new ZipEntry("[Content_Types].xml"));
|
||||||
|
output.write("<Types/>".getBytes(StandardCharsets.UTF_8));
|
||||||
|
output.closeEntry();
|
||||||
|
output.putNextEntry(new ZipEntry("word/document.xml"));
|
||||||
|
output.write("<document/>".getBytes(StandardCharsets.UTF_8));
|
||||||
|
output.closeEntry();
|
||||||
|
for (int index = 0; index < 4096; index++) {
|
||||||
|
output.putNextEntry(new ZipEntry("empty/" + index));
|
||||||
|
output.closeEntry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Fixture fixture = fixture(file);
|
||||||
|
Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
invocation.<java.io.InputStream>getArgument(1).readAllBytes();
|
||||||
|
return "etag-1";
|
||||||
|
});
|
||||||
|
Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1);
|
||||||
|
|
||||||
|
AgentArtifactView view = fixture.service.publish(file.getParent(), file.getFileName().toString(), null,
|
||||||
|
AgentArtifactService.MODE_DRAFT, context("draft-session"));
|
||||||
|
|
||||||
|
Assert.assertEquals("application/zip", view.mimeType());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证正式产物下载必须同时匹配当前 Agent、模式、会话和存活会话归属。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void formalDownloadShouldRejectCrossAgentModeAndSessionReplay() {
|
||||||
|
Fixture fixture = fixture(Path.of("unused"));
|
||||||
|
AgentArtifact artifact = downloadableArtifact(AgentArtifactService.MODE_FORMAL);
|
||||||
|
Mockito.when(fixture.mapper.selectOneByQuery(Mockito.any())).thenReturn(artifact);
|
||||||
|
ChatSessionQueryService queryService = Mockito.mock(ChatSessionQueryService.class);
|
||||||
|
ChatSessionSummary summary = new ChatSessionSummary();
|
||||||
|
summary.setId(BigInteger.valueOf(100));
|
||||||
|
summary.setTenantId(BigInteger.ONE);
|
||||||
|
summary.setUserId(BigInteger.valueOf(2));
|
||||||
|
summary.setAssistantId(BigInteger.valueOf(3));
|
||||||
|
summary.setAssistantCode("AGENT");
|
||||||
|
Mockito.when(queryService.getSessionSummary(BigInteger.valueOf(100))).thenReturn(summary);
|
||||||
|
fixture.service.setChatSessionQueryService(queryService);
|
||||||
|
LoginAccount account = account();
|
||||||
|
|
||||||
|
Assert.assertSame(artifact, fixture.service.requireDownload(
|
||||||
|
"artifact", account, BigInteger.valueOf(3), "FORMAL", BigInteger.valueOf(100), null));
|
||||||
|
Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload(
|
||||||
|
"artifact", account, BigInteger.valueOf(4), "FORMAL", BigInteger.valueOf(100), null));
|
||||||
|
Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload(
|
||||||
|
"artifact", account, BigInteger.valueOf(3), "DRAFT", null, "100"));
|
||||||
|
Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload(
|
||||||
|
"artifact", account, BigInteger.valueOf(3), "FORMAL", BigInteger.valueOf(101), null));
|
||||||
|
Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload(
|
||||||
|
"artifact", account, BigInteger.valueOf(3), "FORMAL", BigInteger.valueOf(100), "draft"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证草稿产物下载必须匹配当前 Agent 和 Runtime 会话。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void draftDownloadShouldRejectCrossAgentAndRuntimeSessionReplay() {
|
||||||
|
Fixture fixture = fixture(Path.of("unused"));
|
||||||
|
AgentArtifact artifact = downloadableArtifact(AgentArtifactService.MODE_DRAFT);
|
||||||
|
Mockito.when(fixture.mapper.selectOneByQuery(Mockito.any())).thenReturn(artifact);
|
||||||
|
LoginAccount account = account();
|
||||||
|
|
||||||
|
Assert.assertSame(artifact, fixture.service.requireDownload(
|
||||||
|
"artifact", account, BigInteger.valueOf(3), "DRAFT", null, "draft-session"));
|
||||||
|
Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload(
|
||||||
|
"artifact", account, BigInteger.valueOf(4), "DRAFT", null, "draft-session"));
|
||||||
|
Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload(
|
||||||
|
"artifact", account, BigInteger.valueOf(3), "DRAFT", null, "another-session"));
|
||||||
|
Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload(
|
||||||
|
"artifact", account, BigInteger.valueOf(3), "FORMAL", BigInteger.valueOf(100), null));
|
||||||
|
Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload(
|
||||||
|
"artifact", account, BigInteger.valueOf(3), "DRAFT", BigInteger.valueOf(100), "draft-session"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChatMessageRecord assistantMessage(BigInteger roundId, int variantIndex) {
|
||||||
|
ChatMessageRecord message = new ChatMessageRecord();
|
||||||
|
message.setSessionId(BigInteger.valueOf(100));
|
||||||
|
message.setRoundId(roundId);
|
||||||
|
message.setVariantIndex(variantIndex);
|
||||||
|
message.setSenderRole("assistant");
|
||||||
|
message.setContentPayload(new LinkedHashMap<>());
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentArtifact downloadableArtifact(String mode) {
|
||||||
|
AgentArtifact artifact = new AgentArtifact();
|
||||||
|
artifact.setArtifactId("artifact");
|
||||||
|
artifact.setTenantId(BigInteger.ONE);
|
||||||
|
artifact.setOwnerUserId(BigInteger.valueOf(2));
|
||||||
|
artifact.setAgentId(BigInteger.valueOf(3));
|
||||||
|
artifact.setChatMode(mode);
|
||||||
|
artifact.setChatSessionId(AgentArtifactService.MODE_FORMAL.equals(mode)
|
||||||
|
? BigInteger.valueOf(100) : null);
|
||||||
|
artifact.setRuntimeSessionId(AgentArtifactService.MODE_DRAFT.equals(mode)
|
||||||
|
? "draft-session" : "100");
|
||||||
|
artifact.setStatus(AgentArtifactStatus.AVAILABLE.name());
|
||||||
|
return artifact;
|
||||||
|
}
|
||||||
|
|
||||||
|
private LoginAccount account() {
|
||||||
|
LoginAccount account = new LoginAccount();
|
||||||
|
account.setTenantId(BigInteger.ONE);
|
||||||
|
account.setId(BigInteger.valueOf(2));
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private List<Map<String, Object>> projectedArtifacts(ChatMessageRecord message) {
|
||||||
|
return (List<Map<String, Object>>) message.getContentPayload().get("artifacts");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Fixture fixture(Path file) {
|
||||||
|
AgentArtifactMapper mapper = Mockito.mock(AgentArtifactMapper.class);
|
||||||
|
AgentArtifactObjectStorage storage = Mockito.mock(AgentArtifactObjectStorage.class);
|
||||||
|
AgentWorkspaceResolver resolver = Mockito.mock(AgentWorkspaceResolver.class);
|
||||||
|
Mockito.when(resolver.resolveExistingFile(Mockito.any(), Mockito.anyString())).thenReturn(file);
|
||||||
|
Mockito.when(storage.stat(Mockito.anyString())).thenAnswer(invocation ->
|
||||||
|
new AgentArtifactObjectStorage.StoredObjectMetadata(Files.size(file), "etag-1"));
|
||||||
|
try {
|
||||||
|
Mockito.when(storage.open(Mockito.anyString())).thenAnswer(invocation -> Files.newInputStream(file));
|
||||||
|
} catch (java.io.IOException error) {
|
||||||
|
throw new IllegalStateException(error);
|
||||||
|
}
|
||||||
|
Mockito.doAnswer(invocation -> {
|
||||||
|
invocation.<AgentArtifact>getArgument(0).setId(BigInteger.valueOf(99));
|
||||||
|
return 1;
|
||||||
|
}).when(mapper).insert(Mockito.any());
|
||||||
|
AgentWorkspaceProperties properties = new AgentWorkspaceProperties();
|
||||||
|
return new Fixture(mapper, storage,
|
||||||
|
new AgentArtifactService(mapper, storage, resolver, properties));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path file(String name, String content) throws Exception {
|
||||||
|
Path file = temporaryFolder.newFile(name).toPath();
|
||||||
|
Files.writeString(file, content, StandardCharsets.UTF_8);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentToolContext context(String sessionId) {
|
||||||
|
AgentRuntimeContext runtimeContext = new AgentRuntimeContext();
|
||||||
|
runtimeContext.setTenantId("1");
|
||||||
|
runtimeContext.setUserId("2");
|
||||||
|
runtimeContext.setSessionId(sessionId);
|
||||||
|
AgentToolContext context = new AgentToolContext();
|
||||||
|
context.setAgentId("3");
|
||||||
|
context.setSessionId(sessionId);
|
||||||
|
context.setRequestId("request-1");
|
||||||
|
context.setToolCallId("tool-1");
|
||||||
|
context.setRuntimeContext(runtimeContext);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentArtifact ledger(String artifactId,
|
||||||
|
AgentArtifactStatus status,
|
||||||
|
BigInteger roundId) {
|
||||||
|
AgentArtifact artifact = new AgentArtifact();
|
||||||
|
artifact.setArtifactId(artifactId);
|
||||||
|
artifact.setRoundId(roundId);
|
||||||
|
artifact.setVariantIndex(1);
|
||||||
|
artifact.setFileName(artifactId + ".txt");
|
||||||
|
artifact.setMimeType("text/plain");
|
||||||
|
artifact.setSizeBytes(10L);
|
||||||
|
artifact.setSha256("sha-" + artifactId);
|
||||||
|
artifact.setStatus(status.name());
|
||||||
|
artifact.setStoragePlatform("secret-platform");
|
||||||
|
artifact.setObjectKey("secret/object/key");
|
||||||
|
return artifact;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> oldView(String artifactId) {
|
||||||
|
Map<String, Object> view = new LinkedHashMap<>();
|
||||||
|
view.put("schemaVersion", 1);
|
||||||
|
view.put("artifactId", artifactId);
|
||||||
|
view.put("fileName", artifactId + ".txt");
|
||||||
|
view.put("mimeType", "text/plain");
|
||||||
|
view.put("size", 10L);
|
||||||
|
view.put("sha256", "old-sha");
|
||||||
|
view.put("downloadUrl", "/stale-download");
|
||||||
|
view.put("status", "AVAILABLE");
|
||||||
|
view.put("objectKey", "must-not-leak");
|
||||||
|
view.put("storagePlatform", "must-not-leak");
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Fixture(AgentArtifactMapper mapper,
|
||||||
|
AgentArtifactObjectStorage storage,
|
||||||
|
AgentArtifactService service) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import tech.easyflow.agent.runtime.tool.AgentToolExecutionResult;
|
|||||||
import tech.easyflow.agent.runtime.tool.PluginToolExecutor;
|
import tech.easyflow.agent.runtime.tool.PluginToolExecutor;
|
||||||
import tech.easyflow.agent.runtime.tool.WorkflowToolExecutor;
|
import tech.easyflow.agent.runtime.tool.WorkflowToolExecutor;
|
||||||
import tech.easyflow.ai.entity.PluginItem;
|
import tech.easyflow.ai.entity.PluginItem;
|
||||||
|
import tech.easyflow.ai.entity.Plugin;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
@@ -67,6 +68,7 @@ public class WorkflowPluginAsyncSubToolsTest {
|
|||||||
try {
|
try {
|
||||||
Map<String, Object> businessResult = Map.of("pluginOutput", List.of("a", "b"));
|
Map<String, Object> businessResult = Map.of("pluginOutput", List.of("a", "b"));
|
||||||
PluginAsyncSubTools subTools = new PluginAsyncSubTools(pluginItem(),
|
PluginAsyncSubTools subTools = new PluginAsyncSubTools(pluginItem(),
|
||||||
|
new Plugin(),
|
||||||
"plugin_demo",
|
"plugin_demo",
|
||||||
"测试插件",
|
"测试插件",
|
||||||
new StubPluginToolExecutor(businessResult),
|
new StubPluginToolExecutor(businessResult),
|
||||||
@@ -165,7 +167,9 @@ public class WorkflowPluginAsyncSubToolsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AgentToolExecutionResult execute(PluginItem pluginItem, Map<String, Object> arguments) {
|
public AgentToolExecutionResult execute(PluginItem pluginItem,
|
||||||
|
Plugin plugin,
|
||||||
|
Map<String, Object> arguments) {
|
||||||
return new AgentToolExecutionResult(businessResult, null);
|
return new AgentToolExecutionResult(businessResult, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package tech.easyflow.agent.runtime.hitl;
|
package tech.easyflow.agent.runtime.hitl;
|
||||||
|
|
||||||
|
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||||
|
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.Mockito;
|
import org.mockito.Mockito;
|
||||||
import tech.easyflow.agent.config.AgentRuntimeProperties;
|
import tech.easyflow.agent.config.AgentRuntimeProperties;
|
||||||
import tech.easyflow.agent.entity.AgentHitlPending;
|
import tech.easyflow.agent.entity.AgentHitlPending;
|
||||||
@@ -10,12 +13,44 @@ import tech.easyflow.agent.mapper.AgentHitlPendingMapper;
|
|||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link AgentHitlPendingServiceImpl} 回归测试。
|
* {@link AgentHitlPendingServiceImpl} 回归测试。
|
||||||
*/
|
*/
|
||||||
public class AgentHitlPendingServiceImplTest {
|
public class AgentHitlPendingServiceImplTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证审批 pending 仅持久化可审查的脱敏工具参数与元数据。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void recordApprovalRequiredShouldRedactSensitiveInputAndMetadata() {
|
||||||
|
AgentHitlPendingMapper mapper = Mockito.mock(AgentHitlPendingMapper.class);
|
||||||
|
AgentHitlPendingServiceImpl service =
|
||||||
|
new AgentHitlPendingServiceImpl(mapper, new AgentRuntimeProperties());
|
||||||
|
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED);
|
||||||
|
event.getPayload().put("resumeToken", "resume-1");
|
||||||
|
event.getPayload().put("toolName", "search");
|
||||||
|
event.getPayload().put("toolInput", Map.of(
|
||||||
|
"authorization", "sentinel-secret-authorization",
|
||||||
|
"keyword", "EasyFlow",
|
||||||
|
"nested", Map.of("password", "sentinel-secret-password")));
|
||||||
|
event.getPayload().put("approvalMetadata", Map.of(
|
||||||
|
"credential", "sentinel-secret-credential",
|
||||||
|
"risk", "low"));
|
||||||
|
|
||||||
|
service.recordApprovalRequired("request-1", null, event);
|
||||||
|
|
||||||
|
ArgumentCaptor<AgentHitlPending> captor = ArgumentCaptor.forClass(AgentHitlPending.class);
|
||||||
|
Mockito.verify(mapper).insertOrUpdate(captor.capture());
|
||||||
|
AgentHitlPending stored = captor.getValue();
|
||||||
|
Assert.assertEquals("EasyFlow", stored.getToolInputJson().get("keyword"));
|
||||||
|
Assert.assertEquals("[已隐藏]", stored.getToolInputJson().get("authorization"));
|
||||||
|
Assert.assertFalse(stored.getToolInputJson().toString().contains("sentinel-secret"));
|
||||||
|
Assert.assertEquals("low", stored.getMetadataJson().get("risk"));
|
||||||
|
Assert.assertFalse(stored.getMetadataJson().toString().contains("sentinel-secret"));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证过期扫描只返回成功从 PENDING 原子更新为 EXPIRED 的记录。
|
* 验证过期扫描只返回成功从 PENDING 原子更新为 EXPIRED 的记录。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package tech.easyflow.agent.runtime.output;
|
||||||
|
|
||||||
|
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||||
|
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
import tech.easyflow.core.chat.protocol.ChatDomain;
|
||||||
|
import tech.easyflow.core.chat.protocol.ChatType;
|
||||||
|
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link AguiAgentRunOutput} 的线级顺序、终态和脱敏测试。
|
||||||
|
*/
|
||||||
|
public class AguiAgentRunOutputTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证输入确认、正文、引用和完成终态顺序。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldEmitOrderedAguiEventsAndSingleTerminal() {
|
||||||
|
ChatSseEmitter emitter = emitter();
|
||||||
|
AguiAgentRunOutput output = new AguiAgentRunOutput(
|
||||||
|
"123", "run-1", "user-message-1", emitter);
|
||||||
|
|
||||||
|
Assert.assertTrue(output.emitViewEvent(
|
||||||
|
ChatDomain.SYSTEM, ChatType.INPUT_ACCEPTED, Map.of("messageId", "server-message-1")));
|
||||||
|
Assert.assertTrue(output.emitViewEvent(
|
||||||
|
ChatDomain.LLM, ChatType.MESSAGE, Map.of("delta", "hello")));
|
||||||
|
AgentRuntimeEvent completed = AgentRuntimeEvent.of(AgentRuntimeEventType.COMPLETED);
|
||||||
|
Assert.assertTrue(output.emitRuntimeEvent(completed));
|
||||||
|
Assert.assertTrue(output.emitViewEvent(
|
||||||
|
ChatDomain.BUSINESS, ChatType.CITATIONS, Map.of("items", List.of(Map.of("id", "c1")))));
|
||||||
|
Assert.assertTrue(output.finish("hello"));
|
||||||
|
|
||||||
|
ArgumentCaptor<String> frames = ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(emitter, atLeastOnce()).sendData(frames.capture());
|
||||||
|
List<String> json = frames.getAllValues();
|
||||||
|
Assert.assertTrue(json.get(0).contains("\"type\":\"RUN_STARTED\""));
|
||||||
|
Assert.assertTrue(json.stream().anyMatch(value -> value.contains("easyflow.input.accepted")));
|
||||||
|
Assert.assertTrue(json.stream().anyMatch(value -> value.contains("\"type\":\"TEXT_MESSAGE_CONTENT\"")));
|
||||||
|
Assert.assertTrue(json.stream().anyMatch(value -> value.contains("easyflow.knowledge.citations")));
|
||||||
|
Assert.assertTrue(json.get(json.size() - 1).contains("\"type\":\"RUN_FINISHED\""));
|
||||||
|
Assert.assertEquals(1, json.stream().filter(value -> value.contains("\"type\":\"RUN_FINISHED\"")).count());
|
||||||
|
verify(emitter).complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证审批 Custom Event 不泄漏恢复令牌。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldHideResumeTokenInApprovalEvent() {
|
||||||
|
ChatSseEmitter emitter = emitter();
|
||||||
|
AguiAgentRunOutput output = new AguiAgentRunOutput("123", "run-1", "user-1", emitter);
|
||||||
|
AgentRuntimeEvent approval = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED);
|
||||||
|
approval.setToolCallId("tool-1");
|
||||||
|
approval.getPayload().put("resumeToken", "secret-resume-token");
|
||||||
|
approval.getPayload().put("toolName", "dangerous-tool");
|
||||||
|
approval.getPayload().put("input", Map.of(
|
||||||
|
"authorization", "sentinel-secret-authorization",
|
||||||
|
"callbackUrl", "https://example.test/callback?api_key=sentinel-secret-query",
|
||||||
|
"value", 1));
|
||||||
|
approval.getMetadata().put("approvalId", "approval-public");
|
||||||
|
|
||||||
|
Assert.assertTrue(output.emitRuntimeEvent(approval));
|
||||||
|
|
||||||
|
ArgumentCaptor<String> frames = ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(emitter, atLeastOnce()).sendData(frames.capture());
|
||||||
|
String wire = String.join("\n", frames.getAllValues());
|
||||||
|
Assert.assertTrue(wire.contains("approval-public"));
|
||||||
|
Assert.assertTrue(wire.contains("[已隐藏]"));
|
||||||
|
Assert.assertFalse(wire.contains("secret-resume-token"));
|
||||||
|
Assert.assertFalse(wire.contains("sentinel-secret"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 EasyFlow 业务 CUSTOM 只接受服务层公开载荷,Runtime 原始检索数据不会透出。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldNotExposeRawBusinessRuntimePayloads() {
|
||||||
|
ChatSseEmitter emitter = emitter();
|
||||||
|
AguiAgentRunOutput output = new AguiAgentRunOutput("123", "run-1", "user-1", emitter);
|
||||||
|
AgentRuntimeEvent knowledge = AgentRuntimeEvent.of(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL);
|
||||||
|
knowledge.getPayload().put("documents", List.of(Map.of(
|
||||||
|
"chunkContent", "private chunk",
|
||||||
|
"sourceUri", "private://document")));
|
||||||
|
|
||||||
|
Assert.assertTrue(output.emitRuntimeEvent(knowledge));
|
||||||
|
Assert.assertTrue(output.emitViewEvent(
|
||||||
|
ChatDomain.BUSINESS,
|
||||||
|
ChatType.STATUS,
|
||||||
|
Map.of(
|
||||||
|
"documents", knowledge.getPayload().get("documents"),
|
||||||
|
"label", "已检索知识库",
|
||||||
|
"status", "done",
|
||||||
|
"statusKey", "knowledge-retrieval")));
|
||||||
|
|
||||||
|
ArgumentCaptor<String> frames = ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(emitter, atLeastOnce()).sendData(frames.capture());
|
||||||
|
String wire = String.join("\n", frames.getAllValues());
|
||||||
|
Assert.assertTrue(wire.contains("easyflow.knowledge.retrieval_status"));
|
||||||
|
Assert.assertFalse(wire.contains("private chunk"));
|
||||||
|
Assert.assertFalse(wire.contains("private://document"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证没有 Runtime 终态的正常 EOF 会转换为明确协议错误。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldFailWhenStreamEndsWithoutTerminalEvent() {
|
||||||
|
ChatSseEmitter emitter = emitter();
|
||||||
|
AguiAgentRunOutput output = new AguiAgentRunOutput("123", "run-1", "user-1", emitter);
|
||||||
|
|
||||||
|
Assert.assertFalse(output.canFinishSuccessfully());
|
||||||
|
Assert.assertTrue(output.finish(null));
|
||||||
|
|
||||||
|
ArgumentCaptor<String> frames = ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(emitter, atLeastOnce()).sendData(frames.capture());
|
||||||
|
String wire = String.join("\n", frames.getAllValues());
|
||||||
|
Assert.assertTrue(wire.contains("\"type\":\"RUN_ERROR\""));
|
||||||
|
Assert.assertTrue(wire.contains("MISSING_TERMINAL_EVENT"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证最终权威文本发生修正时通过标准消息快照收敛,且协议保留字段不可被覆盖。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldReconcileAuthoritativeTextAndProtectReservedFields() {
|
||||||
|
ChatSseEmitter emitter = emitter();
|
||||||
|
AguiAgentRunOutput output = new AguiAgentRunOutput(
|
||||||
|
"123", "run-1", "user-1", "question", emitter);
|
||||||
|
Assert.assertTrue(output.emitViewEvent(
|
||||||
|
ChatDomain.LLM, ChatType.MESSAGE, Map.of("delta", "draft")));
|
||||||
|
Assert.assertTrue(output.emitViewEvent(
|
||||||
|
ChatDomain.SYSTEM, ChatType.INPUT_ACCEPTED,
|
||||||
|
Map.of("messageId", "server-1", "runId", "untrusted-run")));
|
||||||
|
Assert.assertTrue(output.emitRuntimeEvent(AgentRuntimeEvent.of(AgentRuntimeEventType.COMPLETED)));
|
||||||
|
Assert.assertTrue(output.finish("final"));
|
||||||
|
|
||||||
|
ArgumentCaptor<String> frames = ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(emitter, atLeastOnce()).sendData(frames.capture());
|
||||||
|
String wire = String.join("\n", frames.getAllValues());
|
||||||
|
Assert.assertTrue(wire.contains("\"type\":\"MESSAGES_SNAPSHOT\""));
|
||||||
|
Assert.assertTrue(wire.contains("\"role\":\"user\""));
|
||||||
|
Assert.assertTrue(wire.contains("\"content\":\"question\""));
|
||||||
|
Assert.assertTrue(wire.contains("\"content\":\"final\""));
|
||||||
|
Assert.assertTrue(wire.contains("\"runId\":\"run-1\""));
|
||||||
|
Assert.assertFalse(wire.contains("untrusted-run"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证工具前后的多段正文使用不同消息 ID,推理开始事件使用标准 reasoning 角色。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldUseUniqueSegmentMessageIdsAndReasoningRole() {
|
||||||
|
ChatSseEmitter emitter = emitter();
|
||||||
|
AguiAgentRunOutput output = new AguiAgentRunOutput("123", "run-1", "user-1", emitter);
|
||||||
|
Assert.assertTrue(output.emitViewEvent(
|
||||||
|
ChatDomain.LLM, ChatType.MESSAGE, Map.of("delta", "before")));
|
||||||
|
AgentRuntimeEvent toolCall = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_CALL);
|
||||||
|
toolCall.setToolCallId("tool-1");
|
||||||
|
toolCall.getPayload().put("toolName", "search");
|
||||||
|
toolCall.getPayload().put("toolDisplayName", "联网搜索");
|
||||||
|
Assert.assertTrue(output.emitRuntimeEvent(toolCall));
|
||||||
|
Assert.assertTrue(output.emitViewEvent(
|
||||||
|
ChatDomain.LLM, ChatType.MESSAGE, Map.of("delta", "after")));
|
||||||
|
Assert.assertTrue(output.emitViewEvent(
|
||||||
|
ChatDomain.LLM, ChatType.THINKING, Map.of("delta", "reason")));
|
||||||
|
|
||||||
|
ArgumentCaptor<String> frames = ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(emitter, atLeastOnce()).sendData(frames.capture());
|
||||||
|
String wire = String.join("\n", frames.getAllValues());
|
||||||
|
Assert.assertTrue(wire.contains("run-1-assistant-1"));
|
||||||
|
Assert.assertTrue(wire.contains("run-1-assistant-2"));
|
||||||
|
Assert.assertTrue(wire.contains("\"role\":\"reasoning\""));
|
||||||
|
Assert.assertTrue(wire.contains("easyflow.tool.metadata"));
|
||||||
|
Assert.assertTrue(wire.contains("联网搜索"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Artifact 发布事件仅发送安全字段。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldEmitSafeArtifactPublishedCustomEvent() {
|
||||||
|
ChatSseEmitter emitter = emitter();
|
||||||
|
AguiAgentRunOutput output = new AguiAgentRunOutput("123", "run-1", "user-1", emitter);
|
||||||
|
|
||||||
|
Assert.assertTrue(output.emitViewEvent(ChatDomain.BUSINESS, ChatType.STATUS, Map.of(
|
||||||
|
"statusKey", "artifact-published",
|
||||||
|
"schemaVersion", 1,
|
||||||
|
"artifactId", "a1",
|
||||||
|
"fileName", "report.csv",
|
||||||
|
"mimeType", "text/csv",
|
||||||
|
"size", 12,
|
||||||
|
"sha256", "abc",
|
||||||
|
"downloadUrl", "/api/v1/agent/artifacts/a1/content",
|
||||||
|
"status", "AVAILABLE",
|
||||||
|
"objectKey", "private/object/key")));
|
||||||
|
|
||||||
|
ArgumentCaptor<String> frames = ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(emitter, atLeastOnce()).sendData(frames.capture());
|
||||||
|
String wire = String.join("\n", frames.getAllValues());
|
||||||
|
Assert.assertTrue(wire.contains("easyflow.artifact.published"));
|
||||||
|
Assert.assertTrue(wire.contains("report.csv"));
|
||||||
|
Assert.assertFalse(wire.contains("private/object/key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatSseEmitter emitter() {
|
||||||
|
ChatSseEmitter emitter = mock(ChatSseEmitter.class);
|
||||||
|
when(emitter.getEmitter()).thenReturn(new SseEmitter());
|
||||||
|
when(emitter.sendData(anyString())).thenReturn(true);
|
||||||
|
when(emitter.isClosed()).thenReturn(false);
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package tech.easyflow.agent.runtime.skill;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.agent.entity.Agent;
|
||||||
|
import tech.easyflow.agent.entity.AgentSkillBinding;
|
||||||
|
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||||
|
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompilation;
|
||||||
|
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler;
|
||||||
|
import tech.easyflow.agent.service.AgentDependencyAccessService;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.skill.entity.Skill;
|
||||||
|
import tech.easyflow.skill.service.SkillService;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent Skill Runtime 编译器测试。
|
||||||
|
*/
|
||||||
|
public class AgentSkillRuntimeCompilerTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整冻结投影应编译为 SkillBox,且运行时不再回查当前 Skill。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldCompileFrozenSkillBox() {
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
AgentSkillRuntimeProjector projector = projector(objectMapper, Map.of());
|
||||||
|
Agent agent = new Agent();
|
||||||
|
agent.setSkillBindings(projector.projectCurrentBindings(
|
||||||
|
agent, List.of(binding(BigInteger.ONE))));
|
||||||
|
AgentToolRuntimeCompiler toolCompiler = mock(AgentToolRuntimeCompiler.class);
|
||||||
|
when(toolCompiler.compileBindings(anyList())).thenReturn(new AgentToolRuntimeCompilation());
|
||||||
|
AgentSkillRuntimeCompiler compiler = new AgentSkillRuntimeCompiler(
|
||||||
|
projector, toolCompiler, objectMapper);
|
||||||
|
|
||||||
|
AgentSkillRuntimeCompilation compilation = compiler.compile(agent);
|
||||||
|
|
||||||
|
Assert.assertNotNull(compilation.getSkillBoxSpec());
|
||||||
|
Assert.assertEquals(1, compilation.getSkillBoxSpec().getSkills().size());
|
||||||
|
Assert.assertEquals("skill-1", compilation.getSkillBoxSpec().getSkills().get(0).getName());
|
||||||
|
Assert.assertEquals(List.of(), compilation.getSkillBoxSpec().getToolBindings().get("1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 直接 Tool 与 Skill Tool 指向同一资源时应拒绝,避免执行归属不确定。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectTargetSharedByDirectAndSkillTool() {
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
Map<String, Object> frozenTool = Map.of(
|
||||||
|
"toolType", "WORKFLOW",
|
||||||
|
"targetId", 100,
|
||||||
|
"toolCount", 1,
|
||||||
|
"resourceSnapshot", Map.of("id", 100));
|
||||||
|
AgentSkillRuntimeProjector projector = projector(objectMapper, Map.of(
|
||||||
|
"schemaVersion", 1,
|
||||||
|
"bindings", List.of(frozenTool),
|
||||||
|
"snapshotHash", "tool-hash"));
|
||||||
|
Agent agent = new Agent();
|
||||||
|
agent.setSkillBindings(projector.projectCurrentBindings(
|
||||||
|
agent, List.of(binding(BigInteger.ONE))));
|
||||||
|
AgentToolBinding direct = new AgentToolBinding();
|
||||||
|
direct.setToolType("WORKFLOW");
|
||||||
|
direct.setTargetId(BigInteger.valueOf(100));
|
||||||
|
direct.setEnabled(true);
|
||||||
|
agent.setToolBindings(List.of(direct));
|
||||||
|
AgentSkillRuntimeCompiler compiler = new AgentSkillRuntimeCompiler(
|
||||||
|
projector, mock(AgentToolRuntimeCompiler.class), objectMapper);
|
||||||
|
|
||||||
|
Assert.assertThrows(BusinessException.class, () -> compiler.compile(agent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缺失冻结投影的绑定不得进入正式 Runtime。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectBindingWithoutFrozenSnapshot() {
|
||||||
|
AgentSkillRuntimeProjector projector = mock(AgentSkillRuntimeProjector.class);
|
||||||
|
AgentSkillRuntimeCompiler compiler = new AgentSkillRuntimeCompiler(
|
||||||
|
projector, mock(AgentToolRuntimeCompiler.class), new ObjectMapper());
|
||||||
|
Agent agent = new Agent();
|
||||||
|
agent.setSkillBindings(List.of(binding(BigInteger.ONE)));
|
||||||
|
|
||||||
|
Assert.assertThrows(BusinessException.class, () -> compiler.compile(agent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建基于单个已发布 Skill 的真实投影器。
|
||||||
|
*
|
||||||
|
* @param objectMapper JSON 映射器
|
||||||
|
* @param toolSnapshot Tool 发布快照
|
||||||
|
* @return 投影器
|
||||||
|
*/
|
||||||
|
private AgentSkillRuntimeProjector projector(ObjectMapper objectMapper,
|
||||||
|
Map<String, Object> toolSnapshot) {
|
||||||
|
AgentDependencyAccessService accessService = mock(AgentDependencyAccessService.class);
|
||||||
|
SkillService skillService = mock(SkillService.class);
|
||||||
|
Skill skill = new Skill();
|
||||||
|
skill.setId(BigInteger.ONE);
|
||||||
|
skill.setName("skill-1");
|
||||||
|
Map<String, Object> content = new LinkedHashMap<>();
|
||||||
|
content.put("schemaVersion", 2);
|
||||||
|
content.put("name", "skill-1");
|
||||||
|
content.put("displayName", "测试 Skill");
|
||||||
|
content.put("description", "用于测试 Skill Runtime");
|
||||||
|
content.put("visibilityScope", "PRIVATE");
|
||||||
|
content.put("skillContent", "# 指令\n执行测试");
|
||||||
|
content.put("packageHash", "package-hash");
|
||||||
|
content.put("resources", List.of());
|
||||||
|
content.put("snapshotHash", "content-hash");
|
||||||
|
skill.setPublishedSnapshotJson(content);
|
||||||
|
skill.setPublishedToolBindingsJson(toolSnapshot);
|
||||||
|
when(accessService.requireSkill(org.mockito.ArgumentMatchers.any(),
|
||||||
|
org.mockito.ArgumentMatchers.eq(BigInteger.ONE))).thenReturn(skill);
|
||||||
|
return new AgentSkillRuntimeProjector(accessService, skillService, objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Skill 绑定。
|
||||||
|
*
|
||||||
|
* @param skillId Skill ID
|
||||||
|
* @return 绑定
|
||||||
|
*/
|
||||||
|
private AgentSkillBinding binding(BigInteger skillId) {
|
||||||
|
AgentSkillBinding binding = new AgentSkillBinding();
|
||||||
|
binding.setSkillId(skillId);
|
||||||
|
return binding;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package tech.easyflow.agent.runtime.skill;
|
||||||
|
|
||||||
|
import com.easyagents.agent.runtime.mcp.McpToolManifestEntry;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.InOrder;
|
||||||
|
import tech.easyflow.agent.entity.Agent;
|
||||||
|
import tech.easyflow.agent.entity.AgentSkillBinding;
|
||||||
|
import tech.easyflow.agent.service.AgentDependencyAccessService;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.skill.entity.Skill;
|
||||||
|
import tech.easyflow.skill.service.SkillService;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.inOrder;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent Skill 冻结运行投影测试。
|
||||||
|
*/
|
||||||
|
public class AgentSkillRuntimeProjectorTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 投影应只使用 Skill 发布快照展示字段、排除二进制正文并校验组合 hash。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public void shouldProjectPublishedContentAndDetectTampering() {
|
||||||
|
AgentDependencyAccessService accessService = mock(AgentDependencyAccessService.class);
|
||||||
|
SkillService skillService = mock(SkillService.class);
|
||||||
|
AgentSkillRuntimeProjector projector = new AgentSkillRuntimeProjector(
|
||||||
|
accessService, skillService, new ObjectMapper());
|
||||||
|
Skill skill = skill(BigInteger.ONE, "线上名称");
|
||||||
|
skill.setDisplayName("未发布草稿名称");
|
||||||
|
when(accessService.requireSkill(org.mockito.ArgumentMatchers.any(),
|
||||||
|
org.mockito.ArgumentMatchers.eq(BigInteger.ONE))).thenReturn(skill);
|
||||||
|
|
||||||
|
List<AgentSkillBinding> projected = projector.projectCurrentBindings(
|
||||||
|
new Agent(), List.of(binding(BigInteger.ONE)));
|
||||||
|
|
||||||
|
Map<String, Object> snapshot = projected.get(0).getResourceSnapshot();
|
||||||
|
Map<String, Object> summary = projected.get(0).getResourceSummary();
|
||||||
|
Assert.assertEquals("线上名称", snapshot.get("displayName"));
|
||||||
|
Assert.assertEquals(Map.of("references/guide.md", "指南正文"), snapshot.get("resources"));
|
||||||
|
Assert.assertEquals(1, summary.get("binaryExcludedCount"));
|
||||||
|
Assert.assertEquals(1, summary.get("toolCount"));
|
||||||
|
projector.assertFrozenBindings(projected);
|
||||||
|
|
||||||
|
Map<String, Object> tampered = new LinkedHashMap<>(snapshot);
|
||||||
|
tampered.put("skillContent", "被篡改的指令");
|
||||||
|
projected.get(0).setResourceSnapshot(tampered);
|
||||||
|
Assert.assertThrows(BusinessException.class, () -> projector.assertFrozenBindings(projected));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP 清单在发布前为 POJO、落库后为 Map 时,运行快照 hash 应保持一致。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public void shouldKeepRuntimeHashAfterMcpManifestJsonRoundTrip() {
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
AgentDependencyAccessService accessService = mock(AgentDependencyAccessService.class);
|
||||||
|
SkillService skillService = mock(SkillService.class);
|
||||||
|
AgentSkillRuntimeProjector projector = new AgentSkillRuntimeProjector(
|
||||||
|
accessService, skillService, objectMapper);
|
||||||
|
Skill skill = skill(BigInteger.ONE, "MCP Skill");
|
||||||
|
McpToolManifestEntry entry = new McpToolManifestEntry();
|
||||||
|
entry.setName("query-docs");
|
||||||
|
entry.setDescription("查询文档");
|
||||||
|
entry.setInputSchema(Map.of("type", "object"));
|
||||||
|
entry.setOutputSchema(Map.of("type", "object"));
|
||||||
|
skill.setPublishedToolBindingsJson(Map.of(
|
||||||
|
"schemaVersion", 1,
|
||||||
|
"bindings", List.of(Map.of(
|
||||||
|
"toolType", "MCP",
|
||||||
|
"targetId", 200,
|
||||||
|
"toolCount", 1,
|
||||||
|
"mcpToolManifest", List.of(entry),
|
||||||
|
"resourceSnapshot", Map.of("id", 200))),
|
||||||
|
"snapshotHash", "tools-mcp"));
|
||||||
|
when(accessService.requireSkill(org.mockito.ArgumentMatchers.any(),
|
||||||
|
org.mockito.ArgumentMatchers.eq(BigInteger.ONE))).thenReturn(skill);
|
||||||
|
|
||||||
|
List<AgentSkillBinding> projected = projector.projectCurrentBindings(
|
||||||
|
new Agent(), List.of(binding(BigInteger.ONE)));
|
||||||
|
Map<String, Object> persisted = objectMapper.convertValue(
|
||||||
|
projected.get(0).getResourceSnapshot(), Map.class);
|
||||||
|
projected.get(0).setResourceSnapshot(persisted);
|
||||||
|
|
||||||
|
projector.assertFrozenBindings(projected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 资源锁应按 Skill ID 稳定获取,同时保留用户编排顺序。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldLockSkillsInStableOrderAndPreserveBindingOrder() {
|
||||||
|
AgentDependencyAccessService accessService = mock(AgentDependencyAccessService.class);
|
||||||
|
SkillService skillService = mock(SkillService.class);
|
||||||
|
AgentSkillRuntimeProjector projector = new AgentSkillRuntimeProjector(
|
||||||
|
accessService, skillService, new ObjectMapper());
|
||||||
|
Agent agent = new Agent();
|
||||||
|
when(accessService.requireSkill(agent, BigInteger.ONE)).thenReturn(skill(BigInteger.ONE, "一"));
|
||||||
|
when(accessService.requireSkill(agent, BigInteger.TWO)).thenReturn(skill(BigInteger.TWO, "二"));
|
||||||
|
|
||||||
|
List<AgentSkillBinding> projected = projector.projectCurrentBindings(
|
||||||
|
agent, List.of(binding(BigInteger.TWO), binding(BigInteger.ONE)));
|
||||||
|
|
||||||
|
InOrder order = inOrder(accessService);
|
||||||
|
order.verify(accessService).requireSkill(agent, BigInteger.ONE);
|
||||||
|
order.verify(accessService).requireSkill(agent, BigInteger.TWO);
|
||||||
|
Assert.assertEquals(BigInteger.TWO, projected.get(0).getSkillId());
|
||||||
|
Assert.assertEquals(BigInteger.ONE, projected.get(1).getSkillId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同一 Agent 不得重复绑定同一 Skill。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectDuplicateSkillBindings() {
|
||||||
|
AgentDependencyAccessService accessService = mock(AgentDependencyAccessService.class);
|
||||||
|
SkillService skillService = mock(SkillService.class);
|
||||||
|
AgentSkillRuntimeProjector projector = new AgentSkillRuntimeProjector(
|
||||||
|
accessService, skillService, new ObjectMapper());
|
||||||
|
when(accessService.requireSkill(org.mockito.ArgumentMatchers.any(),
|
||||||
|
org.mockito.ArgumentMatchers.eq(BigInteger.ONE))).thenReturn(skill(BigInteger.ONE, "一"));
|
||||||
|
|
||||||
|
Assert.assertThrows(BusinessException.class, () -> projector.projectCurrentBindings(
|
||||||
|
new Agent(), List.of(binding(BigInteger.ONE), binding(BigInteger.ONE))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个 Agent 超过二十个 Skill 时应在访问依赖资源前拒绝。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectMoreThanTwentySkills() {
|
||||||
|
AgentSkillRuntimeProjector projector = new AgentSkillRuntimeProjector(
|
||||||
|
mock(AgentDependencyAccessService.class), mock(SkillService.class), new ObjectMapper());
|
||||||
|
List<AgentSkillBinding> bindings = new ArrayList<>();
|
||||||
|
for (int index = 1; index <= 21; index++) {
|
||||||
|
bindings.add(binding(BigInteger.valueOf(index)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.assertThrows(BusinessException.class,
|
||||||
|
() -> projector.projectCurrentBindings(new Agent(), bindings));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建带发布内容和 Tool 快照的 Skill。
|
||||||
|
*
|
||||||
|
* @param id Skill ID
|
||||||
|
* @param publishedDisplayName 已发布展示名
|
||||||
|
* @return Skill
|
||||||
|
*/
|
||||||
|
private Skill skill(BigInteger id, String publishedDisplayName) {
|
||||||
|
Skill skill = new Skill();
|
||||||
|
skill.setId(id);
|
||||||
|
skill.setName("skill-" + id);
|
||||||
|
skill.setDisplayName(publishedDisplayName);
|
||||||
|
Map<String, Object> content = new LinkedHashMap<>();
|
||||||
|
content.put("schemaVersion", 2);
|
||||||
|
content.put("name", "skill-" + id);
|
||||||
|
content.put("displayName", publishedDisplayName);
|
||||||
|
content.put("description", "用于测试 Skill 运行投影");
|
||||||
|
content.put("visibilityScope", "PRIVATE");
|
||||||
|
content.put("skillContent", "# 指令\n执行测试");
|
||||||
|
content.put("packageHash", "package-" + id);
|
||||||
|
content.put("resources", List.of(
|
||||||
|
Map.of("path", "references/guide.md", "text", true, "textContent", "指南正文"),
|
||||||
|
Map.of("path", "assets/template.bin", "text", false, "contentRef", "sha256:binary")));
|
||||||
|
content.put("snapshotHash", "content-" + id);
|
||||||
|
skill.setPublishedSnapshotJson(content);
|
||||||
|
skill.setPublishedToolBindingsJson(Map.of(
|
||||||
|
"schemaVersion", 1,
|
||||||
|
"bindings", List.of(Map.of(
|
||||||
|
"toolType", "WORKFLOW",
|
||||||
|
"targetId", 100,
|
||||||
|
"toolCount", 1,
|
||||||
|
"resourceSnapshot", Map.of("id", 100))),
|
||||||
|
"snapshotHash", "tools-" + id));
|
||||||
|
return skill;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Skill 绑定。
|
||||||
|
*
|
||||||
|
* @param skillId Skill ID
|
||||||
|
* @return 绑定
|
||||||
|
*/
|
||||||
|
private AgentSkillBinding binding(BigInteger skillId) {
|
||||||
|
AgentSkillBinding binding = new AgentSkillBinding();
|
||||||
|
binding.setSkillId(skillId);
|
||||||
|
return binding;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package tech.easyflow.agent.runtime.tool;
|
package tech.easyflow.agent.runtime.tool;
|
||||||
|
|
||||||
import com.easyagents.agent.runtime.tool.AgentToolSpec;
|
import com.easyagents.agent.runtime.tool.AgentToolSpec;
|
||||||
|
import com.easyagents.agent.runtime.tool.AgentToolContext;
|
||||||
|
import com.easyagents.agent.runtime.tool.AgentToolResult;
|
||||||
import com.easyagents.core.model.chat.tool.Parameter;
|
import com.easyagents.core.model.chat.tool.Parameter;
|
||||||
import com.easyagents.core.model.chat.tool.Tool;
|
import com.easyagents.core.model.chat.tool.Tool;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -128,6 +130,40 @@ public class AgentToolRuntimeCompilerTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证同步工具异常对外只返回稳定消息,不暴露底层连接细节。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入依赖失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void syncToolFailureShouldReturnSanitizedMessage() throws Exception {
|
||||||
|
AgentToolRuntimeCompiler compiler = compiler();
|
||||||
|
setField(compiler, "workflowToolExecutor", new WorkflowToolExecutor(null) {
|
||||||
|
@Override
|
||||||
|
public Tool buildTool(Workflow workflow) {
|
||||||
|
return testTool(workflow.getEnglishName(), workflow.getDescription());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AgentToolExecutionResult execute(Workflow workflow, Map<String, Object> arguments) {
|
||||||
|
throw new IllegalStateException("jdbc:mysql://internal:3306 secret-token");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
AgentToolRuntimeCompilation compilation = compiler.compile(agent(workflowBinding(null, false, "flow-sync")));
|
||||||
|
AgentToolContext context = new AgentToolContext();
|
||||||
|
context.setAgentId("agent-1");
|
||||||
|
context.setSessionId("session-1");
|
||||||
|
context.setRequestId("request-1");
|
||||||
|
context.setTraceId("trace-1");
|
||||||
|
context.setToolCallId("tool-call-1");
|
||||||
|
|
||||||
|
AgentToolResult result = compilation.getToolInvokers().get("flow-sync").invoke(Map.of(), context);
|
||||||
|
|
||||||
|
Assert.assertFalse(result.isSuccess());
|
||||||
|
Assert.assertEquals("工具执行失败,请稍后重试", result.getErrorMessage());
|
||||||
|
Assert.assertFalse(result.getModelContent().contains("internal"));
|
||||||
|
}
|
||||||
|
|
||||||
private AgentToolRuntimeCompiler compiler() throws Exception {
|
private AgentToolRuntimeCompiler compiler() throws Exception {
|
||||||
AgentToolRuntimeCompiler compiler = new AgentToolRuntimeCompiler();
|
AgentToolRuntimeCompiler compiler = new AgentToolRuntimeCompiler();
|
||||||
setField(compiler, "objectMapper", new ObjectMapper());
|
setField(compiler, "objectMapper", new ObjectMapper());
|
||||||
@@ -175,10 +211,17 @@ public class AgentToolRuntimeCompilerTest {
|
|||||||
binding.setEnabled(true);
|
binding.setEnabled(true);
|
||||||
binding.setOptionsJson(Map.of("executionMode", executionMode));
|
binding.setOptionsJson(Map.of("executionMode", executionMode));
|
||||||
binding.setResourceSnapshot(Map.of(
|
binding.setResourceSnapshot(Map.of(
|
||||||
|
"pluginItem", Map.of(
|
||||||
"id", BigInteger.valueOf(102L),
|
"id", BigInteger.valueOf(102L),
|
||||||
|
"pluginId", BigInteger.valueOf(202L),
|
||||||
"name", "插件工具",
|
"name", "插件工具",
|
||||||
"description", "调用插件",
|
"description", "调用插件",
|
||||||
"englishName", "plugin-tool"
|
"englishName", "plugin-tool"
|
||||||
|
),
|
||||||
|
"plugin", Map.of(
|
||||||
|
"id", BigInteger.valueOf(202L),
|
||||||
|
"name", "测试插件"
|
||||||
|
)
|
||||||
));
|
));
|
||||||
return binding;
|
return binding;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package tech.easyflow.agent.runtime.workspace;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Rule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.rules.TemporaryFolder;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import tech.easyflow.agent.config.AgentWorkspaceProperties;
|
||||||
|
import tech.easyflow.agent.runtime.AgentRunRegistry;
|
||||||
|
import tech.easyflow.agent.runtime.lock.AgentRunLock;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.attribute.FileTime;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link AgentWorkspaceCleanupService} 会话锁竞态保护测试。
|
||||||
|
*/
|
||||||
|
public class AgentWorkspaceCleanupServiceTest {
|
||||||
|
|
||||||
|
/** 临时工作区。 */
|
||||||
|
@Rule
|
||||||
|
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证运行已持有同一会话锁但尚未注册活动态时,清理任务无等待跳过目录。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试目录准备失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void cleanupShouldSkipInitializationWindowWhenSessionLockIsHeld() throws Exception {
|
||||||
|
Fixture fixture = fixture();
|
||||||
|
Mockito.when(fixture.runRegistry.hasActiveSession("runtime-3")).thenReturn(false);
|
||||||
|
Mockito.when(fixture.runLock.tryAcquire(BigInteger.TWO, "runtime-3")).thenReturn(null);
|
||||||
|
|
||||||
|
fixture.service.cleanup();
|
||||||
|
|
||||||
|
Assert.assertTrue(Files.isDirectory(fixture.workspace));
|
||||||
|
Mockito.verify(fixture.runLock).tryAcquire(BigInteger.TWO, "runtime-3");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证取得清理锁后仍会重检活动态并释放锁,不删除刚完成注册的目录。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试目录准备失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void cleanupShouldRecheckActiveSessionAfterAcquiringLock() throws Exception {
|
||||||
|
Fixture fixture = fixture();
|
||||||
|
AgentRunLock.Handle handle = Mockito.mock(AgentRunLock.Handle.class);
|
||||||
|
Mockito.when(fixture.runRegistry.hasActiveSession("runtime-3")).thenReturn(false, true);
|
||||||
|
Mockito.when(fixture.runLock.tryAcquire(BigInteger.TWO, "runtime-3")).thenReturn(handle);
|
||||||
|
|
||||||
|
fixture.service.cleanup();
|
||||||
|
|
||||||
|
Assert.assertTrue(Files.isDirectory(fixture.workspace));
|
||||||
|
Mockito.verify(handle).close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Fixture fixture() throws Exception {
|
||||||
|
AgentWorkspaceProperties properties = new AgentWorkspaceProperties();
|
||||||
|
properties.setRoot(temporaryFolder.newFolder("agent-workspaces").getAbsolutePath());
|
||||||
|
properties.setRetention(Duration.ofMinutes(1));
|
||||||
|
AgentWorkspaceResolver resolver = new AgentWorkspaceResolver(properties);
|
||||||
|
resolver.initialize();
|
||||||
|
Path workspace = resolver.resolve(BigInteger.ONE, BigInteger.TWO, "runtime-3");
|
||||||
|
Files.setLastModifiedTime(workspace, FileTime.from(Instant.now().minus(Duration.ofHours(2))));
|
||||||
|
Files.setLastModifiedTime(
|
||||||
|
resolver.activityFile(workspace), FileTime.from(Instant.now().minus(Duration.ofHours(2))));
|
||||||
|
AgentRunRegistry runRegistry = Mockito.mock(AgentRunRegistry.class);
|
||||||
|
AgentRunLock runLock = Mockito.mock(AgentRunLock.class);
|
||||||
|
return new Fixture(workspace, runRegistry, runLock,
|
||||||
|
new AgentWorkspaceCleanupService(resolver, properties, runRegistry, runLock));
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Fixture(Path workspace,
|
||||||
|
AgentRunRegistry runRegistry,
|
||||||
|
AgentRunLock runLock,
|
||||||
|
AgentWorkspaceCleanupService service) {
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user