feat: 完善 Agent 标准交互与安全运行时

- 接入 AG-UI 运行投影、Turn 时间线和审批隔离

- 增加 Agent Skill 冻结绑定与运行时消费闭环

- 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
2026-08-19 22:13:41 +08:00
parent 91d66e636d
commit 4e8640dcaf
241 changed files with 24382 additions and 2777 deletions

1
.gitignore vendored
View File

@@ -36,6 +36,7 @@ build/
.DS_Store .DS_Store
/.logs/ /.logs/
/logs/ /logs/
/agent-workspaces/
/.idea/ /.idea/
.logs .logs
.idea .idea

View File

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

View File

@@ -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("当前登录状态失效,请重新登录后再试");
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 草稿。
* *

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
}
/** /**
* 幂等删除指定文件;物理文件已不存在时同步清理残留记录。 * 幂等删除指定文件;物理文件已不存在时同步清理残留记录。
* *

View File

@@ -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 时抛出带有效消息的异常。
* *

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -84,12 +84,25 @@ public class AgentRuntimeCommandConsumer implements MQConsumerHandler {
} }
try { try {
if (command.getAction() == AgentRuntimeCommandAction.APPROVE) { if (command.getAction() == AgentRuntimeCommandAction.APPROVE) {
agentRunService.approveRuntimeLocal( if (command.getApprovalId() == null || command.getApprovalId().isBlank()) {
command.getRequestId(), command.getResumeToken(), command.getOperatorId(), command.getUserId()); agentRunService.approveRuntimeLocal(
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) {
agentRunService.rejectRuntimeLocal( if (command.getApprovalId() == null || command.getApprovalId().isBlank()) {
command.getRequestId(), command.getResumeToken(), command.getReason(), agentRunService.rejectRuntimeLocal(
command.getOperatorId(), command.getUserId()); command.getRequestId(), command.getResumeToken(), command.getReason(),
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());

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()) {
private Tool buildTool(AgentToolBinding binding) { if (!names.add(spec.getName())) {
AgentToolType type = AgentToolType.from(binding.getToolType()); throw new BusinessException("Agent Tool 运行名冲突:" + spec.getName());
if (type == AgentToolType.WORKFLOW) {
Workflow workflow = snapshotOrPublishedWorkflow(binding);
if (workflow == null) {
throw new BusinessException("绑定工作流不存在");
} }
return new WorkflowTool( toolSpecs.add(spec);
workflow,
true,
PublishedWorkflowDefinitionIds.published(String.valueOf(workflow.getId()))
);
} }
if (type == AgentToolType.PLUGIN) { List<McpSpec> mcpSpecs = new ArrayList<>(direct.getMcpSpecs());
PluginItem pluginItem = snapshotOrCurrentPlugin(binding); mcpSpecs.addAll(skills.getMcpSpecs());
if (pluginItem == null) { assertToolBudget(toolSpecs, mcpSpecs);
throw new BusinessException("绑定插件不存在"); Map<String, com.easyagents.agent.runtime.tool.AgentToolInvoker> invokers =
new LinkedHashMap<>(direct.getToolInvokers());
skills.getToolInvokers().forEach((name, invoker) -> {
if (invokers.putIfAbsent(name, invoker) != null) {
throw new BusinessException("Agent Tool 运行名冲突:" + name);
} }
return pluginItem.toFunction(); });
} definition.setToolSpecs(toolSpecs);
throw new BusinessException("不支持的 Agent 工具类型:" + type.name()); definition.setMcpSpecs(mcpSpecs);
definition.setSkillBoxSpec(skills.getSkillBoxSpec());
bundle.setToolInvokers(invokers);
} }
private McpSpec buildMcpSpec(AgentToolBinding binding) { private void validateBuiltinTools(AgentDefinition definition, AgentBuiltinToolsConfig config) {
Mcp mcp = snapshotOrCurrentMcp(binding); Set<String> builtinNames = builtinToolNames(config);
if (mcp == null) { assertNoBuiltinNameConflict(definition, builtinNames);
throw new BusinessException("绑定 MCP 不存在"); List<AgentToolSpec> specs = new ArrayList<>(definition.getToolSpecs());
specs.addAll(buildOperateBudgetSpecs(config));
if (config.artifactPublish().enabled()) {
specs.add(buildArtifactPublishSpec(config.artifactPublish()));
} }
Map.Entry<String, Map<String, Object>> server = firstMcpServer(mcp); assertToolBudget(specs, definition.getMcpSpecs());
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) { private void attachBuiltinTools(Agent agent,
if (Boolean.TRUE.equals(binding.getHitlEnabled())) { AgentRuntimeContext runtimeContext,
spec.setApprovalRequired(true); boolean draftMode,
spec.setApprovalRequest(buildBindingApprovalRequest(binding)); 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()) {
private AgentToolApprovalRequest buildMcpApprovalRequest(Mcp mcp) { return;
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()); Path workspace;
spec.getMetadata().put("targetId", binding.getTargetId());
return spec;
}
private Map<String, Object> sanitizedHitlMetadata(Map<String, Object> config) {
Map<String, Object> metadata = new LinkedHashMap<>();
if (config != null) {
config.forEach((key, value) -> {
if (!isHitlPromptKey(key)) {
metadata.put(key, value);
}
});
}
return metadata;
}
private boolean isHitlPromptKey(String key) {
if (key == null) {
return false;
}
String normalized = key.trim();
return "prompt".equalsIgnoreCase(normalized)
|| "question".equalsIgnoreCase(normalized)
|| "approvalPrompt".equalsIgnoreCase(normalized);
}
private AgentToolResult invokeTool(Tool tool, Map<String, Object> arguments) {
String toolName = tool == null ? null : tool.getName();
LOG.info("Agent tool invoke started, toolName={}, arguments={}", toolName, arguments);
try { 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());

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 会话归属不完整");
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
package tech.easyflow.agent.runtime.artifact;
/**
* Agent Artifact 跨数据库与对象存储的状态。
*/
public enum AgentArtifactStatus {
/** 正在上传。 */
PUBLISHING,
/** 可下载。 */
AVAILABLE,
/** 发布失败。 */
FAILED,
/** 等待删除。 */
DELETE_PENDING,
/** 删除失败且等待重试。 */
DELETE_FAILED,
/** 已删除。 */
DELETED
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 运行锁句柄。
*/ */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {
/** /**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 的可见范围。
* *

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
) {
}
/** /**
* 模型安全选择项。 * 模型安全选择项。
* *

View File

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

View File

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

View File

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

View File

@@ -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");
invoke(service, "handleRuntimeEvent", formalEvent.getPayload().put("input", Map.of("apiKey", "sentinel-secret-input"));
runtimeEventParameterTypes(), formalEvent.getMetadata().put("authorization", "sentinel-secret-metadata");
formalEvent, "request-formal", new RecordingChatSseEmitter(), new StringBuilder(), RecordingChatSseEmitter formalEmitter = new RecordingChatSseEmitter();
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), true); 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",
runtimeEventParameterTypes(),
formalEvent, "request-formal", legacyOutput(formalEmitter), new StringBuilder(),
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;
} }
} }

View File

@@ -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));
}
}
/** /**
* 创建测试模型。 * 创建测试模型。
* *

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 的记录。
*/ */

View File

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

View File

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

View File

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

View File

@@ -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(
"id", BigInteger.valueOf(102L), "pluginItem", Map.of(
"name", "插件工具", "id", BigInteger.valueOf(102L),
"description", "调用插件", "pluginId", BigInteger.valueOf(202L),
"englishName", "plugin-tool" "name", "插件工具",
"description", "调用插件",
"englishName", "plugin-tool"
),
"plugin", Map.of(
"id", BigInteger.valueOf(202L),
"name", "测试插件"
)
)); ));
return binding; return binding;
} }

View File

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