From c7d410d7559847f7e182b1d29e7d3e69bd513b6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Wed, 19 Aug 2026 21:51:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20Agent=20Skill=20?= =?UTF-8?q?=E6=B8=90=E8=BF=9B=E6=8A=AB=E9=9C=B2=E8=BF=90=E8=A1=8C=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持 Skill 绑定 MCP 冻结清单和延迟注册 - 拒绝同步工具工作流进入不可恢复挂起状态 --- .../agentscope/AgentScopeReActRuntime.java | 33 +- .../agentscope/AgentScopeSkillAdapter.java | 56 ++- .../agentscope/AgentScopeToolAdapter.java | 1 + .../observer/SkillExecutionObserver.java | 3 + .../event/observer/ToolExecutionObserver.java | 40 +-- .../runtime/mcp/FrozenMcpClientWrapper.java | 155 ++++++++ .../agent/runtime/mcp/McpRegistration.java | 26 ++ .../runtime/mcp/McpSkillRegistration.java | 88 +++++ .../easyagents/agent/runtime/mcp/McpSpec.java | 59 ++++ .../agent/runtime/mcp/McpToolManifest.java | 332 ++++++++++++++++++ .../runtime/mcp/McpToolManifestEntry.java | 105 ++++++ .../agent/runtime/mcp/McpToolkitAdapter.java | 79 ++++- .../runtime/skill/AgentSkillBinding.java | 29 +- .../runtime/skill/AgentSkillLoadCall.java | 34 ++ .../skill/AgentSkillRuntimeContext.java | 11 +- .../runtime/mcp/McpToolManifestTest.java | 168 +++++++++ .../runtime/mcp/McpToolkitAdapterTest.java | 137 ++++++++ .../core/chain/runtime/ChainExecutor.java | 51 ++- .../test/ChainExecutorConcurrencyTest.java | 69 ++++ 19 files changed, 1417 insertions(+), 59 deletions(-) create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/FrozenMcpClientWrapper.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpSkillRegistration.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpToolManifest.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpToolManifestEntry.java create mode 100644 easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/mcp/McpToolManifestTest.java diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeReActRuntime.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeReActRuntime.java index b59265b..a013812 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeReActRuntime.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeReActRuntime.java @@ -18,6 +18,7 @@ import com.easyagents.agent.runtime.knowledge.citation.AgentKnowledgeCitationMat import com.easyagents.agent.runtime.knowledge.citation.HeuristicKnowledgeCitationMatcher; import com.easyagents.agent.runtime.message.*; import com.easyagents.agent.runtime.mcp.McpRegistration; +import com.easyagents.agent.runtime.mcp.McpSkillRegistration; import com.easyagents.agent.runtime.mcp.McpSpecValidator; import com.easyagents.agent.runtime.mcp.McpToolkitAdapter; import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore; @@ -218,7 +219,7 @@ public class AgentScopeReActRuntime implements AgentRuntime { saveSession(); return Flux.just(started(executionContext), cancelled(executionContext)); }).doOnNext(event -> executionContext.getConversationRecorder().record(executionContext, event)) - .doFinally(signalType -> cleanupTurn()); + .doFinally(signalType -> cleanupStreamSegment(false)); } return runAgentStreamAfterLock(executionContext, List::of); }); @@ -238,6 +239,8 @@ public class AgentScopeReActRuntime implements AgentRuntime { if (!running.compareAndSet(false, true)) { return Flux.error(new AgentRuntimeException("Agent runtime is already streaming.")); } + // 新用户消息建立新的 Turn,上一 Turn 的 MCP 级批准不能跨轮复用。 + approvalCoordinator.clearReusableApprovalScopes(); return runAgentStreamAfterLock(executionContext, inputSupplier); }); } @@ -296,8 +299,8 @@ public class AgentScopeReActRuntime implements AgentRuntime { .doOnNext(event -> executionContext.getConversationRecorder().record(executionContext, event)) // 处理中断请求 .doOnCancel(() -> cancelInternal(executionContext, sideEvents, finalText, finalMessage, cancelled)) - // 释放运行锁并清掉 turn context。 - .doFinally(signalType -> cleanupTurn()); + // HITL 挂起时保留当前 Turn 的 MCP 批准,其余终态完整清理。 + .doFinally(signalType -> cleanupStreamSegment(suspendedEvent.get() != null)); } /** @@ -582,7 +585,7 @@ public class AgentScopeReActRuntime implements AgentRuntime { suspended.getMetadata().put("approvalStatus", AgentToolApprovalResolution.Status.WAITING.name()); return Flux.just(started(context), suspended) .doOnNext(event -> context.getConversationRecorder().record(context, event)) - .doFinally(signalType -> cleanupTurn()); + .doFinally(signalType -> cleanupStreamSegment(true)); } /** @@ -791,10 +794,15 @@ public class AgentScopeReActRuntime implements AgentRuntime { } /** - * 清理本轮状态。 + * 清理一次 stream/resume 片段状态。 + * + * @param preserveReusableApprovalScopes 是否因 HITL 挂起而保留当前 Turn 的 MCP 批准 */ - private void cleanupTurn() { + private void cleanupStreamSegment(boolean preserveReusableApprovalScopes) { approvalCoordinator.clearExecutionAuthorizations(); + if (!preserveReusableApprovalScopes) { + approvalCoordinator.clearReusableApprovalScopes(); + } turnContextHolder.clear(); running.set(false); } @@ -1123,7 +1131,8 @@ public class AgentScopeReActRuntime implements AgentRuntime { AgentScopeMemoryBuildResult memoryResult = memoryAdapter.createMemoryResult(null, definition.getMemoryPolicy(), model); Memory memory = memoryResult.getMemory(); Knowledge knowledge = knowledgeAdapter.createAggregateKnowledge(context, turnContextHolder); - SkillBox skillBox = skillAdapter.createSkillBox(definition.getSkillBoxSpec(), toolkit, skillTools); + SkillBox skillBox = skillAdapter.createSkillBox(definition.getSkillBoxSpec(), toolkit, skillTools, + toolkitBuildResult.skillMcpRegistrations()); // AutoContextInterceptor 是官方 AutoContextHook 的替代实现。这里仍只注册统一 runtime hook, // 避免官方 hook 与 Easy-Agents interceptor 同时触发压缩和 inputMessages 改写。 AgentRuntimeEventBridge eventBridge = new AgentRuntimeEventBridge(context, turnContextHolder); @@ -1202,7 +1211,7 @@ public class AgentScopeReActRuntime implements AgentRuntime { Toolkit toolkit) { Map> skillTools = new LinkedHashMap<>(); if (!context.getAgentDefinition().getExecutionOptions().isToolCallingEnabled()) { - return new AgentScopeToolkitBuildResult(skillTools, List.of(), List.of()); + return new AgentScopeToolkitBuildResult(skillTools, List.of(), List.of(), List.of()); } for (AgentToolSpec toolSpec : context.getAgentDefinition().getToolSpecs()) { AgentToolInvoker invoker = context.getToolInvokers().get(toolSpec.getName()); @@ -1222,7 +1231,8 @@ public class AgentScopeReActRuntime implements AgentRuntime { context.getAgentDefinition().getOperateToolSpecs(), toolkit); McpSpecValidator.validateToolConflicts(context.getAgentDefinition().getToolSpecs(), mcpRegistration.getToolSpecs(), context.getAgentDefinition().getOperateToolSpecs()); - return new AgentScopeToolkitBuildResult(skillTools, mcpRegistration.getToolSpecs(), operateToolSpecs); + return new AgentScopeToolkitBuildResult(skillTools, mcpRegistration.getToolSpecs(), operateToolSpecs, + mcpRegistration.getSkillRegistrations()); } private List mergeToolSpecs(List toolSpecs, @@ -1290,7 +1300,8 @@ public class AgentScopeReActRuntime implements AgentRuntime { } private record AgentScopeToolkitBuildResult(Map> skillTools, - List mcpToolSpecs, - List operateToolSpecs) { + List mcpToolSpecs, + List operateToolSpecs, + List skillMcpRegistrations) { } } diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeSkillAdapter.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeSkillAdapter.java index 0a2e9bb..3be5391 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeSkillAdapter.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeSkillAdapter.java @@ -4,12 +4,14 @@ import com.easyagents.agent.runtime.AgentRuntimeException; import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec; import com.easyagents.agent.runtime.skill.AgentSkillCompiler; import com.easyagents.agent.runtime.skill.AgentSkillSpec; +import com.easyagents.agent.runtime.mcp.McpSkillRegistration; import io.agentscope.core.skill.AgentSkill; import io.agentscope.core.skill.SkillBox; import io.agentscope.core.tool.AgentTool; import io.agentscope.core.tool.Toolkit; import java.util.List; +import java.util.LinkedHashMap; import java.util.Map; /** @@ -67,6 +69,22 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler { * @return SkillBox;未配置 Skill 时返回 null */ public SkillBox createSkillBox(AgentSkillBoxSpec spec, Toolkit toolkit, Map> skillTools) { + return createSkillBox(spec, toolkit, skillTools, List.of()); + } + + /** + * 创建并绑定静态工具及 MCP 工具的 AgentScope SkillBox。 + * + * @param spec SkillBox 声明 + * @param toolkit Toolkit 实例 + * @param skillTools 按 Skill ID 分组的静态工具 + * @param skillMcpRegistrations 按 Skill 延迟激活的 MCP client + * @return SkillBox;未配置 Skill 时返回 null + */ + public SkillBox createSkillBox(AgentSkillBoxSpec spec, + Toolkit toolkit, + Map> skillTools, + List skillMcpRegistrations) { if (spec == null || spec.getSkills().isEmpty()) { return null; } @@ -74,10 +92,15 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler { ? new SkillBox(toolkit) : new SkillBox(toolkit, spec.getSkillBoxId()); skillBox.setExposeAllSkillMetadata(spec.isExposeAllSkillMetadata()); + Map> mcpBySkill = groupMcpRegistrations(skillMcpRegistrations); for (AgentSkillSpec skillSpec : spec.getSkills()) { AgentSkill skill = compile(skillSpec); List tools = skillTools == null ? List.of() : skillTools.getOrDefault(skillSpec.getSkillId(), List.of()); - if (tools.isEmpty()) { + List mcpRegistrations = mcpBySkill.remove(skillSpec.getSkillId()); + if (mcpRegistrations == null) { + mcpRegistrations = List.of(); + } + if (tools.isEmpty() && mcpRegistrations.isEmpty()) { skillBox.registration() .skill(skill) .toolkit(toolkit) @@ -97,11 +120,42 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler { .agentTool(tool) .apply(); } + for (McpSkillRegistration mcpRegistration : mcpRegistrations) { + skillBox.registration() + .skill(skill) + .toolkit(toolkit) + .enableTools(mcpRegistration.getEnableTools()) + .disableTools(mcpRegistration.getDisableTools()) + .presetParameters(mcpRegistration.getPresetParameters()) + .mcpClient(mcpRegistration.getClient()) + .apply(); + } + } + if (!mcpBySkill.isEmpty()) { + throw new AgentRuntimeException("Skill-bound MCP references unknown skill: " + + mcpBySkill.keySet().iterator().next()); } skillBox.syncToolGroupStates(); return skillBox; } + private Map> groupMcpRegistrations( + List registrations) { + Map> grouped = new LinkedHashMap<>(); + if (registrations == null) { + return grouped; + } + for (McpSkillRegistration registration : registrations) { + if (registration == null || registration.getSkillId() == null + || registration.getSkillId().isBlank()) { + throw new AgentRuntimeException("Skill-bound MCP skill id is required."); + } + grouped.computeIfAbsent(registration.getSkillId(), key -> new java.util.ArrayList<>()) + .add(registration); + } + return grouped; + } + /** * 校验 Skill 声明是否具备 AgentScope 注册和模型提示所需的必要信息。 * diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeToolAdapter.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeToolAdapter.java index ccf460b..ccc236d 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeToolAdapter.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeToolAdapter.java @@ -561,6 +561,7 @@ public class AgentScopeToolAdapter { } target.put("skillId", binding.getSkillId()); target.put("skillName", binding.getSkillName()); + target.put("skillDisplayName", binding.getSkillDisplayName()); target.put("skillBoxId", binding.getSkillBoxId()); } diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/SkillExecutionObserver.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/SkillExecutionObserver.java index a2e9740..58b3281 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/SkillExecutionObserver.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/SkillExecutionObserver.java @@ -270,10 +270,12 @@ public class SkillExecutionObserver implements AgentRuntimeObserver { } event.getPayload().put("skillId", call.getSkillId()); event.getPayload().put("skillName", call.getSkillName()); + event.getPayload().put("skillDisplayName", call.getSkillDisplayName()); event.getPayload().put("skillBoxId", call.getSkillBoxId()); event.getPayload().put("path", call.getPath()); event.getMetadata().put("skillId", call.getSkillId()); event.getMetadata().put("skillName", call.getSkillName()); + event.getMetadata().put("skillDisplayName", call.getSkillDisplayName()); event.getMetadata().put("skillBoxId", call.getSkillBoxId()); } @@ -283,6 +285,7 @@ public class SkillExecutionObserver implements AgentRuntimeObserver { } target.put("skillId", binding.getSkillId()); target.put("skillName", binding.getSkillName()); + target.put("skillDisplayName", binding.getSkillDisplayName()); target.put("skillBoxId", binding.getSkillBoxId()); } diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/ToolExecutionObserver.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/ToolExecutionObserver.java index 14b39c7..0f8f9af 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/ToolExecutionObserver.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/ToolExecutionObserver.java @@ -9,8 +9,6 @@ import com.easyagents.agent.runtime.tool.AgentToolSpec; import io.agentscope.core.hook.HookEvent; import io.agentscope.core.hook.PostActingEvent; import io.agentscope.core.hook.PreActingEvent; -import io.agentscope.core.message.ContentBlock; -import io.agentscope.core.message.TextBlock; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolUseBlock; import reactor.core.publisher.Mono; @@ -103,12 +101,7 @@ public class ToolExecutionObserver implements AgentRuntimeObserver { runtimeEvent.getPayload().put("toolCallId", toolUse.getId()); runtimeEvent.getPayload().put("name", toolUse.getName()); runtimeEvent.getPayload().put("toolName", toolUse.getName()); - runtimeEvent.getPayload().put("input", toolUse.getInput()); - runtimeEvent.getPayload().put("content", toolUse.getContent()); runtimeEvent.getPayload().put("status", "RUNNING"); - runtimeEvent.getPayload().put("source", "HOOK"); - runtimeEvent.getPayload().put("phase", "PRE_ACTING"); - runtimeEvent.getMetadata().putAll(nullToEmpty(toolUse.getMetadata())); enrichToolPayload(runtimeEvent, toolUse.getName()); eventBridge.emit(runtimeEvent); } @@ -129,15 +122,8 @@ public class ToolExecutionObserver implements AgentRuntimeObserver { runtimeEvent.getPayload().put("toolCallId", toolCallId); runtimeEvent.getPayload().put("name", toolName); runtimeEvent.getPayload().put("toolName", toolName); - runtimeEvent.getPayload().put("text", resultText(result)); - runtimeEvent.getPayload().put("suspended", result != null && result.isSuspended()); runtimeEvent.getPayload().put("status", success(result) ? "SUCCESS" : "FAILED"); runtimeEvent.getPayload().put("success", success(result)); - runtimeEvent.getPayload().put("source", "HOOK"); - runtimeEvent.getPayload().put("phase", "POST_ACTING"); - if (result != null) { - runtimeEvent.getMetadata().putAll(nullToEmpty(result.getMetadata())); - } enrichToolPayload(runtimeEvent, toolName); eventBridge.emit(runtimeEvent); } @@ -149,12 +135,7 @@ public class ToolExecutionObserver implements AgentRuntimeObserver { } Map metadata = toolSpec.getMetadata(); putIfPresent(runtimeEvent.getPayload(), metadata, "toolDisplayName"); - putIfPresent(runtimeEvent.getPayload(), metadata, "rawMcpToolName"); - putIfPresent(runtimeEvent.getPayload(), metadata, "mcpToolName"); - putIfPresent(runtimeEvent.getPayload(), metadata, "mcpName"); - putIfPresent(runtimeEvent.getPayload(), metadata, "mcpTitle"); - putIfPresent(runtimeEvent.getPayload(), metadata, "source"); - runtimeEvent.getMetadata().putAll(metadata); + putIfPresent(runtimeEvent.getPayload(), metadata, "skillId"); } private void putIfPresent(Map payload, Map metadata, String key) { @@ -171,25 +152,6 @@ public class ToolExecutionObserver implements AgentRuntimeObserver { return !(success instanceof Boolean) || Boolean.TRUE.equals(success); } - private String resultText(ToolResultBlock result) { - if (result == null || result.getOutput() == null || result.getOutput().isEmpty()) { - return ""; - } - StringBuilder builder = new StringBuilder(); - for (ContentBlock block : result.getOutput()) { - if (block instanceof TextBlock textBlock) { - builder.append(textBlock.getText()); - } else { - builder.append(block); - } - } - return builder.toString(); - } - - private Map nullToEmpty(Map map) { - return map == null ? new LinkedHashMap<>() : map; - } - private boolean isSkillTool(String toolName) { if (skillContext == null) { return false; diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/FrozenMcpClientWrapper.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/FrozenMcpClientWrapper.java new file mode 100644 index 0000000..6dad341 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/FrozenMcpClientWrapper.java @@ -0,0 +1,155 @@ +package com.easyagents.agent.runtime.mcp; + +import io.agentscope.core.tool.mcp.McpClientWrapper; +import io.modelcontextprotocol.spec.McpSchema; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 将一次验证通过的远端 MCP Tool 清单冻结为只读白名单视图。 + */ +final class FrozenMcpClientWrapper extends McpClientWrapper { + + private final McpClientWrapper delegate; + private final List frozenTools; + private final Map runtimeToRaw = new LinkedHashMap<>(); + + /** + * 创建冻结 MCP client 视图。 + * + * @param delegate 原始 client + * @param actualTools 已一次性读取并验证的远端 Tool + * @param manifest 冻结清单 + * @param aliases 显式运行别名 + * @param prefix 运行名前缀 + */ + FrozenMcpClientWrapper(McpClientWrapper delegate, + List actualTools, + List manifest, + Map aliases, + String prefix) { + super(delegate == null ? "mcp" : delegate.getName()); + this.delegate = delegate; + this.frozenTools = freeze(actualTools, manifest, aliases, prefix); + this.frozenTools.forEach(tool -> cachedTools.put(tool.name(), tool)); + } + + /** {@inheritDoc} */ + @Override + public Mono initialize() { + return delegate.initialize().doOnSuccess(ignored -> initialized = delegate.isInitialized()); + } + + /** {@inheritDoc} */ + @Override + public Mono> listTools() { + return Mono.just(frozenTools); + } + + /** {@inheritDoc} */ + @Override + public Mono callTool(String toolName, Map arguments) { + return delegate.callTool(runtimeToRaw.getOrDefault(toolName, toolName), arguments); + } + + /** {@inheritDoc} */ + @Override + public void close() { + delegate.close(); + initialized = false; + } + + /** + * 按冻结 manifest 顺序裁剪并应用稳定运行别名。 + * + * @param actualTools 远端当前 Tool + * @param manifest 冻结清单 + * @param aliases 显式别名 + * @param prefix 动态前缀 + * @return 不可变 Tool 白名单 + */ + private List freeze(List actualTools, + List manifest, + Map aliases, + String prefix) { + Map actualByName = new LinkedHashMap<>(); + if (actualTools != null) { + actualTools.stream().filter(tool -> tool != null && tool.name() != null) + .forEach(tool -> actualByName.put(tool.name(), tool)); + } + Map usedRuntimeNames = new LinkedHashMap<>(); + List result = new ArrayList<>(); + for (McpToolManifestEntry entry : manifest) { + McpSchema.Tool actual = actualByName.get(entry.getName()); + if (actual == null) { + throw new IllegalStateException("Frozen MCP tool is missing after validation: " + entry.getName()); + } + String runtimeName = uniqueRuntimeName( + runtimeName(actual.name(), aliases, prefix), actual.name(), usedRuntimeNames); + runtimeToRaw.put(runtimeName, actual.name()); + Map meta = new LinkedHashMap<>(); + if (actual.meta() != null) { + meta.putAll(actual.meta()); + } + if (!runtimeName.equals(actual.name())) { + meta.put(AliasedMcpClientWrapper.RAW_TOOL_NAME_META_KEY, actual.name()); + } + // 模型可见描述也必须来自发布时冻结清单,避免远端描述在运行中漂移。 + result.add(new McpSchema.Tool(runtimeName, actual.title(), entry.getDescription(), + actual.inputSchema(), actual.outputSchema(), actual.annotations(), meta)); + } + return List.copyOf(result); + } + + /** + * 计算单个 Tool 的运行名。 + * + * @param rawName 原始名称 + * @param aliases 显式别名 + * @param prefix 动态前缀 + * @return 运行名 + */ + private String runtimeName(String rawName, Map aliases, String prefix) { + String alias = aliases == null ? null : aliases.get(rawName); + if (alias != null && !alias.isBlank()) { + return alias; + } + if (prefix == null || prefix.isBlank()) { + return rawName; + } + String segment = String.valueOf(rawName == null ? "" : rawName).trim() + .replaceAll("[^A-Za-z0-9_-]", "_") + .replaceAll("_+", "_"); + return prefix.trim() + (segment.isBlank() ? "tool" : segment); + } + + /** + * 避免别名碰撞。 + * + * @param candidate 候选运行名 + * @param rawName 原始名称 + * @param used 已使用运行名 + * @return 唯一运行名 + */ + private String uniqueRuntimeName(String candidate, + String rawName, + Map used) { + String existing = used.get(candidate); + if (existing == null || existing.equals(rawName)) { + used.put(candidate, rawName); + return candidate; + } + int suffix = 2; + String value = candidate + "_" + suffix; + while (used.containsKey(value)) { + suffix++; + value = candidate + "_" + suffix; + } + used.put(value, rawName); + return value; + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpRegistration.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpRegistration.java index 2550406..32952c8 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpRegistration.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpRegistration.java @@ -13,6 +13,7 @@ public class McpRegistration { private final List clients; private final List toolSpecs; + private final List skillRegistrations; /** * 创建 MCP 注册结果。 @@ -21,8 +22,24 @@ public class McpRegistration { * @param toolSpecs 已注册工具声明 */ public McpRegistration(List clients, List toolSpecs) { + this(clients, toolSpecs, List.of()); + } + + /** + * 创建 MCP 注册结果。 + * + * @param clients 已创建 MCP client + * @param toolSpecs 已发现工具声明 + * @param skillRegistrations 等待注册到 Skill 的 MCP client + */ + public McpRegistration(List clients, + List toolSpecs, + List skillRegistrations) { this.clients = clients == null ? List.of() : new ArrayList<>(clients); this.toolSpecs = toolSpecs == null ? List.of() : new ArrayList<>(toolSpecs); + this.skillRegistrations = skillRegistrations == null + ? List.of() + : new ArrayList<>(skillRegistrations); } /** @@ -51,4 +68,13 @@ public class McpRegistration { public List getToolSpecs() { return toolSpecs; } + + /** + * 获取等待注册到 Skill 的 MCP client。 + * + * @return Skill MCP 注册声明 + */ + public List getSkillRegistrations() { + return skillRegistrations; + } } diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpSkillRegistration.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpSkillRegistration.java new file mode 100644 index 0000000..63bf01d --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpSkillRegistration.java @@ -0,0 +1,88 @@ +package com.easyagents.agent.runtime.mcp; + +import io.agentscope.core.tool.mcp.McpClientWrapper; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 等待注册到指定 Skill 的 MCP client。 + */ +public class McpSkillRegistration { + + private final String skillId; + private final McpClientWrapper client; + private final List enableTools; + private final List disableTools; + private final Map> presetParameters; + + /** + * 创建 Skill MCP 注册声明。 + * + * @param skillId Skill ID + * @param client MCP client + * @param enableTools 运行时工具白名单 + * @param disableTools 运行时工具黑名单 + * @param presetParameters 预设参数 + */ + public McpSkillRegistration(String skillId, + McpClientWrapper client, + List enableTools, + List disableTools, + Map> presetParameters) { + this.skillId = skillId; + this.client = client; + this.enableTools = enableTools == null ? List.of() : new ArrayList<>(enableTools); + this.disableTools = disableTools == null ? List.of() : new ArrayList<>(disableTools); + this.presetParameters = presetParameters == null + ? Map.of() + : new LinkedHashMap<>(presetParameters); + } + + /** + * 获取 Skill ID。 + * + * @return Skill ID + */ + public String getSkillId() { + return skillId; + } + + /** + * 获取 MCP client。 + * + * @return MCP client + */ + public McpClientWrapper getClient() { + return client; + } + + /** + * 获取运行时工具白名单。 + * + * @return 工具白名单 + */ + public List getEnableTools() { + return enableTools; + } + + /** + * 获取运行时工具黑名单。 + * + * @return 工具黑名单 + */ + public List getDisableTools() { + return disableTools; + } + + /** + * 获取预设参数。 + * + * @return 预设参数 + */ + public Map> getPresetParameters() { + return presetParameters; + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpSpec.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpSpec.java index 8ebb80b..c585898 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpSpec.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpSpec.java @@ -33,6 +33,9 @@ public class McpSpec { private boolean approvalRequired; private AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest(); private Map toolApprovalRequests = new LinkedHashMap<>(); + private String skillId; + private List frozenToolManifest = new ArrayList<>(); + private String frozenToolManifestHash; private Map metadata = new LinkedHashMap<>(); /** @@ -406,6 +409,62 @@ public class McpSpec { : new LinkedHashMap<>(toolApprovalRequests); } + /** + * 获取所属 Skill ID。 + * + * @return Skill ID;未绑定 Skill 时为空 + */ + public String getSkillId() { + return skillId; + } + + /** + * 设置所属 Skill ID。 + * + * @param skillId Skill ID + */ + public void setSkillId(String skillId) { + this.skillId = skillId; + } + + /** + * 获取冻结 Tool 清单。 + * + * @return 冻结 Tool 清单 + */ + public List getFrozenToolManifest() { + return frozenToolManifest; + } + + /** + * 设置冻结 Tool 清单。 + * + * @param frozenToolManifest 冻结 Tool 清单 + */ + public void setFrozenToolManifest(List frozenToolManifest) { + this.frozenToolManifest = frozenToolManifest == null + ? new ArrayList<>() + : new ArrayList<>(frozenToolManifest); + } + + /** + * 获取冻结 Tool 清单 hash。 + * + * @return 清单 hash + */ + public String getFrozenToolManifestHash() { + return frozenToolManifestHash; + } + + /** + * 设置冻结 Tool 清单 hash。 + * + * @param frozenToolManifestHash 清单 hash + */ + public void setFrozenToolManifestHash(String frozenToolManifestHash) { + this.frozenToolManifestHash = frozenToolManifestHash; + } + /** * 获取元数据。 * diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpToolManifest.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpToolManifest.java new file mode 100644 index 0000000..ac8fa4d --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpToolManifest.java @@ -0,0 +1,332 @@ +package com.easyagents.agent.runtime.mcp; + +import com.alibaba.fastjson2.JSON; +import com.easyagents.agent.runtime.AgentRuntimeException; +import io.modelcontextprotocol.spec.McpSchema; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +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; + +/** + * MCP Tool 冻结清单规范化与完整性校验器。 + */ +public final class McpToolManifest { + + /** MCP Tool 原始名称允许的最大 Unicode 字符数。 */ + public static final int MAX_TOOL_NAME_LENGTH = 128; + + /** MCP Tool 描述允许的最大 Unicode 字符数。 */ + public static final int MAX_TOOL_DESCRIPTION_LENGTH = 4_096; + + /** 单个输入或输出 Schema 允许的最大 UTF-8 字节数。 */ + public static final int MAX_SCHEMA_UTF8_BYTES = 256 * 1_024; + + /** 完整规范化 Manifest 允许的最大 UTF-8 字节数。 */ + public static final int MAX_MANIFEST_UTF8_BYTES = 2 * 1_024 * 1_024; + + private McpToolManifest() { + } + + /** + * 将 MCP Tool 转换为稳定清单项。 + * + * @param tools MCP Tool 列表 + * @return 按名称稳定排序的清单 + */ + public static List fromTools(List tools) { + if (tools == null || tools.isEmpty()) { + return List.of(); + } + List entries = new ArrayList<>(); + Set names = new HashSet<>(); + int manifestBytes = 2; + for (McpSchema.Tool tool : tools) { + if (tool == null || tool.name() == null || tool.name().isBlank()) { + continue; + } + if (!names.add(tool.name())) { + throw new AgentRuntimeException("Duplicate MCP tool name: " + tool.name()); + } + McpToolManifestEntry entry = new McpToolManifestEntry(); + entry.setName(tool.name()); + entry.setDescription(normalizeText(tool.description())); + entry.setInputSchema(normalizeSchema("MCP tool input schema", tool.inputSchema())); + entry.setOutputSchema(normalizeSchema("MCP tool output schema", tool.outputSchema())); + assertEntryBounds(entry); + manifestBytes += JSON.toJSONString(toCanonicalValue(entry)) + .getBytes(StandardCharsets.UTF_8).length; + if (!entries.isEmpty()) { + manifestBytes++; + } + if (manifestBytes > MAX_MANIFEST_UTF8_BYTES) { + throw new AgentRuntimeException("MCP tool manifest exceeds " + + MAX_MANIFEST_UTF8_BYTES + " UTF-8 bytes."); + } + entries.add(entry); + } + entries.sort(Comparator.comparing(McpToolManifestEntry::getName)); + assertManifestSize(entries); + return List.copyOf(entries); + } + + /** + * 计算冻结清单的 SHA-256。 + * + * @param entries 冻结清单 + * @return 十六进制 SHA-256 + */ + public static String hash(List entries) { + String json = canonicalJson(entries); + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(json.getBytes(StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException error) { + throw new AgentRuntimeException("SHA-256 is unavailable for MCP manifest validation.", error); + } + } + + /** + * 校验远端 Tool 与冻结白名单一致,同时忽略远端新增 Tool。 + * + * @param spec MCP 运行时声明 + * @param actualTools 远端当前 Tool + * @throws AgentRuntimeException 冻结清单缺失、被篡改、Tool 缺失或 Schema 漂移时抛出 + */ + public static void assertFrozenManifest(McpSpec spec, List actualTools) { + if (spec == null || spec.getSkillId() == null || spec.getSkillId().isBlank()) { + return; + } + List expected = spec.getFrozenToolManifest(); + String expectedHash = spec.getFrozenToolManifestHash(); + if (expected == null || expected.isEmpty() || expectedHash == null || expectedHash.isBlank()) { + throw new AgentRuntimeException("Skill-bound MCP requires a frozen tool manifest: " + spec.getName()); + } + if (!expectedHash.equals(hash(expected))) { + throw new AgentRuntimeException("Skill-bound MCP frozen tool manifest is invalid: " + spec.getName()); + } + Set frozenNames = new HashSet<>(); + for (McpToolManifestEntry entry : expected) { + if (entry != null && entry.getName() != null && !entry.getName().isBlank()) { + frozenNames.add(entry.getName()); + } + } + Map actualByName = new LinkedHashMap<>(); + if (actualTools != null) { + for (McpSchema.Tool tool : actualTools) { + if (tool == null || tool.name() == null || !frozenNames.contains(tool.name())) { + continue; + } + if (actualByName.containsKey(tool.name())) { + throw new AgentRuntimeException("Duplicate MCP tool name: " + tool.name()); + } + List normalized = fromTools(List.of(tool)); + if (!normalized.isEmpty()) { + actualByName.put(tool.name(), normalized.get(0)); + } + } + } + for (McpToolManifestEntry expectedEntry : expected) { + if (expectedEntry == null || expectedEntry.getName() == null || expectedEntry.getName().isBlank()) { + throw new AgentRuntimeException("Skill-bound MCP frozen tool name is required: " + spec.getName()); + } + McpToolManifestEntry actualEntry = actualByName.get(expectedEntry.getName()); + if (actualEntry == null) { + throw new AgentRuntimeException("Skill-bound MCP tool is missing: " + expectedEntry.getName()); + } + if (!sameRuntimeSchema(expectedEntry, actualEntry)) { + throw new AgentRuntimeException("Skill-bound MCP tool schema has changed: " + + expectedEntry.getName()); + } + } + } + + /** + * 比较 Runtime 必须锁定的 Tool 名称及输入、输出 Schema。 + * + *

描述用于保存与发布阶段的完整 manifest 变更识别,但远端仅调整描述时不会改变 + * 已发布 Tool 的可调用边界,因此运行时不应中断既有 Agent。

+ * + * @param expected 冻结清单项 + * @param actual 远端当前清单项 + * @return 名称及 Schema 相同时返回 {@code true} + */ + private static boolean sameRuntimeSchema(McpToolManifestEntry expected, + McpToolManifestEntry actual) { + return java.util.Objects.equals(expected.getName(), actual.getName()) + && java.util.Objects.equals(normalizeJson(expected.getInputSchema()), + normalizeJson(actual.getInputSchema())) + && java.util.Objects.equals(normalizeJson(expected.getOutputSchema()), + normalizeJson(actual.getOutputSchema())); + } + + /** + * 将冻结清单转换为稳定 JSON,并对反序列化后的清单执行同等边界校验。 + * + * @param entries 冻结清单 + * @return 稳定 JSON + * @throws AgentRuntimeException 清单包含重复名称或超出预算时抛出 + */ + private static String canonicalJson(List entries) { + List> canonical = new ArrayList<>(); + Set names = new HashSet<>(); + if (entries != null) { + entries.stream() + .filter(entry -> entry != null && entry.getName() != null && !entry.getName().isBlank()) + .sorted(Comparator.comparing(McpToolManifestEntry::getName)) + .forEach(entry -> { + if (!names.add(entry.getName())) { + throw new AgentRuntimeException("Duplicate MCP tool name: " + entry.getName()); + } + McpToolManifestEntry normalized = new McpToolManifestEntry(); + normalized.setName(entry.getName()); + normalized.setDescription(normalizeText(entry.getDescription())); + normalized.setInputSchema(normalizeSchema( + "MCP tool input schema", entry.getInputSchema())); + normalized.setOutputSchema(normalizeSchema( + "MCP tool output schema", entry.getOutputSchema())); + assertEntryBounds(normalized); + canonical.add(toCanonicalValue(normalized)); + }); + } + String json = JSON.toJSONString(canonical); + assertUtf8Size("MCP tool manifest", json, MAX_MANIFEST_UTF8_BYTES); + return json; + } + + /** + * 校验单个清单项的名称、描述及 Schema 预算。 + * + * @param entry 已规范化的清单项 + * @throws AgentRuntimeException 任一字段超出预算时抛出 + */ + private static void assertEntryBounds(McpToolManifestEntry entry) { + assertTextLength("MCP tool name", entry.getName(), MAX_TOOL_NAME_LENGTH); + assertTextLength("MCP tool description", entry.getDescription(), MAX_TOOL_DESCRIPTION_LENGTH); + assertSchemaSize("MCP tool input schema", entry.getInputSchema()); + assertSchemaSize("MCP tool output schema", entry.getOutputSchema()); + } + + /** + * 校验规范化清单的聚合字节预算。 + * + * @param entries 已规范化且排序的清单 + * @throws AgentRuntimeException 清单超出聚合预算时抛出 + */ + private static void assertManifestSize(List entries) { + List> canonical = entries.stream() + .map(McpToolManifest::toCanonicalValue) + .toList(); + assertUtf8Size("MCP tool manifest", JSON.toJSONString(canonical), MAX_MANIFEST_UTF8_BYTES); + } + + /** + * 构造用于哈希和预算计算的稳定清单值。 + * + * @param entry 已规范化的清单项 + * @return 保持字段顺序的清单值 + */ + private static Map toCanonicalValue(McpToolManifestEntry entry) { + Map value = new LinkedHashMap<>(); + value.put("name", entry.getName()); + value.put("description", normalizeText(entry.getDescription())); + value.put("inputSchema", entry.getInputSchema()); + value.put("outputSchema", entry.getOutputSchema()); + return value; + } + + /** + * 校验 Unicode 字符长度,避免 UTF-16 代理对被重复计数。 + * + * @param field 字段名称 + * @param value 字段值 + * @param maxLength 最大 Unicode 字符数 + * @throws AgentRuntimeException 字段超长时抛出 + */ + private static void assertTextLength(String field, String value, int maxLength) { + if (value != null && value.codePointCount(0, value.length()) > maxLength) { + throw new AgentRuntimeException(field + " exceeds " + maxLength + " characters."); + } + } + + /** + * 校验单个 Schema 的 UTF-8 字节预算。 + * + * @param field Schema 字段名称 + * @param schema 已规范化 Schema + * @throws AgentRuntimeException Schema 超出预算时抛出 + */ + private static void assertSchemaSize(String field, Object schema) { + if (schema != null) { + assertUtf8Size(field, JSON.toJSONString(schema), MAX_SCHEMA_UTF8_BYTES); + } + } + + /** + * 校验 JSON 或文本的 UTF-8 字节长度。 + * + * @param field 字段名称 + * @param value 待校验文本 + * @param maxBytes 最大 UTF-8 字节数 + * @throws AgentRuntimeException 文本超出预算时抛出 + */ + private static void assertUtf8Size(String field, String value, int maxBytes) { + int bytes = value.getBytes(StandardCharsets.UTF_8).length; + if (bytes > maxBytes) { + throw new AgentRuntimeException(field + " exceeds " + maxBytes + " UTF-8 bytes."); + } + } + + /** + * 在解析和排序前限制原始 Schema,避免超大输入进入规范化流程。 + * + * @param field Schema 字段名称 + * @param value 原始 Schema + * @return 规范化 Schema + * @throws AgentRuntimeException 原始 Schema 超出预算时抛出 + */ + private static Object normalizeSchema(String field, Object value) { + if (value == null) { + return null; + } + String json = JSON.toJSONString(value); + assertUtf8Size(field, json, MAX_SCHEMA_UTF8_BYTES); + return sortJson(JSON.parse(json)); + } + + private static Object normalizeJson(Object value) { + if (value == null) { + return null; + } + return sortJson(JSON.parse(JSON.toJSONString(value))); + } + + private static Object sortJson(Object value) { + if (value instanceof Map source) { + Map sorted = new TreeMap<>(); + source.forEach((key, child) -> sorted.put(String.valueOf(key), sortJson(child))); + return sorted; + } + if (value instanceof List source) { + List sorted = new ArrayList<>(source.size()); + for (Object child : source) { + sorted.add(sortJson(child)); + } + return sorted; + } + return value; + } + + private static String normalizeText(String value) { + return value == null ? "" : value; + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpToolManifestEntry.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpToolManifestEntry.java new file mode 100644 index 0000000..6eb16bd --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpToolManifestEntry.java @@ -0,0 +1,105 @@ +package com.easyagents.agent.runtime.mcp; + +import java.util.Objects; + +/** + * MCP Tool 冻结清单项。 + */ +public class McpToolManifestEntry { + + private String name; + private String description; + private Object inputSchema; + private Object outputSchema; + + /** + * 获取 Tool 名称。 + * + * @return Tool 名称 + */ + public String getName() { + return name; + } + + /** + * 设置 Tool 名称。 + * + * @param name Tool 名称 + */ + public void setName(String name) { + this.name = name; + } + + /** + * 获取 Tool 描述。 + * + * @return Tool 描述 + */ + public String getDescription() { + return description; + } + + /** + * 设置 Tool 描述。 + * + * @param description Tool 描述 + */ + public void setDescription(String description) { + this.description = description; + } + + /** + * 获取输入 Schema。 + * + * @return 输入 Schema + */ + public Object getInputSchema() { + return inputSchema; + } + + /** + * 设置输入 Schema。 + * + * @param inputSchema 输入 Schema + */ + public void setInputSchema(Object inputSchema) { + this.inputSchema = inputSchema; + } + + /** + * 获取输出 Schema。 + * + * @return 输出 Schema + */ + public Object getOutputSchema() { + return outputSchema; + } + + /** + * 设置输出 Schema。 + * + * @param outputSchema 输出 Schema + */ + public void setOutputSchema(Object outputSchema) { + this.outputSchema = outputSchema; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof McpToolManifestEntry that)) { + return false; + } + return Objects.equals(name, that.name) + && Objects.equals(description, that.description) + && Objects.equals(inputSchema, that.inputSchema) + && Objects.equals(outputSchema, that.outputSchema); + } + + @Override + public int hashCode() { + return Objects.hash(name, description, inputSchema, outputSchema); + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpToolkitAdapter.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpToolkitAdapter.java index f82c0b9..ab1adb7 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpToolkitAdapter.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpToolkitAdapter.java @@ -52,6 +52,7 @@ public class McpToolkitAdapter { } List clients = new ArrayList<>(); List toolSpecs = new ArrayList<>(); + List skillRegistrations = new ArrayList<>(); try { for (McpSpec spec : specs) { if (spec == null) { @@ -59,16 +60,59 @@ public class McpToolkitAdapter { } McpSpecValidator.validateConnection(spec); McpClientWrapper client = clientFactory.create(spec); - client = applyAliases(spec, client); clients.add(client); - registerClient(spec, client, toolkit); + if (isSkillBound(spec)) { + List actualTools = initializeAndListTools(client); + McpToolManifest.assertFrozenManifest(spec, actualTools); + client = new FrozenMcpClientWrapper(client, actualTools, + spec.getFrozenToolManifest(), spec.getToolAliases(), spec.getToolNamePrefix()); + } else { + client = applyAliases(spec, client); + } + clients.set(clients.size() - 1, client); + if (isSkillBound(spec)) { + // Skill MCP 必须以冻结 manifest 派生白名单,调用方不能通过空列表放宽到远端全部 Tool。 + spec.setEnableTools(frozenRuntimeToolNames(spec, client)); + skillRegistrations.add(new McpSkillRegistration( + spec.getSkillId(), client, spec.getEnableTools(), spec.getDisableTools(), + spec.getPresetParameters())); + } else { + registerClient(spec, client, toolkit); + } toolSpecs.addAll(toToolSpecs(spec, registeredTools(spec, client))); } } catch (RuntimeException error) { closeQuietly(clients); throw error; } - return new McpRegistration(clients, toolSpecs); + return new McpRegistration(clients, toolSpecs, skillRegistrations); + } + + /** + * 根据冻结原始 Tool 名称和别名后的远端清单生成强制运行白名单。 + * + * @param spec Skill MCP 声明 + * @param client 已应用运行别名的 client + * @return 冻结 Tool 对应的运行名 + */ + private List frozenRuntimeToolNames(McpSpec spec, McpClientWrapper client) { + Set frozenRawNames = new LinkedHashSet<>(); + for (McpToolManifestEntry entry : spec.getFrozenToolManifest()) { + if (entry != null && entry.getName() != null && !entry.getName().isBlank()) { + frozenRawNames.add(entry.getName()); + } + } + List names = new ArrayList<>(); + for (McpSchema.Tool tool : listTools(client)) { + if (tool != null && frozenRawNames.contains(rawToolName(spec, tool))) { + names.add(tool.name()); + } + } + if (names.size() != frozenRawNames.size()) { + throw new AgentRuntimeException("Skill-bound MCP frozen tool aliases are incomplete: " + + spec.getName()); + } + return List.copyOf(names); } private McpClientWrapper applyAliases(McpSpec spec, McpClientWrapper client) { @@ -95,7 +139,7 @@ public class McpToolkitAdapter { } private List registeredTools(McpSpec spec, McpClientWrapper client) { - List tools = client.listTools().block(); + List tools = listTools(client); if (tools == null || tools.isEmpty()) { return List.of(); } @@ -108,6 +152,30 @@ public class McpToolkitAdapter { return filtered; } + private List listTools(McpClientWrapper client) { + List tools = client.listTools().block(); + return tools == null ? List.of() : tools; + } + + /** + * 初始化 client 后读取一次远端 Tool 清单。 + * + * @param client MCP client + * @return Tool 清单 + */ + private List initializeAndListTools(McpClientWrapper client) { + // AgentScope validates the initialized flag when listTools() is invoked. Build the + // second publisher only after initialization has completed, otherwise eager publisher + // assembly can fail even though the server initializes successfully moments later. + client.initialize().block(); + List tools = client.listTools().block(); + return tools == null ? List.of() : tools; + } + + private boolean isSkillBound(McpSpec spec) { + return spec.getSkillId() != null && !spec.getSkillId().isBlank(); + } + private boolean shouldRegister(String toolName, List enableTools, List disableTools) { if (enableTools != null && !enableTools.isEmpty()) { return enableTools.contains(toolName); @@ -166,6 +234,9 @@ public class McpToolkitAdapter { metadata.put("rawMcpToolName", rawToolName(spec, tool)); metadata.put("toolDisplayName", toolDisplayName(spec, tool)); metadata.put("transportType", spec.getTransportType().configValue()); + if (isSkillBound(spec)) { + metadata.put("skillId", spec.getSkillId()); + } return metadata; } diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/skill/AgentSkillBinding.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/skill/AgentSkillBinding.java index f009fb0..eee5af8 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/skill/AgentSkillBinding.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/skill/AgentSkillBinding.java @@ -7,6 +7,7 @@ public class AgentSkillBinding { private final String skillId; private final String skillName; + private final String skillDisplayName; private final String skillBoxId; /** @@ -17,8 +18,26 @@ public class AgentSkillBinding { * @param skillBoxId SkillBox ID */ public AgentSkillBinding(String skillId, String skillName, String skillBoxId) { + this(skillId, skillName, skillName, skillBoxId); + } + + /** + * 创建带展示名称的 Skill 绑定关系。 + * + * @param skillId Skill ID + * @param skillName Skill 规范名称 + * @param skillDisplayName Skill 展示名称 + * @param skillBoxId SkillBox ID + */ + public AgentSkillBinding(String skillId, + String skillName, + String skillDisplayName, + String skillBoxId) { this.skillId = skillId; this.skillName = skillName; + this.skillDisplayName = skillDisplayName == null || skillDisplayName.isBlank() + ? skillName + : skillDisplayName; this.skillBoxId = skillBoxId; } @@ -40,6 +59,15 @@ public class AgentSkillBinding { return skillName; } + /** + * 获取 Skill 展示名称。 + * + * @return Skill 展示名称 + */ + public String getSkillDisplayName() { + return skillDisplayName; + } + /** * 获取 SkillBox ID。 * @@ -49,4 +77,3 @@ public class AgentSkillBinding { return skillBoxId; } } - diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/skill/AgentSkillLoadCall.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/skill/AgentSkillLoadCall.java index 5684a1a..f87636e 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/skill/AgentSkillLoadCall.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/skill/AgentSkillLoadCall.java @@ -11,6 +11,7 @@ public class AgentSkillLoadCall { private final String toolCallId; private final String skillId; private final String skillName; + private final String skillDisplayName; private final String skillBoxId; private final String path; private final Map input; @@ -31,9 +32,33 @@ public class AgentSkillLoadCall { String skillBoxId, String path, Map input) { + this(toolCallId, skillId, skillName, skillName, skillBoxId, path, input); + } + + /** + * 创建带展示名称的 Skill 加载工具调用记录。 + * + * @param toolCallId 工具调用 ID + * @param skillId Skill ID + * @param skillName Skill 规范名称 + * @param skillDisplayName Skill 展示名称 + * @param skillBoxId SkillBox ID + * @param path 资源路径 + * @param input 工具输入 + */ + public AgentSkillLoadCall(String toolCallId, + String skillId, + String skillName, + String skillDisplayName, + String skillBoxId, + String path, + Map input) { this.toolCallId = toolCallId; this.skillId = skillId; this.skillName = skillName; + this.skillDisplayName = skillDisplayName == null || skillDisplayName.isBlank() + ? skillName + : skillDisplayName; this.skillBoxId = skillBoxId; this.path = path; this.input = input == null ? new LinkedHashMap<>() : new LinkedHashMap<>(input); @@ -66,6 +91,15 @@ public class AgentSkillLoadCall { return skillName; } + /** + * 获取 Skill 展示名称。 + * + * @return Skill 展示名称 + */ + public String getSkillDisplayName() { + return skillDisplayName; + } + /** * 获取 SkillBox ID。 * diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/skill/AgentSkillRuntimeContext.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/skill/AgentSkillRuntimeContext.java index 9e76667..7aace60 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/skill/AgentSkillRuntimeContext.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/skill/AgentSkillRuntimeContext.java @@ -43,7 +43,8 @@ public class AgentSkillRuntimeContext { continue; } skillBindings.put(skillSpec.getSkillId(), - new AgentSkillBinding(skillSpec.getSkillId(), skillSpec.getName(), spec.getSkillBoxId())); + new AgentSkillBinding(skillSpec.getSkillId(), skillSpec.getName(), + displayName(skillSpec), spec.getSkillBoxId())); } Map toolBindings = new LinkedHashMap<>(); for (Map.Entry> entry : spec.getToolBindings().entrySet()) { @@ -171,6 +172,7 @@ public class AgentSkillRuntimeContext { AgentSkillBinding binding = getSkillBinding(skillId); AgentSkillLoadCall call = new AgentSkillLoadCall(toolCallId, skillId, binding == null ? null : binding.getSkillName(), + binding == null ? null : binding.getSkillDisplayName(), binding == null ? null : binding.getSkillBoxId(), path, input); pendingLoadCalls.put(toolCallId, call); return call; @@ -206,4 +208,11 @@ public class AgentSkillRuntimeContext { private static String stringValue(Object value) { return value == null ? null : String.valueOf(value); } + + private static String displayName(AgentSkillSpec skillSpec) { + Object value = skillSpec.getMetadata() == null ? null : skillSpec.getMetadata().get("displayName"); + return value == null || String.valueOf(value).isBlank() + ? skillSpec.getName() + : String.valueOf(value); + } } diff --git a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/mcp/McpToolManifestTest.java b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/mcp/McpToolManifestTest.java new file mode 100644 index 0000000..70d7eda --- /dev/null +++ b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/mcp/McpToolManifestTest.java @@ -0,0 +1,168 @@ +package com.easyagents.agent.runtime.mcp; + +import com.easyagents.agent.runtime.AgentRuntimeException; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.Assert; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 测试 MCP Tool 冻结清单的稳定化与服务端预算。 + */ +public class McpToolManifestTest { + + /** + * 验证远端返回重复原始 Tool 名称时立即拒绝。 + */ + @Test + public void shouldRejectDuplicateRawToolNames() { + expectManifestFailure( + () -> McpToolManifest.fromTools(List.of(tool("search", "first", smallSchema()), + tool("search", "second", smallSchema()))), + "Duplicate"); + } + + /** + * 验证 Tool 名称超出字符预算时拒绝。 + */ + @Test + public void shouldRejectOverlongToolName() { + String name = "n".repeat(McpToolManifest.MAX_TOOL_NAME_LENGTH + 1); + + expectManifestFailure(() -> McpToolManifest.fromTools(List.of( + tool(name, "description", smallSchema()))), "name"); + } + + /** + * 验证 Tool 描述超出字符预算时拒绝。 + */ + @Test + public void shouldRejectOverlongToolDescription() { + String description = "d".repeat(McpToolManifest.MAX_TOOL_DESCRIPTION_LENGTH + 1); + + expectManifestFailure(() -> McpToolManifest.fromTools(List.of( + tool("search", description, smallSchema()))), "description"); + } + + /** + * 验证单个输入或输出 Schema 超出 UTF-8 预算时拒绝。 + */ + @Test + public void shouldRejectOversizedSingleSchema() { + McpSchema.JsonSchema oversized = schemaWithDescription( + "x".repeat(McpToolManifest.MAX_SCHEMA_UTF8_BYTES)); + + expectManifestFailure(() -> McpToolManifest.fromTools(List.of( + tool("search", "description", oversized))), "schema"); + } + + /** + * 验证各 Schema 合法但规范化 Manifest 聚合超过预算时拒绝。 + */ + @Test + public void shouldRejectOversizedAggregateManifest() { + McpSchema.JsonSchema schema = schemaWithDescription("x".repeat(220_000)); + List tools = new ArrayList<>(); + for (int index = 0; index < 10; index++) { + tools.add(new McpSchema.Tool("tool_" + index, "tool_" + index, "description", + schema, null, null, null)); + } + + expectManifestFailure(() -> McpToolManifest.fromTools(tools), "manifest"); + } + + /** + * 验证哈希入口同样拒绝反序列化后的重复名称,避免绕过发布阶段校验。 + */ + @Test + public void shouldRejectDuplicateNamesWhenHashingFrozenManifest() { + McpToolManifestEntry first = manifestEntry("search"); + McpToolManifestEntry second = manifestEntry("search"); + + expectManifestFailure(() -> McpToolManifest.hash(List.of(first, second)), "Duplicate"); + } + + /** + * 验证运行时完全忽略冻结白名单外新增 Tool,即使新增 Tool 超出发布清单预算。 + */ + @Test + public void shouldIgnoreOversizedRemoteToolOutsideFrozenWhitelist() { + McpSchema.Tool frozenTool = tool("search", "description", smallSchema()); + McpSpec spec = new McpSpec(); + spec.setName("demo"); + spec.setSkillId("skill-1"); + spec.setFrozenToolManifest(McpToolManifest.fromTools(List.of(frozenTool))); + spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest())); + McpSchema.Tool extraTool = tool( + "new_remote_tool", + "x".repeat(McpToolManifest.MAX_TOOL_DESCRIPTION_LENGTH + 1), + smallSchema()); + + McpToolManifest.assertFrozenManifest(spec, List.of(frozenTool, extraTool)); + } + + /** + * 构造普通 MCP Tool。 + * + * @param name Tool 名称 + * @param description Tool 描述 + * @param schema 输入 Schema + * @return MCP Tool + */ + private McpSchema.Tool tool(String name, String description, McpSchema.JsonSchema schema) { + return new McpSchema.Tool(name, name, description, schema, null, null, null); + } + + /** + * 构造小型合法 Schema。 + * + * @return 合法 Schema + */ + private McpSchema.JsonSchema smallSchema() { + return schemaWithDescription("query"); + } + + /** + * 构造带指定属性描述的 Schema。 + * + * @param description 属性描述 + * @return MCP JSON Schema + */ + private McpSchema.JsonSchema schemaWithDescription(String description) { + return new McpSchema.JsonSchema("object", + Map.of("value", Map.of("type", "string", "description", description)), + List.of("value"), null, null, null); + } + + /** + * 构造最小冻结清单项。 + * + * @param name Tool 名称 + * @return 冻结清单项 + */ + private McpToolManifestEntry manifestEntry(String name) { + McpToolManifestEntry entry = new McpToolManifestEntry(); + entry.setName(name); + entry.setDescription("description"); + entry.setInputSchema(Map.of("type", "object")); + return entry; + } + + /** + * 断言清单转换抛出包含指定片段的运行时异常。 + * + * @param action 待执行动作 + * @param messageFragment 预期错误片段 + */ + private void expectManifestFailure(Runnable action, String messageFragment) { + try { + action.run(); + Assert.fail("Expected MCP manifest validation failure."); + } catch (AgentRuntimeException expected) { + Assert.assertTrue(expected.getMessage(), expected.getMessage().contains(messageFragment)); + } + } +} diff --git a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/mcp/McpToolkitAdapterTest.java b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/mcp/McpToolkitAdapterTest.java index 345fd50..31be0a5 100644 --- a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/mcp/McpToolkitAdapterTest.java +++ b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/mcp/McpToolkitAdapterTest.java @@ -1,6 +1,9 @@ package com.easyagents.agent.runtime.mcp; import com.easyagents.agent.runtime.AgentRuntimeException; +import com.easyagents.agent.runtime.agentscope.AgentScopeSkillAdapter; +import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec; +import com.easyagents.agent.runtime.skill.AgentSkillSpec; import com.easyagents.agent.runtime.tool.AgentToolSpec; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.tool.Toolkit; @@ -161,6 +164,130 @@ public class McpToolkitAdapterTest { } } + /** + * 验证 Skill MCP 只注册到禁用的 Skill Tool Group,加载前不向模型暴露。 + */ + @Test + public void shouldRegisterSkillMcpAsInactiveSkillToolGroup() { + List frozenTools = List.of(tool("search")); + FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", + List.of(tool("search"), tool("new_remote_tool"))); + McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client)); + McpSpec spec = stdioSpec(); + spec.setSkillId("skill-1"); + spec.setToolAliases(Map.of("search", "skill_1_mcp_search")); + spec.setFrozenToolManifest(McpToolManifest.fromTools(frozenTools)); + spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest())); + Toolkit toolkit = new Toolkit(); + + McpRegistration registration = adapter.register(List.of(spec), toolkit); + + Assert.assertNull(toolkit.getTool("skill_1_mcp_search")); + Assert.assertEquals(1, registration.getSkillRegistrations().size()); + Assert.assertEquals(List.of("skill_1_mcp_search"), + registration.getSkillRegistrations().get(0).getEnableTools()); + AgentSkillSpec skill = new AgentSkillSpec(); + skill.setSkillId("skill-1"); + skill.setName("Search Skill"); + skill.setDescription("Search through MCP."); + skill.setSkillContent("Load this skill before searching."); + AgentSkillBoxSpec skillBoxSpec = new AgentSkillBoxSpec(); + skillBoxSpec.setSkills(List.of(skill)); + + new AgentScopeSkillAdapter().createSkillBox(skillBoxSpec, toolkit, Map.of(), + registration.getSkillRegistrations()); + + Assert.assertNotNull(toolkit.getTool("skill_1_mcp_search")); + Assert.assertFalse(toolkit.getActiveGroups().contains("skill-1_skill_tools")); + Assert.assertTrue(toolkit.getToolSchemas().stream() + .noneMatch(schema -> "skill_1_mcp_search".equals(schema.getName()))); + Assert.assertNull(toolkit.getTool("skill_1_mcp_new_remote_tool")); + Assert.assertEquals(1, client.remoteListCalls.get()); + } + + /** + * 验证 Skill MCP 会在读取 Tool 清单前完成异步初始化。 + */ + @Test + public void shouldInitializeSkillMcpBeforeListingTools() { + FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", List.of(tool("search"))); + client.deferInitialization = true; + McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client)); + McpSpec spec = stdioSpec(); + spec.setSkillId("skill-1"); + spec.setFrozenToolManifest(McpToolManifest.fromTools(List.of(tool("search")))); + spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest())); + + McpRegistration registration = adapter.register(List.of(spec), new Toolkit()); + + Assert.assertTrue(client.isInitialized()); + Assert.assertEquals(1, client.remoteListCalls.get()); + Assert.assertEquals(1, registration.getSkillRegistrations().size()); + } + + /** + * 验证冻结 Tool 缺失时拒绝注册并关闭 client。 + */ + @Test + public void shouldRejectMissingFrozenSkillMcpToolAndCloseClient() { + FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", List.of(tool("other"))); + McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client)); + McpSpec spec = stdioSpec(); + spec.setSkillId("skill-1"); + spec.setFrozenToolManifest(McpToolManifest.fromTools(List.of(tool("search")))); + spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest())); + + try { + adapter.register(List.of(spec), new Toolkit()); + Assert.fail("Expected frozen MCP tool validation failure."); + } catch (AgentRuntimeException expected) { + Assert.assertTrue(expected.getMessage().contains("missing")); + Assert.assertTrue(client.closed.get()); + } + } + + /** + * 验证冻结 Tool Schema 漂移时拒绝注册。 + */ + @Test(expected = AgentRuntimeException.class) + public void shouldRejectChangedFrozenSkillMcpSchema() { + McpSchema.Tool expectedTool = tool("search"); + McpSchema.JsonSchema changedSchema = new McpSchema.JsonSchema("object", + Map.of("keyword", Map.of("type", "string")), List.of("keyword"), null, null, null); + McpSchema.Tool actualTool = new McpSchema.Tool("search", "search", "search description", + changedSchema, null, null, null); + McpToolkitAdapter adapter = new McpToolkitAdapter( + new FakeMcpClientFactory(new FakeMcpClientWrapper("demo", List.of(actualTool)))); + McpSpec spec = stdioSpec(); + spec.setSkillId("skill-1"); + spec.setFrozenToolManifest(McpToolManifest.fromTools(List.of(expectedTool))); + spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest())); + + adapter.register(List.of(spec), new Toolkit()); + } + + /** + * 验证远端仅调整 Tool 描述时不破坏已发布 Skill 的运行兼容性。 + */ + @Test + public void shouldAllowChangedDescriptionWhenFrozenSchemaIsStable() { + McpSchema.Tool expectedTool = tool("search"); + McpSchema.Tool actualTool = new McpSchema.Tool( + "search", "search", "updated description", + expectedTool.inputSchema(), expectedTool.outputSchema(), null, null); + McpToolkitAdapter adapter = new McpToolkitAdapter( + new FakeMcpClientFactory(new FakeMcpClientWrapper("demo", List.of(actualTool)))); + McpSpec spec = stdioSpec(); + spec.setSkillId("skill-1"); + spec.setFrozenToolManifest(McpToolManifest.fromTools(List.of(expectedTool))); + spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest())); + + McpRegistration registration = adapter.register(List.of(spec), new Toolkit()); + + Assert.assertEquals(1, registration.getSkillRegistrations().size()); + Assert.assertEquals("search description", registration.getToolSpecs().get(0).getDescription()); + } + private McpSpec stdioSpec() { McpSpec spec = new McpSpec(); spec.setName("demo"); @@ -197,7 +324,10 @@ public class McpToolkitAdapterTest { private final List tools; private final AtomicBoolean closed = new AtomicBoolean(false); private final AtomicReference lastCalledToolName = new AtomicReference<>(); + private final java.util.concurrent.atomic.AtomicInteger remoteListCalls = + new java.util.concurrent.atomic.AtomicInteger(); private boolean failOnListTools; + private boolean deferInitialization; private FakeMcpClientWrapper(String name, List tools) { super(name); @@ -206,12 +336,19 @@ public class McpToolkitAdapterTest { @Override public Mono initialize() { + if (deferInitialization) { + return Mono.fromRunnable(() -> initialized = true); + } initialized = true; return Mono.empty(); } @Override public Mono> listTools() { + if (!initialized) { + return Mono.error(new IllegalStateException("client is not initialized")); + } + remoteListCalls.incrementAndGet(); if (failOnListTools) { return Mono.error(new IllegalStateException("list tools failed")); } diff --git a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ChainExecutor.java b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ChainExecutor.java index a752ada..ba130f3 100644 --- a/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ChainExecutor.java +++ b/easy-agents-flow/src/main/java/com/easyagents/flow/core/chain/runtime/ChainExecutor.java @@ -194,18 +194,44 @@ public class ChainExecutor { public Map execute(String definitionId, Map variables) { - return execute(definitionId, variables, Long.MAX_VALUE, TimeUnit.SECONDS); + return executeInternal(definitionId, variables, Long.MAX_VALUE, TimeUnit.SECONDS, false); } public Map execute(String definitionId, Map variables, long timeout, TimeUnit unit) { + return executeInternal(definitionId, variables, timeout, unit, false); + } + + /** + * 同步执行不允许进入人工挂起状态的工作流。 + * + *

该入口适用于 Tool 等无法把工作流恢复协议接回原调用方的同步场景。 + * 工作流一旦进入 {@link ChainStatus#SUSPEND},实例会被取消并立即返回失败。

+ * + * @param definitionId 工作流定义 ID + * @param variables 输入变量 + * @return 工作流输出 + * @throws RuntimeException 工作流失败、挂起或执行线程被中断时抛出 + */ + public Map executeWithoutSuspension( + String definitionId, Map variables) { + return executeInternal( + definitionId, variables, Long.MAX_VALUE, TimeUnit.SECONDS, true); + } + + private Map executeInternal( + String definitionId, + Map variables, + long timeout, + TimeUnit unit, + boolean rejectSuspension) { Chain chain = createChain(definitionId); String stateInstanceId = chain.getStateInstanceId(); try { chain.start(variables); Map result = awaitPersistentOutcome( - stateInstanceId, timeout, unit, null); + stateInstanceId, timeout, unit, null, rejectSuspension); clearDefaultStates(result); return result; } catch (TimeoutException e) { @@ -759,6 +785,17 @@ public class ChainExecutor { TimeUnit unit, Chain parentChain) throws InterruptedException, TimeoutException { + return awaitPersistentOutcome( + stateInstanceId, timeout, unit, parentChain, false); + } + + private Map awaitPersistentOutcome( + String stateInstanceId, + long timeout, + TimeUnit unit, + Chain parentChain, + boolean rejectSuspension) + throws InterruptedException, TimeoutException { Objects.requireNonNull(unit, "time unit required"); long timeoutNanos = timeout == Long.MAX_VALUE ? Long.MAX_VALUE @@ -789,6 +826,12 @@ public class ChainExecutor { "Chain state not found: " + stateInstanceId); } ChainStatus status = state.getStatus(); + if (rejectSuspension && status == ChainStatus.SUSPEND) { + cancel(stateInstanceId, "Suspended workflow is not supported by this caller"); + throw new ChainException( + "Workflow suspended and requires external input: " + + stateInstanceId); + } if (status != null && status.isTerminal()) { if (!status.isSuccess()) { ExceptionSummary error = state.getError(); @@ -1087,6 +1130,10 @@ public class ChainExecutor { // 状态已过期或被清理时,该触发器已经失去业务目标,直接确认避免无限热重放。 return; } + if (state.getStatus() != null && state.getStatus().isTerminal()) { + // 终态不可再次执行;直接确认迟到或重复触发器,避免重新加载已清理的定义快照。 + return; + } ChainDefinition definition = getDefinitionForInstance(state); diff --git a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainExecutorConcurrencyTest.java b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainExecutorConcurrencyTest.java index b9d402d..266147d 100644 --- a/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainExecutorConcurrencyTest.java +++ b/easy-agents-flow/src/test/java/com/easyagents/flow/core/test/ChainExecutorConcurrencyTest.java @@ -33,6 +33,7 @@ import com.easyagents.flow.core.chain.runtime.Trigger; import com.easyagents.flow.core.chain.runtime.TriggerScheduler; import com.easyagents.flow.core.node.EndNode; import com.easyagents.flow.core.node.BaseNode; +import com.easyagents.flow.core.node.ConfirmNode; import com.easyagents.flow.core.node.StartNode; import org.junit.Assert; import org.junit.Test; @@ -46,6 +47,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -59,6 +61,43 @@ import java.util.concurrent.atomic.AtomicReference; */ public class ChainExecutorConcurrencyTest { + /** + * 验证同步 Tool 入口遇到人工挂起会快速失败,不会无限占用调用线程。 + * + * @throws Exception 异步测试执行失败时抛出 + */ + @Test + public void shouldFailFastWhenNonSuspendingExecutionIsSuspended() + throws Exception { + ScheduledExecutorService schedulerPool = Executors.newSingleThreadScheduledExecutor(); + ExecutorService workerPool = Executors.newFixedThreadPool(2); + TriggerScheduler triggerScheduler = new TriggerScheduler( + new InMemoryTriggerStore(), schedulerPool, workerPool, 10L); + ChainDefinition definition = createConfirmDefinition(); + ChainExecutor executor = new ChainExecutor( + ignored -> definition, + new InMemoryChainStateRepository(), + new InMemoryNodeStateRepository(), + triggerScheduler); + ExecutorService caller = Executors.newSingleThreadExecutor(); + try { + Future> result = caller.submit( + () -> executor.executeWithoutSuspension( + definition.getId(), Collections.emptyMap())); + try { + result.get(3, TimeUnit.SECONDS); + Assert.fail("suspended workflow must fail"); + } catch (ExecutionException exception) { + Assert.assertTrue( + String.valueOf(exception.getCause().getMessage()) + .contains("Execution failed")); + } + } finally { + caller.shutdownNow(); + triggerScheduler.shutdown(); + } + } + /** * 验证实例初始化会在入口触发器创建前持久化工作流定义 ID。 * @@ -512,6 +551,36 @@ public class ChainExecutorConcurrencyTest { return definition; } + /** + * 创建包含内部确认节点的测试 Workflow。 + * + * @return 会进入挂起状态的 Workflow 定义 + */ + private ChainDefinition createConfirmDefinition() { + ChainDefinition definition = new ChainDefinition(); + definition.setId("non-suspending-confirm-test"); + StartNode start = new StartNode(); + start.setId("start"); + ConfirmNode confirm = new ConfirmNode(); + confirm.setId("confirm"); + EndNode end = new EndNode(); + end.setId("end"); + Edge first = new Edge(); + first.setId("start-to-confirm"); + first.setSource("start"); + first.setTarget("confirm"); + Edge second = new Edge(); + second.setId("confirm-to-end"); + second.setSource("confirm"); + second.setTarget("end"); + definition.addNode(start); + definition.addNode(confirm); + definition.addNode(end); + definition.addEdge(first); + definition.addEdge(second); + return definition; + } + /** * 创建用于取消传播验证的工作流。 *