feat: 完善 Agent Skill 渐进披露运行时
- 支持 Skill 绑定 MCP 冻结清单和延迟注册 - 拒绝同步工具工作流进入不可恢复挂起状态
This commit is contained in:
@@ -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) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 注册和模型提示所需的必要信息。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取元数据。
|
* 获取元数据。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建用于取消传播验证的工作流。
|
* 创建用于取消传播验证的工作流。
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user