feat: 完善 Agent Skill 渐进披露运行时

- 支持 Skill 绑定 MCP 冻结清单和延迟注册

- 拒绝同步工具工作流进入不可恢复挂起状态
This commit is contained in:
2026-08-19 21:51:16 +08:00
parent 49a7de34bb
commit c7d410d755
19 changed files with 1417 additions and 59 deletions

View File

@@ -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.knowledge.citation.HeuristicKnowledgeCitationMatcher;
import com.easyagents.agent.runtime.message.*; import com.easyagents.agent.runtime.message.*;
import com.easyagents.agent.runtime.mcp.McpRegistration; 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.McpSpecValidator;
import com.easyagents.agent.runtime.mcp.McpToolkitAdapter; import com.easyagents.agent.runtime.mcp.McpToolkitAdapter;
import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore; import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore;
@@ -218,7 +219,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
saveSession(); saveSession();
return Flux.just(started(executionContext), cancelled(executionContext)); return Flux.just(started(executionContext), cancelled(executionContext));
}).doOnNext(event -> executionContext.getConversationRecorder().record(executionContext, event)) }).doOnNext(event -> executionContext.getConversationRecorder().record(executionContext, event))
.doFinally(signalType -> cleanupTurn()); .doFinally(signalType -> cleanupStreamSegment(false));
} }
return runAgentStreamAfterLock(executionContext, List::of); return runAgentStreamAfterLock(executionContext, List::of);
}); });
@@ -238,6 +239,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
if (!running.compareAndSet(false, true)) { if (!running.compareAndSet(false, true)) {
return Flux.error(new AgentRuntimeException("Agent runtime is already streaming.")); return Flux.error(new AgentRuntimeException("Agent runtime is already streaming."));
} }
// 新用户消息建立新的 Turn上一 Turn 的 MCP 级批准不能跨轮复用。
approvalCoordinator.clearReusableApprovalScopes();
return runAgentStreamAfterLock(executionContext, inputSupplier); return runAgentStreamAfterLock(executionContext, inputSupplier);
}); });
} }
@@ -296,8 +299,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
.doOnNext(event -> executionContext.getConversationRecorder().record(executionContext, event)) .doOnNext(event -> executionContext.getConversationRecorder().record(executionContext, event))
// 处理中断请求 // 处理中断请求
.doOnCancel(() -> cancelInternal(executionContext, sideEvents, finalText, finalMessage, cancelled)) .doOnCancel(() -> cancelInternal(executionContext, sideEvents, finalText, finalMessage, cancelled))
// 释放运行锁并清掉 turn context // HITL 挂起时保留当前 Turn 的 MCP 批准,其余终态完整清理
.doFinally(signalType -> cleanupTurn()); .doFinally(signalType -> cleanupStreamSegment(suspendedEvent.get() != null));
} }
/** /**
@@ -582,7 +585,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
suspended.getMetadata().put("approvalStatus", AgentToolApprovalResolution.Status.WAITING.name()); suspended.getMetadata().put("approvalStatus", AgentToolApprovalResolution.Status.WAITING.name());
return Flux.just(started(context), suspended) return Flux.just(started(context), suspended)
.doOnNext(event -> context.getConversationRecorder().record(context, event)) .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(); approvalCoordinator.clearExecutionAuthorizations();
if (!preserveReusableApprovalScopes) {
approvalCoordinator.clearReusableApprovalScopes();
}
turnContextHolder.clear(); turnContextHolder.clear();
running.set(false); running.set(false);
} }
@@ -1123,7 +1131,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
AgentScopeMemoryBuildResult memoryResult = memoryAdapter.createMemoryResult(null, definition.getMemoryPolicy(), model); AgentScopeMemoryBuildResult memoryResult = memoryAdapter.createMemoryResult(null, definition.getMemoryPolicy(), model);
Memory memory = memoryResult.getMemory(); Memory memory = memoryResult.getMemory();
Knowledge knowledge = knowledgeAdapter.createAggregateKnowledge(context, turnContextHolder); 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 // AutoContextInterceptor 是官方 AutoContextHook 的替代实现。这里仍只注册统一 runtime hook
// 避免官方 hook 与 Easy-Agents interceptor 同时触发压缩和 inputMessages 改写。 // 避免官方 hook 与 Easy-Agents interceptor 同时触发压缩和 inputMessages 改写。
AgentRuntimeEventBridge eventBridge = new AgentRuntimeEventBridge(context, turnContextHolder); AgentRuntimeEventBridge eventBridge = new AgentRuntimeEventBridge(context, turnContextHolder);
@@ -1202,7 +1211,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
Toolkit toolkit) { Toolkit toolkit) {
Map<String, List<AgentTool>> skillTools = new LinkedHashMap<>(); Map<String, List<AgentTool>> skillTools = new LinkedHashMap<>();
if (!context.getAgentDefinition().getExecutionOptions().isToolCallingEnabled()) { 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()) { for (AgentToolSpec toolSpec : context.getAgentDefinition().getToolSpecs()) {
AgentToolInvoker invoker = context.getToolInvokers().get(toolSpec.getName()); AgentToolInvoker invoker = context.getToolInvokers().get(toolSpec.getName());
@@ -1222,7 +1231,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
context.getAgentDefinition().getOperateToolSpecs(), toolkit); context.getAgentDefinition().getOperateToolSpecs(), toolkit);
McpSpecValidator.validateToolConflicts(context.getAgentDefinition().getToolSpecs(), McpSpecValidator.validateToolConflicts(context.getAgentDefinition().getToolSpecs(),
mcpRegistration.getToolSpecs(), context.getAgentDefinition().getOperateToolSpecs()); mcpRegistration.getToolSpecs(), context.getAgentDefinition().getOperateToolSpecs());
return new AgentScopeToolkitBuildResult(skillTools, mcpRegistration.getToolSpecs(), operateToolSpecs); return new AgentScopeToolkitBuildResult(skillTools, mcpRegistration.getToolSpecs(), operateToolSpecs,
mcpRegistration.getSkillRegistrations());
} }
private List<AgentToolSpec> mergeToolSpecs(List<AgentToolSpec> toolSpecs, private List<AgentToolSpec> mergeToolSpecs(List<AgentToolSpec> toolSpecs,
@@ -1290,7 +1300,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
} }
private record AgentScopeToolkitBuildResult(Map<String, List<AgentTool>> skillTools, private record AgentScopeToolkitBuildResult(Map<String, List<AgentTool>> skillTools,
List<AgentToolSpec> mcpToolSpecs, List<AgentToolSpec> mcpToolSpecs,
List<AgentToolSpec> operateToolSpecs) { List<AgentToolSpec> operateToolSpecs,
List<McpSkillRegistration> skillMcpRegistrations) {
} }
} }

View File

@@ -4,12 +4,14 @@ import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec; import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec;
import com.easyagents.agent.runtime.skill.AgentSkillCompiler; import com.easyagents.agent.runtime.skill.AgentSkillCompiler;
import com.easyagents.agent.runtime.skill.AgentSkillSpec; 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.AgentSkill;
import io.agentscope.core.skill.SkillBox; import io.agentscope.core.skill.SkillBox;
import io.agentscope.core.tool.AgentTool; import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.Toolkit; import io.agentscope.core.tool.Toolkit;
import java.util.List; import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
/** /**
@@ -67,6 +69,22 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler<AgentSkill> {
* @return SkillBox未配置 Skill 时返回 null * @return SkillBox未配置 Skill 时返回 null
*/ */
public SkillBox createSkillBox(AgentSkillBoxSpec spec, Toolkit toolkit, Map<String, List<AgentTool>> skillTools) { public SkillBox createSkillBox(AgentSkillBoxSpec spec, Toolkit toolkit, Map<String, List<AgentTool>> 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<String, List<AgentTool>> skillTools,
List<McpSkillRegistration> skillMcpRegistrations) {
if (spec == null || spec.getSkills().isEmpty()) { if (spec == null || spec.getSkills().isEmpty()) {
return null; return null;
} }
@@ -74,10 +92,15 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler<AgentSkill> {
? new SkillBox(toolkit) ? new SkillBox(toolkit)
: new SkillBox(toolkit, spec.getSkillBoxId()); : new SkillBox(toolkit, spec.getSkillBoxId());
skillBox.setExposeAllSkillMetadata(spec.isExposeAllSkillMetadata()); skillBox.setExposeAllSkillMetadata(spec.isExposeAllSkillMetadata());
Map<String, List<McpSkillRegistration>> mcpBySkill = groupMcpRegistrations(skillMcpRegistrations);
for (AgentSkillSpec skillSpec : spec.getSkills()) { for (AgentSkillSpec skillSpec : spec.getSkills()) {
AgentSkill skill = compile(skillSpec); AgentSkill skill = compile(skillSpec);
List<AgentTool> tools = skillTools == null ? List.of() : skillTools.getOrDefault(skillSpec.getSkillId(), List.of()); List<AgentTool> tools = skillTools == null ? List.of() : skillTools.getOrDefault(skillSpec.getSkillId(), List.of());
if (tools.isEmpty()) { List<McpSkillRegistration> mcpRegistrations = mcpBySkill.remove(skillSpec.getSkillId());
if (mcpRegistrations == null) {
mcpRegistrations = List.of();
}
if (tools.isEmpty() && mcpRegistrations.isEmpty()) {
skillBox.registration() skillBox.registration()
.skill(skill) .skill(skill)
.toolkit(toolkit) .toolkit(toolkit)
@@ -97,11 +120,42 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler<AgentSkill> {
.agentTool(tool) .agentTool(tool)
.apply(); .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(); skillBox.syncToolGroupStates();
return skillBox; return skillBox;
} }
private Map<String, List<McpSkillRegistration>> groupMcpRegistrations(
List<McpSkillRegistration> registrations) {
Map<String, List<McpSkillRegistration>> 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 注册和模型提示所需的必要信息。 * 校验 Skill 声明是否具备 AgentScope 注册和模型提示所需的必要信息。
* *

View File

@@ -561,6 +561,7 @@ public class AgentScopeToolAdapter {
} }
target.put("skillId", binding.getSkillId()); target.put("skillId", binding.getSkillId());
target.put("skillName", binding.getSkillName()); target.put("skillName", binding.getSkillName());
target.put("skillDisplayName", binding.getSkillDisplayName());
target.put("skillBoxId", binding.getSkillBoxId()); target.put("skillBoxId", binding.getSkillBoxId());
} }

View File

@@ -270,10 +270,12 @@ public class SkillExecutionObserver implements AgentRuntimeObserver {
} }
event.getPayload().put("skillId", call.getSkillId()); event.getPayload().put("skillId", call.getSkillId());
event.getPayload().put("skillName", call.getSkillName()); event.getPayload().put("skillName", call.getSkillName());
event.getPayload().put("skillDisplayName", call.getSkillDisplayName());
event.getPayload().put("skillBoxId", call.getSkillBoxId()); event.getPayload().put("skillBoxId", call.getSkillBoxId());
event.getPayload().put("path", call.getPath()); event.getPayload().put("path", call.getPath());
event.getMetadata().put("skillId", call.getSkillId()); event.getMetadata().put("skillId", call.getSkillId());
event.getMetadata().put("skillName", call.getSkillName()); event.getMetadata().put("skillName", call.getSkillName());
event.getMetadata().put("skillDisplayName", call.getSkillDisplayName());
event.getMetadata().put("skillBoxId", call.getSkillBoxId()); event.getMetadata().put("skillBoxId", call.getSkillBoxId());
} }
@@ -283,6 +285,7 @@ public class SkillExecutionObserver implements AgentRuntimeObserver {
} }
target.put("skillId", binding.getSkillId()); target.put("skillId", binding.getSkillId());
target.put("skillName", binding.getSkillName()); target.put("skillName", binding.getSkillName());
target.put("skillDisplayName", binding.getSkillDisplayName());
target.put("skillBoxId", binding.getSkillBoxId()); target.put("skillBoxId", binding.getSkillBoxId());
} }

View File

@@ -9,8 +9,6 @@ import com.easyagents.agent.runtime.tool.AgentToolSpec;
import io.agentscope.core.hook.HookEvent; import io.agentscope.core.hook.HookEvent;
import io.agentscope.core.hook.PostActingEvent; import io.agentscope.core.hook.PostActingEvent;
import io.agentscope.core.hook.PreActingEvent; 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.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.message.ToolUseBlock;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -103,12 +101,7 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
runtimeEvent.getPayload().put("toolCallId", toolUse.getId()); runtimeEvent.getPayload().put("toolCallId", toolUse.getId());
runtimeEvent.getPayload().put("name", toolUse.getName()); runtimeEvent.getPayload().put("name", toolUse.getName());
runtimeEvent.getPayload().put("toolName", 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("status", "RUNNING");
runtimeEvent.getPayload().put("source", "HOOK");
runtimeEvent.getPayload().put("phase", "PRE_ACTING");
runtimeEvent.getMetadata().putAll(nullToEmpty(toolUse.getMetadata()));
enrichToolPayload(runtimeEvent, toolUse.getName()); enrichToolPayload(runtimeEvent, toolUse.getName());
eventBridge.emit(runtimeEvent); eventBridge.emit(runtimeEvent);
} }
@@ -129,15 +122,8 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
runtimeEvent.getPayload().put("toolCallId", toolCallId); runtimeEvent.getPayload().put("toolCallId", toolCallId);
runtimeEvent.getPayload().put("name", toolName); runtimeEvent.getPayload().put("name", toolName);
runtimeEvent.getPayload().put("toolName", 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("status", success(result) ? "SUCCESS" : "FAILED");
runtimeEvent.getPayload().put("success", success(result)); 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); enrichToolPayload(runtimeEvent, toolName);
eventBridge.emit(runtimeEvent); eventBridge.emit(runtimeEvent);
} }
@@ -149,12 +135,7 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
} }
Map<String, Object> metadata = toolSpec.getMetadata(); Map<String, Object> metadata = toolSpec.getMetadata();
putIfPresent(runtimeEvent.getPayload(), metadata, "toolDisplayName"); putIfPresent(runtimeEvent.getPayload(), metadata, "toolDisplayName");
putIfPresent(runtimeEvent.getPayload(), metadata, "rawMcpToolName"); putIfPresent(runtimeEvent.getPayload(), metadata, "skillId");
putIfPresent(runtimeEvent.getPayload(), metadata, "mcpToolName");
putIfPresent(runtimeEvent.getPayload(), metadata, "mcpName");
putIfPresent(runtimeEvent.getPayload(), metadata, "mcpTitle");
putIfPresent(runtimeEvent.getPayload(), metadata, "source");
runtimeEvent.getMetadata().putAll(metadata);
} }
private void putIfPresent(Map<String, Object> payload, Map<String, Object> metadata, String key) { private void putIfPresent(Map<String, Object> payload, Map<String, Object> metadata, String key) {
@@ -171,25 +152,6 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
return !(success instanceof Boolean) || Boolean.TRUE.equals(success); 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<String, Object> nullToEmpty(Map<String, Object> map) {
return map == null ? new LinkedHashMap<>() : map;
}
private boolean isSkillTool(String toolName) { private boolean isSkillTool(String toolName) {
if (skillContext == null) { if (skillContext == null) {
return false; return false;

View File

@@ -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<McpSchema.Tool> frozenTools;
private final Map<String, String> runtimeToRaw = new LinkedHashMap<>();
/**
* 创建冻结 MCP client 视图。
*
* @param delegate 原始 client
* @param actualTools 已一次性读取并验证的远端 Tool
* @param manifest 冻结清单
* @param aliases 显式运行别名
* @param prefix 运行名前缀
*/
FrozenMcpClientWrapper(McpClientWrapper delegate,
List<McpSchema.Tool> actualTools,
List<McpToolManifestEntry> manifest,
Map<String, String> 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<Void> initialize() {
return delegate.initialize().doOnSuccess(ignored -> initialized = delegate.isInitialized());
}
/** {@inheritDoc} */
@Override
public Mono<List<McpSchema.Tool>> listTools() {
return Mono.just(frozenTools);
}
/** {@inheritDoc} */
@Override
public Mono<McpSchema.CallToolResult> callTool(String toolName, Map<String, Object> 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<McpSchema.Tool> freeze(List<McpSchema.Tool> actualTools,
List<McpToolManifestEntry> manifest,
Map<String, String> aliases,
String prefix) {
Map<String, McpSchema.Tool> actualByName = new LinkedHashMap<>();
if (actualTools != null) {
actualTools.stream().filter(tool -> tool != null && tool.name() != null)
.forEach(tool -> actualByName.put(tool.name(), tool));
}
Map<String, String> usedRuntimeNames = new LinkedHashMap<>();
List<McpSchema.Tool> 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<String, Object> 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<String, String> 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<String, String> 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;
}
}

View File

@@ -13,6 +13,7 @@ public class McpRegistration {
private final List<McpClientWrapper> clients; private final List<McpClientWrapper> clients;
private final List<AgentToolSpec> toolSpecs; private final List<AgentToolSpec> toolSpecs;
private final List<McpSkillRegistration> skillRegistrations;
/** /**
* 创建 MCP 注册结果。 * 创建 MCP 注册结果。
@@ -21,8 +22,24 @@ public class McpRegistration {
* @param toolSpecs 已注册工具声明 * @param toolSpecs 已注册工具声明
*/ */
public McpRegistration(List<McpClientWrapper> clients, List<AgentToolSpec> toolSpecs) { public McpRegistration(List<McpClientWrapper> clients, List<AgentToolSpec> toolSpecs) {
this(clients, toolSpecs, List.of());
}
/**
* 创建 MCP 注册结果。
*
* @param clients 已创建 MCP client
* @param toolSpecs 已发现工具声明
* @param skillRegistrations 等待注册到 Skill 的 MCP client
*/
public McpRegistration(List<McpClientWrapper> clients,
List<AgentToolSpec> toolSpecs,
List<McpSkillRegistration> skillRegistrations) {
this.clients = clients == null ? List.of() : new ArrayList<>(clients); this.clients = clients == null ? List.of() : new ArrayList<>(clients);
this.toolSpecs = toolSpecs == null ? List.of() : new ArrayList<>(toolSpecs); 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<AgentToolSpec> getToolSpecs() { public List<AgentToolSpec> getToolSpecs() {
return toolSpecs; return toolSpecs;
} }
/**
* 获取等待注册到 Skill 的 MCP client。
*
* @return Skill MCP 注册声明
*/
public List<McpSkillRegistration> getSkillRegistrations() {
return skillRegistrations;
}
} }

View File

@@ -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<String> enableTools;
private final List<String> disableTools;
private final Map<String, Map<String, Object>> presetParameters;
/**
* 创建 Skill MCP 注册声明。
*
* @param skillId Skill ID
* @param client MCP client
* @param enableTools 运行时工具白名单
* @param disableTools 运行时工具黑名单
* @param presetParameters 预设参数
*/
public McpSkillRegistration(String skillId,
McpClientWrapper client,
List<String> enableTools,
List<String> disableTools,
Map<String, Map<String, Object>> 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<String> getEnableTools() {
return enableTools;
}
/**
* 获取运行时工具黑名单。
*
* @return 工具黑名单
*/
public List<String> getDisableTools() {
return disableTools;
}
/**
* 获取预设参数。
*
* @return 预设参数
*/
public Map<String, Map<String, Object>> getPresetParameters() {
return presetParameters;
}
}

View File

@@ -33,6 +33,9 @@ public class McpSpec {
private boolean approvalRequired; private boolean approvalRequired;
private AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest(); private AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest();
private Map<String, AgentToolApprovalRequest> toolApprovalRequests = new LinkedHashMap<>(); private Map<String, AgentToolApprovalRequest> toolApprovalRequests = new LinkedHashMap<>();
private String skillId;
private List<McpToolManifestEntry> frozenToolManifest = new ArrayList<>();
private String frozenToolManifestHash;
private Map<String, Object> metadata = new LinkedHashMap<>(); private Map<String, Object> metadata = new LinkedHashMap<>();
/** /**
@@ -406,6 +409,62 @@ public class McpSpec {
: new LinkedHashMap<>(toolApprovalRequests); : 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<McpToolManifestEntry> getFrozenToolManifest() {
return frozenToolManifest;
}
/**
* 设置冻结 Tool 清单。
*
* @param frozenToolManifest 冻结 Tool 清单
*/
public void setFrozenToolManifest(List<McpToolManifestEntry> 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;
}
/** /**
* 获取元数据。 * 获取元数据。
* *

View File

@@ -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<McpToolManifestEntry> fromTools(List<McpSchema.Tool> tools) {
if (tools == null || tools.isEmpty()) {
return List.of();
}
List<McpToolManifestEntry> entries = new ArrayList<>();
Set<String> 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<McpToolManifestEntry> 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<McpSchema.Tool> actualTools) {
if (spec == null || spec.getSkillId() == null || spec.getSkillId().isBlank()) {
return;
}
List<McpToolManifestEntry> 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<String> frozenNames = new HashSet<>();
for (McpToolManifestEntry entry : expected) {
if (entry != null && entry.getName() != null && !entry.getName().isBlank()) {
frozenNames.add(entry.getName());
}
}
Map<String, McpToolManifestEntry> 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<McpToolManifestEntry> 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。
*
* <p>描述用于保存与发布阶段的完整 manifest 变更识别,但远端仅调整描述时不会改变
* 已发布 Tool 的可调用边界,因此运行时不应中断既有 Agent。</p>
*
* @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<McpToolManifestEntry> entries) {
List<Map<String, Object>> canonical = new ArrayList<>();
Set<String> 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<McpToolManifestEntry> entries) {
List<Map<String, Object>> canonical = entries.stream()
.map(McpToolManifest::toCanonicalValue)
.toList();
assertUtf8Size("MCP tool manifest", JSON.toJSONString(canonical), MAX_MANIFEST_UTF8_BYTES);
}
/**
* 构造用于哈希和预算计算的稳定清单值。
*
* @param entry 已规范化的清单项
* @return 保持字段顺序的清单值
*/
private static Map<String, Object> toCanonicalValue(McpToolManifestEntry entry) {
Map<String, Object> 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<String, Object> sorted = new TreeMap<>();
source.forEach((key, child) -> sorted.put(String.valueOf(key), sortJson(child)));
return sorted;
}
if (value instanceof List<?> source) {
List<Object> 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;
}
}

View File

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

View File

@@ -52,6 +52,7 @@ public class McpToolkitAdapter {
} }
List<McpClientWrapper> clients = new ArrayList<>(); List<McpClientWrapper> clients = new ArrayList<>();
List<AgentToolSpec> toolSpecs = new ArrayList<>(); List<AgentToolSpec> toolSpecs = new ArrayList<>();
List<McpSkillRegistration> skillRegistrations = new ArrayList<>();
try { try {
for (McpSpec spec : specs) { for (McpSpec spec : specs) {
if (spec == null) { if (spec == null) {
@@ -59,16 +60,59 @@ public class McpToolkitAdapter {
} }
McpSpecValidator.validateConnection(spec); McpSpecValidator.validateConnection(spec);
McpClientWrapper client = clientFactory.create(spec); McpClientWrapper client = clientFactory.create(spec);
client = applyAliases(spec, client);
clients.add(client); clients.add(client);
registerClient(spec, client, toolkit); if (isSkillBound(spec)) {
List<McpSchema.Tool> 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))); toolSpecs.addAll(toToolSpecs(spec, registeredTools(spec, client)));
} }
} catch (RuntimeException error) { } catch (RuntimeException error) {
closeQuietly(clients); closeQuietly(clients);
throw error; 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<String> frozenRuntimeToolNames(McpSpec spec, McpClientWrapper client) {
Set<String> frozenRawNames = new LinkedHashSet<>();
for (McpToolManifestEntry entry : spec.getFrozenToolManifest()) {
if (entry != null && entry.getName() != null && !entry.getName().isBlank()) {
frozenRawNames.add(entry.getName());
}
}
List<String> 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) { private McpClientWrapper applyAliases(McpSpec spec, McpClientWrapper client) {
@@ -95,7 +139,7 @@ public class McpToolkitAdapter {
} }
private List<McpSchema.Tool> registeredTools(McpSpec spec, McpClientWrapper client) { private List<McpSchema.Tool> registeredTools(McpSpec spec, McpClientWrapper client) {
List<McpSchema.Tool> tools = client.listTools().block(); List<McpSchema.Tool> tools = listTools(client);
if (tools == null || tools.isEmpty()) { if (tools == null || tools.isEmpty()) {
return List.of(); return List.of();
} }
@@ -108,6 +152,30 @@ public class McpToolkitAdapter {
return filtered; return filtered;
} }
private List<McpSchema.Tool> listTools(McpClientWrapper client) {
List<McpSchema.Tool> tools = client.listTools().block();
return tools == null ? List.of() : tools;
}
/**
* 初始化 client 后读取一次远端 Tool 清单。
*
* @param client MCP client
* @return Tool 清单
*/
private List<McpSchema.Tool> 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<McpSchema.Tool> 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<String> enableTools, List<String> disableTools) { private boolean shouldRegister(String toolName, List<String> enableTools, List<String> disableTools) {
if (enableTools != null && !enableTools.isEmpty()) { if (enableTools != null && !enableTools.isEmpty()) {
return enableTools.contains(toolName); return enableTools.contains(toolName);
@@ -166,6 +234,9 @@ public class McpToolkitAdapter {
metadata.put("rawMcpToolName", rawToolName(spec, tool)); metadata.put("rawMcpToolName", rawToolName(spec, tool));
metadata.put("toolDisplayName", toolDisplayName(spec, tool)); metadata.put("toolDisplayName", toolDisplayName(spec, tool));
metadata.put("transportType", spec.getTransportType().configValue()); metadata.put("transportType", spec.getTransportType().configValue());
if (isSkillBound(spec)) {
metadata.put("skillId", spec.getSkillId());
}
return metadata; return metadata;
} }

View File

@@ -7,6 +7,7 @@ public class AgentSkillBinding {
private final String skillId; private final String skillId;
private final String skillName; private final String skillName;
private final String skillDisplayName;
private final String skillBoxId; private final String skillBoxId;
/** /**
@@ -17,8 +18,26 @@ public class AgentSkillBinding {
* @param skillBoxId SkillBox ID * @param skillBoxId SkillBox ID
*/ */
public AgentSkillBinding(String skillId, String skillName, String skillBoxId) { 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.skillId = skillId;
this.skillName = skillName; this.skillName = skillName;
this.skillDisplayName = skillDisplayName == null || skillDisplayName.isBlank()
? skillName
: skillDisplayName;
this.skillBoxId = skillBoxId; this.skillBoxId = skillBoxId;
} }
@@ -40,6 +59,15 @@ public class AgentSkillBinding {
return skillName; return skillName;
} }
/**
* 获取 Skill 展示名称。
*
* @return Skill 展示名称
*/
public String getSkillDisplayName() {
return skillDisplayName;
}
/** /**
* 获取 SkillBox ID。 * 获取 SkillBox ID。
* *
@@ -49,4 +77,3 @@ public class AgentSkillBinding {
return skillBoxId; return skillBoxId;
} }
} }

View File

@@ -11,6 +11,7 @@ public class AgentSkillLoadCall {
private final String toolCallId; private final String toolCallId;
private final String skillId; private final String skillId;
private final String skillName; private final String skillName;
private final String skillDisplayName;
private final String skillBoxId; private final String skillBoxId;
private final String path; private final String path;
private final Map<String, Object> input; private final Map<String, Object> input;
@@ -31,9 +32,33 @@ public class AgentSkillLoadCall {
String skillBoxId, String skillBoxId,
String path, String path,
Map<String, Object> input) { Map<String, Object> 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<String, Object> input) {
this.toolCallId = toolCallId; this.toolCallId = toolCallId;
this.skillId = skillId; this.skillId = skillId;
this.skillName = skillName; this.skillName = skillName;
this.skillDisplayName = skillDisplayName == null || skillDisplayName.isBlank()
? skillName
: skillDisplayName;
this.skillBoxId = skillBoxId; this.skillBoxId = skillBoxId;
this.path = path; this.path = path;
this.input = input == null ? new LinkedHashMap<>() : new LinkedHashMap<>(input); this.input = input == null ? new LinkedHashMap<>() : new LinkedHashMap<>(input);
@@ -66,6 +91,15 @@ public class AgentSkillLoadCall {
return skillName; return skillName;
} }
/**
* 获取 Skill 展示名称。
*
* @return Skill 展示名称
*/
public String getSkillDisplayName() {
return skillDisplayName;
}
/** /**
* 获取 SkillBox ID。 * 获取 SkillBox ID。
* *

View File

@@ -43,7 +43,8 @@ public class AgentSkillRuntimeContext {
continue; continue;
} }
skillBindings.put(skillSpec.getSkillId(), skillBindings.put(skillSpec.getSkillId(),
new AgentSkillBinding(skillSpec.getSkillId(), skillSpec.getName(), spec.getSkillBoxId())); new AgentSkillBinding(skillSpec.getSkillId(), skillSpec.getName(),
displayName(skillSpec), spec.getSkillBoxId()));
} }
Map<String, AgentSkillBinding> toolBindings = new LinkedHashMap<>(); Map<String, AgentSkillBinding> toolBindings = new LinkedHashMap<>();
for (Map.Entry<String, List<String>> entry : spec.getToolBindings().entrySet()) { for (Map.Entry<String, List<String>> entry : spec.getToolBindings().entrySet()) {
@@ -171,6 +172,7 @@ public class AgentSkillRuntimeContext {
AgentSkillBinding binding = getSkillBinding(skillId); AgentSkillBinding binding = getSkillBinding(skillId);
AgentSkillLoadCall call = new AgentSkillLoadCall(toolCallId, skillId, AgentSkillLoadCall call = new AgentSkillLoadCall(toolCallId, skillId,
binding == null ? null : binding.getSkillName(), binding == null ? null : binding.getSkillName(),
binding == null ? null : binding.getSkillDisplayName(),
binding == null ? null : binding.getSkillBoxId(), path, input); binding == null ? null : binding.getSkillBoxId(), path, input);
pendingLoadCalls.put(toolCallId, call); pendingLoadCalls.put(toolCallId, call);
return call; return call;
@@ -206,4 +208,11 @@ public class AgentSkillRuntimeContext {
private static String stringValue(Object value) { private static String stringValue(Object value) {
return value == null ? null : String.valueOf(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);
}
} }

View File

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

View File

@@ -1,6 +1,9 @@
package com.easyagents.agent.runtime.mcp; package com.easyagents.agent.runtime.mcp;
import com.easyagents.agent.runtime.AgentRuntimeException; 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 com.easyagents.agent.runtime.tool.AgentToolSpec;
import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.tool.Toolkit; import io.agentscope.core.tool.Toolkit;
@@ -161,6 +164,130 @@ public class McpToolkitAdapterTest {
} }
} }
/**
* 验证 Skill MCP 只注册到禁用的 Skill Tool Group加载前不向模型暴露。
*/
@Test
public void shouldRegisterSkillMcpAsInactiveSkillToolGroup() {
List<McpSchema.Tool> 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() { private McpSpec stdioSpec() {
McpSpec spec = new McpSpec(); McpSpec spec = new McpSpec();
spec.setName("demo"); spec.setName("demo");
@@ -197,7 +324,10 @@ public class McpToolkitAdapterTest {
private final List<McpSchema.Tool> tools; private final List<McpSchema.Tool> tools;
private final AtomicBoolean closed = new AtomicBoolean(false); private final AtomicBoolean closed = new AtomicBoolean(false);
private final AtomicReference<String> lastCalledToolName = new AtomicReference<>(); private final AtomicReference<String> lastCalledToolName = new AtomicReference<>();
private final java.util.concurrent.atomic.AtomicInteger remoteListCalls =
new java.util.concurrent.atomic.AtomicInteger();
private boolean failOnListTools; private boolean failOnListTools;
private boolean deferInitialization;
private FakeMcpClientWrapper(String name, List<McpSchema.Tool> tools) { private FakeMcpClientWrapper(String name, List<McpSchema.Tool> tools) {
super(name); super(name);
@@ -206,12 +336,19 @@ public class McpToolkitAdapterTest {
@Override @Override
public Mono<Void> initialize() { public Mono<Void> initialize() {
if (deferInitialization) {
return Mono.fromRunnable(() -> initialized = true);
}
initialized = true; initialized = true;
return Mono.empty(); return Mono.empty();
} }
@Override @Override
public Mono<List<McpSchema.Tool>> listTools() { public Mono<List<McpSchema.Tool>> listTools() {
if (!initialized) {
return Mono.error(new IllegalStateException("client is not initialized"));
}
remoteListCalls.incrementAndGet();
if (failOnListTools) { if (failOnListTools) {
return Mono.error(new IllegalStateException("list tools failed")); return Mono.error(new IllegalStateException("list tools failed"));
} }

View File

@@ -194,18 +194,44 @@ public class ChainExecutor {
public Map<String, Object> execute(String definitionId, Map<String, Object> variables) { public Map<String, Object> execute(String definitionId, Map<String, Object> variables) {
return execute(definitionId, variables, Long.MAX_VALUE, TimeUnit.SECONDS); return executeInternal(definitionId, variables, Long.MAX_VALUE, TimeUnit.SECONDS, false);
} }
public Map<String, Object> execute(String definitionId, Map<String, Object> variables, long timeout, TimeUnit unit) { public Map<String, Object> execute(String definitionId, Map<String, Object> variables, long timeout, TimeUnit unit) {
return executeInternal(definitionId, variables, timeout, unit, false);
}
/**
* 同步执行不允许进入人工挂起状态的工作流。
*
* <p>该入口适用于 Tool 等无法把工作流恢复协议接回原调用方的同步场景。
* 工作流一旦进入 {@link ChainStatus#SUSPEND},实例会被取消并立即返回失败。</p>
*
* @param definitionId 工作流定义 ID
* @param variables 输入变量
* @return 工作流输出
* @throws RuntimeException 工作流失败、挂起或执行线程被中断时抛出
*/
public Map<String, Object> executeWithoutSuspension(
String definitionId, Map<String, Object> variables) {
return executeInternal(
definitionId, variables, Long.MAX_VALUE, TimeUnit.SECONDS, true);
}
private Map<String, Object> executeInternal(
String definitionId,
Map<String, Object> variables,
long timeout,
TimeUnit unit,
boolean rejectSuspension) {
Chain chain = createChain(definitionId); Chain chain = createChain(definitionId);
String stateInstanceId = chain.getStateInstanceId(); String stateInstanceId = chain.getStateInstanceId();
try { try {
chain.start(variables); chain.start(variables);
Map<String, Object> result = awaitPersistentOutcome( Map<String, Object> result = awaitPersistentOutcome(
stateInstanceId, timeout, unit, null); stateInstanceId, timeout, unit, null, rejectSuspension);
clearDefaultStates(result); clearDefaultStates(result);
return result; return result;
} catch (TimeoutException e) { } catch (TimeoutException e) {
@@ -759,6 +785,17 @@ public class ChainExecutor {
TimeUnit unit, TimeUnit unit,
Chain parentChain) Chain parentChain)
throws InterruptedException, TimeoutException { throws InterruptedException, TimeoutException {
return awaitPersistentOutcome(
stateInstanceId, timeout, unit, parentChain, false);
}
private Map<String, Object> awaitPersistentOutcome(
String stateInstanceId,
long timeout,
TimeUnit unit,
Chain parentChain,
boolean rejectSuspension)
throws InterruptedException, TimeoutException {
Objects.requireNonNull(unit, "time unit required"); Objects.requireNonNull(unit, "time unit required");
long timeoutNanos = timeout == Long.MAX_VALUE long timeoutNanos = timeout == Long.MAX_VALUE
? Long.MAX_VALUE ? Long.MAX_VALUE
@@ -789,6 +826,12 @@ public class ChainExecutor {
"Chain state not found: " + stateInstanceId); "Chain state not found: " + stateInstanceId);
} }
ChainStatus status = state.getStatus(); 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 != null && status.isTerminal()) {
if (!status.isSuccess()) { if (!status.isSuccess()) {
ExceptionSummary error = state.getError(); ExceptionSummary error = state.getError();
@@ -1087,6 +1130,10 @@ public class ChainExecutor {
// 状态已过期或被清理时,该触发器已经失去业务目标,直接确认避免无限热重放。 // 状态已过期或被清理时,该触发器已经失去业务目标,直接确认避免无限热重放。
return; return;
} }
if (state.getStatus() != null && state.getStatus().isTerminal()) {
// 终态不可再次执行;直接确认迟到或重复触发器,避免重新加载已清理的定义快照。
return;
}
ChainDefinition definition = getDefinitionForInstance(state); ChainDefinition definition = getDefinitionForInstance(state);

View File

@@ -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.chain.runtime.TriggerScheduler;
import com.easyagents.flow.core.node.EndNode; import com.easyagents.flow.core.node.EndNode;
import com.easyagents.flow.core.node.BaseNode; import com.easyagents.flow.core.node.BaseNode;
import com.easyagents.flow.core.node.ConfirmNode;
import com.easyagents.flow.core.node.StartNode; import com.easyagents.flow.core.node.StartNode;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Test; import org.junit.Test;
@@ -46,6 +47,7 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.Future; import java.util.concurrent.Future;
@@ -59,6 +61,43 @@ import java.util.concurrent.atomic.AtomicReference;
*/ */
public class ChainExecutorConcurrencyTest { 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<Map<String, Object>> 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。 * 验证实例初始化会在入口触发器创建前持久化工作流定义 ID。
* *
@@ -512,6 +551,36 @@ public class ChainExecutorConcurrencyTest {
return definition; 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;
}
/** /**
* 创建用于取消传播验证的工作流。 * 创建用于取消传播验证的工作流。
* *