Compare commits

..

10 Commits

Author SHA1 Message Date
0af5147c19 release: 发布v1.1.0 2026-08-20 11:34:00 +08:00
870c2cc583 fix: 统一 Shell 审批开关语义
- 关闭 Shell 审批时移除命令级审批策略与强制审批元数据

- 补充适配器与运行时回归测试
2026-08-20 11:23:55 +08:00
c8be163124 feat: 扩展标准 Skill 包兼容性
- 支持单层父目录包装和多 Skill ZIP 解码

- 安全忽略 macOS 元数据并保持路径校验
2026-08-19 22:38:01 +08:00
9612c5bd62 feat: 增加 Agent 安全工作区工具
- 提供受控文件读写、补丁、Shell 与归档能力

- 补齐路径、配额、命令审批和进程清理边界
2026-08-19 21:51:27 +08:00
c7d410d755 feat: 完善 Agent Skill 渐进披露运行时
- 支持 Skill 绑定 MCP 冻结清单和延迟注册

- 拒绝同步工具工作流进入不可恢复挂起状态
2026-08-19 21:51:16 +08:00
49a7de34bb feat: 标准化 Agent AG-UI 与审批运行时
- 新增 AG-UI 事件投影与协议编码模块

- 支持 Turn 级审批作用域和受信任动态审批策略
2026-08-19 21:50:58 +08:00
a34ca9271e refactor: 精简标准 Skill 包模型
- 统一使用 SKILL.md 与通用资源表达

- 删除仓储、专用资源类型和低价值兼容接口

- 保留安全 ZIP 编解码、校验和内容存储能力
2026-08-14 18:51:43 +08:00
b313523aba feat: 支持内容模板中文变量渲染
- 开启 Enjoy 中文表达式支持

- 补充中文、英文及上游引用回归测试
2026-08-11 21:41:55 +08:00
857fe7caf8 feat: 支持配置 OpenAI 消息内容块格式
- 新增标准字符串与文本内容块数组两种序列化模式

- 统一处理各角色消息并保留多模态及工具调用结构

- 补充默认模式与内容块模式测试
2026-08-11 20:56:05 +08:00
8d8d77ffda feat: 完善工作流实例状态查询与恢复
- 增加暂停态原子恢复守卫,避免重复恢复覆盖实例状态

- 支持从实例定义快照读取节点名称
2026-08-09 21:24:40 +08:00
100 changed files with 10497 additions and 2449 deletions

View File

@@ -28,6 +28,11 @@
<artifactId>fastjson2</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
</dependency>
<dependency>
<groupId>com.anthropic</groupId>
<artifactId>anthropic-java</artifactId>

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.message.*;
import com.easyagents.agent.runtime.mcp.McpRegistration;
import com.easyagents.agent.runtime.mcp.McpSkillRegistration;
import com.easyagents.agent.runtime.mcp.McpSpecValidator;
import com.easyagents.agent.runtime.mcp.McpToolkitAdapter;
import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore;
@@ -218,7 +219,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
saveSession();
return Flux.just(started(executionContext), cancelled(executionContext));
}).doOnNext(event -> executionContext.getConversationRecorder().record(executionContext, event))
.doFinally(signalType -> cleanupTurn());
.doFinally(signalType -> cleanupStreamSegment(false));
}
return runAgentStreamAfterLock(executionContext, List::of);
});
@@ -238,6 +239,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
if (!running.compareAndSet(false, true)) {
return Flux.error(new AgentRuntimeException("Agent runtime is already streaming."));
}
// 新用户消息建立新的 Turn上一 Turn 的 MCP 级批准不能跨轮复用。
approvalCoordinator.clearReusableApprovalScopes();
return runAgentStreamAfterLock(executionContext, inputSupplier);
});
}
@@ -296,8 +299,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
.doOnNext(event -> executionContext.getConversationRecorder().record(executionContext, event))
// 处理中断请求
.doOnCancel(() -> cancelInternal(executionContext, sideEvents, finalText, finalMessage, cancelled))
// 释放运行锁并清掉 turn context
.doFinally(signalType -> cleanupTurn());
// HITL 挂起时保留当前 Turn 的 MCP 批准,其余终态完整清理
.doFinally(signalType -> cleanupStreamSegment(suspendedEvent.get() != null));
}
/**
@@ -582,7 +585,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
suspended.getMetadata().put("approvalStatus", AgentToolApprovalResolution.Status.WAITING.name());
return Flux.just(started(context), suspended)
.doOnNext(event -> context.getConversationRecorder().record(context, event))
.doFinally(signalType -> cleanupTurn());
.doFinally(signalType -> cleanupStreamSegment(true));
}
/**
@@ -791,10 +794,15 @@ public class AgentScopeReActRuntime implements AgentRuntime {
}
/**
* 清理本轮状态。
* 清理一次 stream/resume 片段状态。
*
* @param preserveReusableApprovalScopes 是否因 HITL 挂起而保留当前 Turn 的 MCP 批准
*/
private void cleanupTurn() {
private void cleanupStreamSegment(boolean preserveReusableApprovalScopes) {
approvalCoordinator.clearExecutionAuthorizations();
if (!preserveReusableApprovalScopes) {
approvalCoordinator.clearReusableApprovalScopes();
}
turnContextHolder.clear();
running.set(false);
}
@@ -1123,7 +1131,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
AgentScopeMemoryBuildResult memoryResult = memoryAdapter.createMemoryResult(null, definition.getMemoryPolicy(), model);
Memory memory = memoryResult.getMemory();
Knowledge knowledge = knowledgeAdapter.createAggregateKnowledge(context, turnContextHolder);
SkillBox skillBox = skillAdapter.createSkillBox(definition.getSkillBoxSpec(), toolkit, skillTools);
SkillBox skillBox = skillAdapter.createSkillBox(definition.getSkillBoxSpec(), toolkit, skillTools,
toolkitBuildResult.skillMcpRegistrations());
// AutoContextInterceptor 是官方 AutoContextHook 的替代实现。这里仍只注册统一 runtime hook
// 避免官方 hook 与 Easy-Agents interceptor 同时触发压缩和 inputMessages 改写。
AgentRuntimeEventBridge eventBridge = new AgentRuntimeEventBridge(context, turnContextHolder);
@@ -1202,7 +1211,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
Toolkit toolkit) {
Map<String, List<AgentTool>> skillTools = new LinkedHashMap<>();
if (!context.getAgentDefinition().getExecutionOptions().isToolCallingEnabled()) {
return new AgentScopeToolkitBuildResult(skillTools, List.of(), List.of());
return new AgentScopeToolkitBuildResult(skillTools, List.of(), List.of(), List.of());
}
for (AgentToolSpec toolSpec : context.getAgentDefinition().getToolSpecs()) {
AgentToolInvoker invoker = context.getToolInvokers().get(toolSpec.getName());
@@ -1222,7 +1231,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
context.getAgentDefinition().getOperateToolSpecs(), toolkit);
McpSpecValidator.validateToolConflicts(context.getAgentDefinition().getToolSpecs(),
mcpRegistration.getToolSpecs(), context.getAgentDefinition().getOperateToolSpecs());
return new AgentScopeToolkitBuildResult(skillTools, mcpRegistration.getToolSpecs(), operateToolSpecs);
return new AgentScopeToolkitBuildResult(skillTools, mcpRegistration.getToolSpecs(), operateToolSpecs,
mcpRegistration.getSkillRegistrations());
}
private List<AgentToolSpec> mergeToolSpecs(List<AgentToolSpec> toolSpecs,
@@ -1290,7 +1300,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
}
private record AgentScopeToolkitBuildResult(Map<String, List<AgentTool>> skillTools,
List<AgentToolSpec> mcpToolSpecs,
List<AgentToolSpec> operateToolSpecs) {
List<AgentToolSpec> mcpToolSpecs,
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.AgentSkillCompiler;
import com.easyagents.agent.runtime.skill.AgentSkillSpec;
import com.easyagents.agent.runtime.mcp.McpSkillRegistration;
import io.agentscope.core.skill.AgentSkill;
import io.agentscope.core.skill.SkillBox;
import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.Toolkit;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
/**
@@ -67,6 +69,22 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler<AgentSkill> {
* @return SkillBox未配置 Skill 时返回 null
*/
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()) {
return null;
}
@@ -74,10 +92,15 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler<AgentSkill> {
? new SkillBox(toolkit)
: new SkillBox(toolkit, spec.getSkillBoxId());
skillBox.setExposeAllSkillMetadata(spec.isExposeAllSkillMetadata());
Map<String, List<McpSkillRegistration>> mcpBySkill = groupMcpRegistrations(skillMcpRegistrations);
for (AgentSkillSpec skillSpec : spec.getSkills()) {
AgentSkill skill = compile(skillSpec);
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()
.skill(skill)
.toolkit(toolkit)
@@ -97,11 +120,42 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler<AgentSkill> {
.agentTool(tool)
.apply();
}
for (McpSkillRegistration mcpRegistration : mcpRegistrations) {
skillBox.registration()
.skill(skill)
.toolkit(toolkit)
.enableTools(mcpRegistration.getEnableTools())
.disableTools(mcpRegistration.getDisableTools())
.presetParameters(mcpRegistration.getPresetParameters())
.mcpClient(mcpRegistration.getClient())
.apply();
}
}
if (!mcpBySkill.isEmpty()) {
throw new AgentRuntimeException("Skill-bound MCP references unknown skill: "
+ mcpBySkill.keySet().iterator().next());
}
skillBox.syncToolGroupStates();
return skillBox;
}
private Map<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 注册和模型提示所需的必要信息。
*

View File

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

View File

@@ -7,6 +7,7 @@ import com.easyagents.agent.runtime.event.AgentRuntimeEventBridge;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.event.AgentRuntimeInterceptor;
import com.easyagents.agent.runtime.hitl.AgentPendingState;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalEvaluation;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalCoordinator;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
@@ -22,9 +23,11 @@ import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -140,7 +143,12 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
private void interceptPreActing(PreActingEvent event) {
ToolUseBlock toolUse = event.getToolUse();
AgentToolSpec toolSpec = toolUse == null ? null : toolSpecs.get(toolUse.getName());
if (toolSpec == null || !toolSpec.isApprovalRequired()) {
if (!requiresApproval(toolSpec, toolUse)) {
return;
}
Map<String, Object> approvalMetadata = approvalMetadata(toolSpec, toolUse);
if (!requiresForcedApproval(toolSpec, toolUse)
&& approvalCoordinator.isReusableApprovalGranted(approvalMetadata)) {
return;
}
// 执行授权与 toolCallId、工具名称及入参同时绑定并且只能消费一次。
@@ -239,7 +247,121 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
*/
private boolean isApprovalRequired(ToolUseBlock toolUse) {
AgentToolSpec toolSpec = toolUse == null ? null : toolSpecs.get(toolUse.getName());
return toolSpec != null && toolSpec.isApprovalRequired();
return requiresApproval(toolSpec, toolUse);
}
/**
* 判断工具声明或当前调用是否要求审批。
*
* @param toolSpec 工具声明
* @param toolUse 当前工具调用
* @return 需要审批时为 true
*/
private boolean requiresApproval(AgentToolSpec toolSpec, ToolUseBlock toolUse) {
if (toolSpec == null) {
return false;
}
AgentToolApprovalEvaluation evaluation = approvalEvaluation(toolSpec, toolUse);
if (evaluation != null && !evaluation.valid()) {
// 无效命令直接进入工具并返回结构化拒绝,避免产生必然失败的审批请求。
return false;
}
return toolSpec.isApprovalRequired()
|| (evaluation != null && evaluation.approvalRequired())
|| requiresLegacyForcedApproval(toolSpec, toolUse);
}
/**
* 根据工具声明中的强制审批命令规则检查当前调用。
*
* <p>命令首词解析与受控 Shell 的引号、反斜杠规则保持一致,避免通过
* {@code 'rm'} 或 {@code r\m} 绕过动态审批。畸形命令仍由 Shell 工具拒绝。</p>
*
* @param toolSpec 工具声明
* @param toolUse 当前工具调用
* @return 命中强制审批命令时为 true
*/
private boolean requiresForcedApproval(AgentToolSpec toolSpec, ToolUseBlock toolUse) {
AgentToolApprovalEvaluation evaluation = approvalEvaluation(toolSpec, toolUse);
if (evaluation != null) {
return evaluation.valid() && evaluation.forced();
}
return requiresLegacyForcedApproval(toolSpec, toolUse);
}
/**
* 使用旧版元数据规则判断当前调用是否命中强制审批命令。
*
* @param toolSpec 工具声明
* @param toolUse 当前工具调用
* @return 命中旧版强制审批规则时为 true
*/
private boolean requiresLegacyForcedApproval(AgentToolSpec toolSpec, ToolUseBlock toolUse) {
if (toolSpec == null || toolUse == null || toolSpec.getMetadata() == null) {
return false;
}
Object commandsValue = toolSpec.getMetadata().get("forceApprovalCommands");
Object argumentValue = toolSpec.getMetadata().get("forceApprovalCommandArgument");
if (!(commandsValue instanceof Iterable<?> commands) || !(argumentValue instanceof String argumentName)
|| argumentName.isBlank() || toolUse.getInput() == null) {
return false;
}
Object commandValue = toolUse.getInput().get(argumentName);
if (!(commandValue instanceof String command)) {
return false;
}
String executable = firstCommandToken(command);
if (executable == null) {
return false;
}
for (Object forcedCommand : commands) {
if (forcedCommand instanceof String value && executable.equals(value)) {
return true;
}
}
return false;
}
/**
* 解析受限命令行的首个参数。
*
* @param command 命令行
* @return 首个参数;无有效参数时返回 null
*/
private String firstCommandToken(String command) {
if (command == null || command.isBlank()) {
return null;
}
StringBuilder token = new StringBuilder();
char quote = 0;
boolean escaping = false;
boolean started = false;
for (int index = 0; index < command.length(); index++) {
char character = command.charAt(index);
if (!started && Character.isWhitespace(character)) {
continue;
}
started = true;
if (escaping) {
token.append(character);
escaping = false;
} else if (character == '\\' && quote != '\'') {
escaping = true;
} else if (character == '\'' || character == '"') {
if (quote == 0) {
quote = character;
} else if (quote == character) {
quote = 0;
} else {
token.append(character);
}
} else if (Character.isWhitespace(character) && quote == 0) {
break;
} else {
token.append(character);
}
}
return token.isEmpty() ? null : token.toString();
}
/**
@@ -270,9 +392,26 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
if (toolUses == null || toolUses.isEmpty()) {
return List.of();
}
return toolUses.stream()
.filter(this::isApprovalRequired)
.toList();
List<ToolUseBlock> approvalTools = new ArrayList<>();
Set<String> pendingReusableScopes = new LinkedHashSet<>();
for (ToolUseBlock toolUse : toolUses) {
AgentToolSpec toolSpec = toolUse == null ? null : toolSpecs.get(toolUse.getName());
if (!requiresApproval(toolSpec, toolUse)) {
continue;
}
Map<String, Object> approvalMetadata = approvalMetadata(toolSpec, toolUse);
if (!requiresForcedApproval(toolSpec, toolUse)
&& approvalCoordinator.isReusableApprovalGranted(approvalMetadata)) {
continue;
}
String reusableScope = approvalCoordinator.reusableApprovalScope(approvalMetadata);
if (reusableScope != null && !pendingReusableScopes.add(reusableScope)) {
// 同一推理消息中同一 MCP 的多个工具共享一个审批请求。
continue;
}
approvalTools.add(toolUse);
}
return approvalTools;
}
/**
@@ -294,12 +433,18 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
Map<String, Object> metadata = approvalRequest == null
? new LinkedHashMap<>()
: new LinkedHashMap<>(approvalRequest.getMetadata());
metadata.putAll(toolUse.getMetadata() == null ? Map.of() : toolUse.getMetadata());
if (toolSpec.getMetadata() != null && !toolSpec.getMetadata().isEmpty()) {
// ToolSpec 由工具编译阶段生成,必须覆盖模型返回的同名治理字段(例如 toolType、mcpId
metadata.putAll(toolSpec.getMetadata());
}
AgentToolApprovalEvaluation evaluation = approvalEvaluation(toolSpec, toolUse);
if (evaluation != null && evaluation.metadata() != null) {
// 动态策略由受信任工具实例计算,必须覆盖模型与静态声明中的同名字段。
metadata.putAll(evaluation.metadata());
}
metadata.put("phase", "POST_REASONING");
metadata.put("source", "TOOL_HITL_INTERCEPTOR");
metadata.putAll(toolUse.getMetadata() == null ? Map.of() : toolUse.getMetadata());
return approvalCoordinator.register(
context == null ? null : context.getSessionId(),
context == null || context.getAgentDefinition() == null ? null : context.getAgentDefinition().getAgentId(),
@@ -312,6 +457,40 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
approvalBatchId);
}
/**
* 调用工具声明中的受信任动态审批策略。
*
* @param toolSpec 工具声明
* @param toolUse 当前工具调用
* @return 动态审批判定;未配置策略时返回 null
*/
private AgentToolApprovalEvaluation approvalEvaluation(AgentToolSpec toolSpec, ToolUseBlock toolUse) {
if (toolSpec == null || toolSpec.getApprovalPolicy() == null || toolUse == null) {
return null;
}
return toolSpec.getApprovalPolicy().evaluate(
toolUse.getInput() == null ? Map.of() : toolUse.getInput());
}
/**
* 合并静态工具元数据与动态审批元数据。
*
* @param toolSpec 工具声明
* @param toolUse 当前工具调用
* @return 用于审批作用域判断的受信任元数据
*/
private Map<String, Object> approvalMetadata(AgentToolSpec toolSpec, ToolUseBlock toolUse) {
Map<String, Object> metadata = new LinkedHashMap<>();
if (toolSpec != null && toolSpec.getMetadata() != null) {
metadata.putAll(toolSpec.getMetadata());
}
AgentToolApprovalEvaluation evaluation = approvalEvaluation(toolSpec, toolUse);
if (evaluation != null && evaluation.metadata() != null) {
metadata.putAll(evaluation.metadata());
}
return metadata;
}
/**
* 构建工具审批请求事件。
*
@@ -373,6 +552,7 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
putIfPresent(payload, metadata, "toolDisplayName");
putIfPresent(payload, metadata, "rawMcpToolName");
putIfPresent(payload, metadata, "mcpToolName");
putIfPresent(payload, metadata, "mcpId");
putIfPresent(payload, metadata, "mcpName");
putIfPresent(payload, metadata, "mcpTitle");
}

View File

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

View File

@@ -9,8 +9,6 @@ import com.easyagents.agent.runtime.tool.AgentToolSpec;
import io.agentscope.core.hook.HookEvent;
import io.agentscope.core.hook.PostActingEvent;
import io.agentscope.core.hook.PreActingEvent;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock;
import reactor.core.publisher.Mono;
@@ -103,12 +101,7 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
runtimeEvent.getPayload().put("toolCallId", toolUse.getId());
runtimeEvent.getPayload().put("name", toolUse.getName());
runtimeEvent.getPayload().put("toolName", toolUse.getName());
runtimeEvent.getPayload().put("input", toolUse.getInput());
runtimeEvent.getPayload().put("content", toolUse.getContent());
runtimeEvent.getPayload().put("status", "RUNNING");
runtimeEvent.getPayload().put("source", "HOOK");
runtimeEvent.getPayload().put("phase", "PRE_ACTING");
runtimeEvent.getMetadata().putAll(nullToEmpty(toolUse.getMetadata()));
enrichToolPayload(runtimeEvent, toolUse.getName());
eventBridge.emit(runtimeEvent);
}
@@ -129,15 +122,8 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
runtimeEvent.getPayload().put("toolCallId", toolCallId);
runtimeEvent.getPayload().put("name", toolName);
runtimeEvent.getPayload().put("toolName", toolName);
runtimeEvent.getPayload().put("text", resultText(result));
runtimeEvent.getPayload().put("suspended", result != null && result.isSuspended());
runtimeEvent.getPayload().put("status", success(result) ? "SUCCESS" : "FAILED");
runtimeEvent.getPayload().put("success", success(result));
runtimeEvent.getPayload().put("source", "HOOK");
runtimeEvent.getPayload().put("phase", "POST_ACTING");
if (result != null) {
runtimeEvent.getMetadata().putAll(nullToEmpty(result.getMetadata()));
}
enrichToolPayload(runtimeEvent, toolName);
eventBridge.emit(runtimeEvent);
}
@@ -149,12 +135,7 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
}
Map<String, Object> metadata = toolSpec.getMetadata();
putIfPresent(runtimeEvent.getPayload(), metadata, "toolDisplayName");
putIfPresent(runtimeEvent.getPayload(), metadata, "rawMcpToolName");
putIfPresent(runtimeEvent.getPayload(), metadata, "mcpToolName");
putIfPresent(runtimeEvent.getPayload(), metadata, "mcpName");
putIfPresent(runtimeEvent.getPayload(), metadata, "mcpTitle");
putIfPresent(runtimeEvent.getPayload(), metadata, "source");
runtimeEvent.getMetadata().putAll(metadata);
putIfPresent(runtimeEvent.getPayload(), metadata, "skillId");
}
private void putIfPresent(Map<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);
}
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) {
if (skillContext == null) {
return false;

View File

@@ -19,6 +19,9 @@ import java.util.concurrent.CompletableFuture;
*/
public class AgentToolApprovalCoordinator {
/** MCP 工具类型。 */
private static final String MCP_TOOL_TYPE = "MCP";
/** 是否启用内存审批协调。 */
private final boolean enabled;
/** 恢复令牌到待审批项的索引。 */
@@ -29,6 +32,8 @@ public class AgentToolApprovalCoordinator {
private final Map<String, String> tokensByToolCallId = new LinkedHashMap<>();
/** 工具调用ID到一次性执行授权的索引。 */
private final Map<String, ExecutionAuthorization> executionAuthorizations = new LinkedHashMap<>();
/** 当前 Turn 已批准的可复用工具作用域。 */
private final Set<String> reusableApprovalScopes = new LinkedHashSet<>();
/**
* 创建已启用的协调器。
@@ -300,11 +305,12 @@ public class AgentToolApprovalCoordinator {
}
/**
* 根据服务端持久化审批结果签发受信任的一次性执行授权。
* 根据服务端持久化审批结果签发受信任的执行授权。
*
* <p>恢复元数据应提供单个 {@code toolCallId/toolName/toolInput},或通过
* {@code approvedToolCalls} 提供上述字段组成的列表。该入口仅供已经完成持久化令牌
* 校验和一次性消费的服务端集成层使用。</p>
* {@code approvedToolCalls} 提供上述字段组成的列表。MCP 调用可额外携带受信任的
* {@code toolType/mcpId},用于签发当前 Turn 的复用作用域。该入口仅供已经完成
* 持久化令牌校验和一次性消费的服务端集成层使用。</p>
*
* @param request 受信任恢复请求
*/
@@ -318,16 +324,17 @@ public class AgentToolApprovalCoordinator {
: request.getMetadata();
Object approvedToolCalls = metadata.get("approvedToolCalls");
Map<String, ExecutionAuthorization> trustedAuthorizations = new LinkedHashMap<>();
Set<String> trustedReusableScopes = new LinkedHashSet<>();
int authorizationCount = 0;
if (approvedToolCalls instanceof List<?> calls) {
for (Object call : calls) {
if (call instanceof Map<?, ?> callMap) {
authorizeTrustedCall(callMap, trustedAuthorizations);
authorizeTrustedCall(callMap, trustedAuthorizations, trustedReusableScopes);
authorizationCount++;
}
}
} else if (metadata.containsKey("toolCallId")) {
authorizeTrustedCall(metadata, trustedAuthorizations);
authorizeTrustedCall(metadata, trustedAuthorizations, trustedReusableScopes);
authorizationCount++;
}
if (authorizationCount == 0) {
@@ -335,6 +342,7 @@ public class AgentToolApprovalCoordinator {
"Trusted resume metadata must include approved toolCallId, toolName, and toolInput.");
}
executionAuthorizations.putAll(trustedAuthorizations);
reusableApprovalScopes.addAll(trustedReusableScopes);
}
/**
@@ -363,6 +371,45 @@ public class AgentToolApprovalCoordinator {
}
}
/**
* 判断工具元数据对应的复用作用域是否已在当前 Turn 获得批准。
*
* @param metadata 服务端工具元数据
* @return 当前 Turn 已批准时为 true
*/
public synchronized boolean isReusableApprovalGranted(Map<String, Object> metadata) {
String approvalScope = reusableApprovalScope(metadata);
return approvalScope != null && reusableApprovalScopes.contains(approvalScope);
}
/**
* 解析可在当前 Turn 复用的审批作用域。
*
* <p>MCP 使用稳定 {@code mcpId} 生成作用域;受控 Shell 脚本仅接受动态审批策略写入的
* 内容摘要作用域。缺少受信任标识时返回 null使调用方继续执行逐调用审批。</p>
*
* @param metadata 服务端工具元数据
* @return 可复用审批作用域;不可复用时返回 null
*/
public String reusableApprovalScope(Map<String, Object> metadata) {
if (metadata == null || metadata.isEmpty()) {
return null;
}
String explicitScope = stringValue(metadata.get("approvalScope"));
if (Boolean.TRUE.equals(metadata.get("operateTool"))
&& "SHELL".equalsIgnoreCase(stringValue(metadata.get("operateToolType")))
&& explicitScope != null
&& explicitScope.startsWith("SHELL_SCRIPT:")) {
return explicitScope;
}
String toolType = stringValue(metadata.get("toolType"));
String mcpId = stringValue(metadata.get("mcpId"));
if (!MCP_TOOL_TYPE.equalsIgnoreCase(toolType) || mcpId == null) {
return null;
}
return MCP_TOOL_TYPE + ":" + mcpId;
}
/**
* 清理尚未消费的一次性执行授权。
*/
@@ -370,6 +417,13 @@ public class AgentToolApprovalCoordinator {
executionAuthorizations.clear();
}
/**
* 清理当前 Turn 的可复用工具审批作用域。
*/
public synchronized void clearReusableApprovalScopes() {
reusableApprovalScopes.clear();
}
/**
* 获取指定会话当前仍待处理的审批状态。
*
@@ -399,6 +453,7 @@ public class AgentToolApprovalCoordinator {
approvals.clear();
tokensByToolCallId.clear();
executionAuthorizations.clear();
reusableApprovalScopes.clear();
}
/**
@@ -459,6 +514,11 @@ public class AgentToolApprovalCoordinator {
*/
private void authorize(PendingApproval pendingApproval) {
AgentPendingState state = pendingApproval.state;
String approvalScope = reusableApprovalScope(state.getMetadata());
if (approvalScope != null) {
reusableApprovalScopes.add(approvalScope);
return;
}
if (state.getToolCallId() == null || state.getToolCallId().isBlank()) {
throw new AgentRuntimeException("Approved tool call is missing toolCallId.");
}
@@ -471,10 +531,12 @@ public class AgentToolApprovalCoordinator {
* 为服务端持久化审批结果签发一次性执行授权。
*
* @param callMap 已批准调用元数据
* @param trustedAuthorizations 本次恢复待签发的临时授权集合
* @param trustedAuthorizations 本次恢复待签发的一次性授权集合
* @param trustedReusableScopes 本次恢复待签发的可复用作用域集合
*/
private void authorizeTrustedCall(Map<?, ?> callMap,
Map<String, ExecutionAuthorization> trustedAuthorizations) {
Map<String, ExecutionAuthorization> trustedAuthorizations,
Set<String> trustedReusableScopes) {
String toolCallId = stringValue(callMap.get("toolCallId"));
String toolName = stringValue(callMap.get("toolName"));
if (toolCallId == null || toolName == null) {
@@ -482,6 +544,17 @@ public class AgentToolApprovalCoordinator {
"Trusted resume metadata must include non-empty toolCallId and toolName.");
}
Map<String, Object> toolInput = stringKeyMap(callMap.get("toolInput"));
Map<String, Object> scopeMetadata = new LinkedHashMap<>();
scopeMetadata.put("toolType", callMap.get("toolType"));
scopeMetadata.put("mcpId", callMap.get("mcpId"));
scopeMetadata.put("operateTool", callMap.get("operateTool"));
scopeMetadata.put("operateToolType", callMap.get("operateToolType"));
scopeMetadata.put("approvalScope", callMap.get("approvalScope"));
String reusableScope = reusableApprovalScope(scopeMetadata);
if (reusableScope != null) {
trustedReusableScopes.add(reusableScope);
return;
}
ExecutionAuthorization authorization = new ExecutionAuthorization(toolName, toolInput);
ExecutionAuthorization previous = trustedAuthorizations.put(toolCallId, authorization);
if (previous != null

View File

@@ -0,0 +1,49 @@
package com.easyagents.agent.runtime.hitl;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 单次工具调用的动态审批判定。
*
* @param valid 调用是否通过审批前静态校验
* @param approvalRequired 是否需要人工审批
* @param forced 是否禁止复用既有审批
* @param reusableScope 可复用审批作用域;为空表示逐调用审批
* @param metadata 写入审批事件的受信任元数据
*/
public record AgentToolApprovalEvaluation(
boolean valid,
boolean approvalRequired,
boolean forced,
String reusableScope,
Map<String, Object> metadata) {
/**
* 创建审批前静态校验失败的判定。
*
* @return 无需弹出审批的无效判定
*/
public static AgentToolApprovalEvaluation invalid() {
return new AgentToolApprovalEvaluation(false, false, false, null, Map.of());
}
/**
* 创建通过静态校验的判定。
*
* @param approvalRequired 是否需要审批
* @param forced 是否强制逐调用审批
* @param reusableScope 可复用作用域
* @return 动态审批判定
*/
public static AgentToolApprovalEvaluation valid(boolean approvalRequired,
boolean forced,
String reusableScope) {
Map<String, Object> metadata = new LinkedHashMap<>();
if (reusableScope != null && !reusableScope.isBlank()) {
metadata.put("approvalScope", reusableScope);
}
return new AgentToolApprovalEvaluation(
true, approvalRequired, forced, reusableScope, Map.copyOf(metadata));
}
}

View File

@@ -0,0 +1,18 @@
package com.easyagents.agent.runtime.hitl;
import java.util.Map;
/**
* 根据单次工具入参执行审批前校验并计算动态审批策略。
*/
@FunctionalInterface
public interface AgentToolApprovalPolicy {
/**
* 评估一次工具调用。
*
* @param toolInput 工具调用入参
* @return 动态审批判定
*/
AgentToolApprovalEvaluation evaluate(Map<String, Object> toolInput);
}

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<AgentToolSpec> toolSpecs;
private final List<McpSkillRegistration> skillRegistrations;
/**
* 创建 MCP 注册结果。
@@ -21,8 +22,24 @@ public class McpRegistration {
* @param 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.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() {
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 AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest();
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<>();
/**
@@ -406,6 +409,62 @@ public class McpSpec {
: new LinkedHashMap<>(toolApprovalRequests);
}
/**
* 获取所属 Skill ID。
*
* @return Skill ID未绑定 Skill 时为空
*/
public String getSkillId() {
return skillId;
}
/**
* 设置所属 Skill ID。
*
* @param skillId Skill ID
*/
public void setSkillId(String skillId) {
this.skillId = skillId;
}
/**
* 获取冻结 Tool 清单。
*
* @return 冻结 Tool 清单
*/
public List<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<AgentToolSpec> toolSpecs = new ArrayList<>();
List<McpSkillRegistration> skillRegistrations = new ArrayList<>();
try {
for (McpSpec spec : specs) {
if (spec == null) {
@@ -59,16 +60,59 @@ public class McpToolkitAdapter {
}
McpSpecValidator.validateConnection(spec);
McpClientWrapper client = clientFactory.create(spec);
client = applyAliases(spec, client);
clients.add(client);
registerClient(spec, client, toolkit);
if (isSkillBound(spec)) {
List<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)));
}
} catch (RuntimeException error) {
closeQuietly(clients);
throw error;
}
return new McpRegistration(clients, toolSpecs);
return new McpRegistration(clients, toolSpecs, skillRegistrations);
}
/**
* 根据冻结原始 Tool 名称和别名后的远端清单生成强制运行白名单。
*
* @param spec Skill MCP 声明
* @param client 已应用运行别名的 client
* @return 冻结 Tool 对应的运行名
*/
private List<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) {
@@ -95,7 +139,7 @@ public class McpToolkitAdapter {
}
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()) {
return List.of();
}
@@ -108,6 +152,30 @@ public class McpToolkitAdapter {
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) {
if (enableTools != null && !enableTools.isEmpty()) {
return enableTools.contains(toolName);
@@ -166,6 +234,9 @@ public class McpToolkitAdapter {
metadata.put("rawMcpToolName", rawToolName(spec, tool));
metadata.put("toolDisplayName", toolDisplayName(spec, tool));
metadata.put("transportType", spec.getTransportType().configValue());
if (isSkillBound(spec)) {
metadata.put("skillId", spec.getSkillId());
}
return metadata;
}

View File

@@ -7,6 +7,7 @@ public class AgentSkillBinding {
private final String skillId;
private final String skillName;
private final String skillDisplayName;
private final String skillBoxId;
/**
@@ -17,8 +18,26 @@ public class AgentSkillBinding {
* @param skillBoxId SkillBox ID
*/
public AgentSkillBinding(String skillId, String skillName, String skillBoxId) {
this(skillId, skillName, skillName, skillBoxId);
}
/**
* 创建带展示名称的 Skill 绑定关系。
*
* @param skillId Skill ID
* @param skillName Skill 规范名称
* @param skillDisplayName Skill 展示名称
* @param skillBoxId SkillBox ID
*/
public AgentSkillBinding(String skillId,
String skillName,
String skillDisplayName,
String skillBoxId) {
this.skillId = skillId;
this.skillName = skillName;
this.skillDisplayName = skillDisplayName == null || skillDisplayName.isBlank()
? skillName
: skillDisplayName;
this.skillBoxId = skillBoxId;
}
@@ -40,6 +59,15 @@ public class AgentSkillBinding {
return skillName;
}
/**
* 获取 Skill 展示名称。
*
* @return Skill 展示名称
*/
public String getSkillDisplayName() {
return skillDisplayName;
}
/**
* 获取 SkillBox ID。
*
@@ -49,4 +77,3 @@ public class AgentSkillBinding {
return skillBoxId;
}
}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package com.easyagents.agent.runtime.tool;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalPolicy;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -18,6 +19,7 @@ public class AgentToolSpec {
private AgentToolVisibility visibility = AgentToolVisibility.VISIBLE;
private boolean approvalRequired;
private AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest();
private AgentToolApprovalPolicy approvalPolicy;
private Map<String, Object> metadata = new LinkedHashMap<>();
/**
@@ -164,6 +166,24 @@ public class AgentToolSpec {
this.approvalRequest = approvalRequest == null ? new AgentToolApprovalRequest() : approvalRequest;
}
/**
* 获取单次调用动态审批策略。
*
* @return 动态审批策略;未配置时返回 null
*/
public AgentToolApprovalPolicy getApprovalPolicy() {
return approvalPolicy;
}
/**
* 设置单次调用动态审批策略。
*
* @param approvalPolicy 动态审批策略
*/
public void setApprovalPolicy(AgentToolApprovalPolicy approvalPolicy) {
this.approvalPolicy = approvalPolicy;
}
/**
* 获取元数据。
*

View File

@@ -6,21 +6,15 @@ import com.easyagents.agent.runtime.tool.AgentToolCategory;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import com.easyagents.agent.runtime.tool.AgentToolVisibility;
import io.agentscope.core.tool.Toolkit;
import io.agentscope.core.tool.coding.ShellCommandTool;
import io.agentscope.core.tool.file.ReadFileTool;
import io.agentscope.core.tool.file.WriteFileTool;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.*;
/**
* AgentScope 内置操作工具适配器。
*
* <p>该适配器只负责将 Easy-Agents 的操作工具声明转换为 AgentScope Toolkit 中的原生工具。
* Shell 工具的人工审批不使用 AgentScope {@code ShellCommandTool} 的同步 callback而是通过
* Easy-Agents 现有 {@code ToolHitlInterceptor} 统一处理,以保持 SSE 暂停、恢复和审计语义一致。
* <p>该适配器将 Easy-Agents 的操作工具声明转换为 AgentScope 1.x 工具名和 Schema 兼容的
* 受控实现。Shell 人工审批继续通过 Easy-Agents {@code ToolHitlInterceptor} 处理,以保持
* SSE 暂停、恢复和审计语义一致。
*/
public class AgentOperateToolAdapter {
@@ -28,6 +22,7 @@ public class AgentOperateToolAdapter {
public static final String LIST_DIRECTORY_TOOL = "list_directory";
public static final String WRITE_TEXT_FILE_TOOL = "write_text_file";
public static final String INSERT_TEXT_FILE_TOOL = "insert_text_file";
public static final String APPLY_PATCH_TOOL = "apply_patch";
public static final String EXECUTE_SHELL_COMMAND_TOOL = "execute_shell_command";
/**
@@ -75,6 +70,7 @@ public class AgentOperateToolAdapter {
names.add(WRITE_TEXT_FILE_TOOL);
names.add(INSERT_TEXT_FILE_TOOL);
}
case PATCH -> names.add(APPLY_PATCH_TOOL);
case SHELL -> names.add(EXECUTE_SHELL_COMMAND_TOOL);
default -> {
}
@@ -88,54 +84,66 @@ public class AgentOperateToolAdapter {
if (type == null) {
throw new AgentRuntimeException("Agent operate tool type is required.");
}
Path baseDir = validateBaseDir(spec);
WorkspacePathGuard pathGuard = createPathGuard(spec);
WorkspaceQuotaGuard quotaGuard = new WorkspaceQuotaGuard(
pathGuard, spec.getWorkspaceQuotaLimits(), spec.getWorkspaceQuotaHook());
switch (type) {
case READ_FILE -> {
assertNoToolConflict(toolkit, VIEW_TEXT_FILE_TOOL);
assertNoToolConflict(toolkit, LIST_DIRECTORY_TOOL);
toolkit.registerTool(new ReadFileTool(baseDir.toString()));
SafeReadFileTool readFileTool = new SafeReadFileTool(pathGuard, quotaGuard);
toolkit.registerAgentTool(readFileTool.viewTextFileTool());
toolkit.registerAgentTool(readFileTool.listDirectoryTool());
toolSpecs.add(toolSpec(spec, VIEW_TEXT_FILE_TOOL, "View text file content.", false));
toolSpecs.add(toolSpec(spec, LIST_DIRECTORY_TOOL, "List files and directories.", false));
}
case WRITE_FILE -> {
assertNoToolConflict(toolkit, WRITE_TEXT_FILE_TOOL);
assertNoToolConflict(toolkit, INSERT_TEXT_FILE_TOOL);
toolkit.registerTool(new WriteFileTool(baseDir.toString()));
toolSpecs.add(toolSpec(spec, WRITE_TEXT_FILE_TOOL, "Write or replace text file content.", true));
toolSpecs.add(toolSpec(spec, INSERT_TEXT_FILE_TOOL, "Insert text into a file.", true));
SafeWriteFileTool writeFileTool = new SafeWriteFileTool(pathGuard, quotaGuard);
toolkit.registerAgentTool(writeFileTool.writeTextFileTool());
toolkit.registerAgentTool(writeFileTool.insertTextFileTool());
toolSpecs.add(toolSpec(spec, WRITE_TEXT_FILE_TOOL, "Write or replace text file content.", false));
toolSpecs.add(toolSpec(spec, INSERT_TEXT_FILE_TOOL, "Insert text into a file.", false));
}
case PATCH -> {
assertNoToolConflict(toolkit, APPLY_PATCH_TOOL);
toolkit.registerAgentTool(new ApplyPatchTool(
pathGuard, quotaGuard, spec.getPatchMaxSize(),
spec.getPatchMaxFiles(), spec.getPatchMaxAffectedBytes()));
toolSpecs.add(toolSpec(spec, APPLY_PATCH_TOOL, "Apply a workspace text patch.", false));
}
case SHELL -> {
assertNoToolConflict(toolkit, EXECUTE_SHELL_COMMAND_TOOL);
Charset charset = parseCharset(spec);
toolkit.registerAgentTool(new ShellCommandTool(baseDir.toString(), spec.getShellAllowedCommands(), null,
null, charset));
toolSpecs.add(toolSpec(spec, EXECUTE_SHELL_COMMAND_TOOL, "Execute shell command.", true));
ControlledShellTool shellTool = new ControlledShellTool(pathGuard, quotaGuard, spec);
toolkit.registerAgentTool(shellTool);
AgentToolSpec shellToolSpec = toolSpec(
spec, EXECUTE_SHELL_COMMAND_TOOL, "Execute shell command.", true);
if (shellToolSpec.isApprovalRequired()) {
// 命令级审批策略服从 Agent 的 Shell 审批开关;关闭后仅保留安全校验。
shellToolSpec.setApprovalPolicy(shellTool::approvalEvaluation);
}
toolSpecs.add(shellToolSpec);
}
default -> throw new AgentRuntimeException("Unsupported agent operate tool type: " + type);
}
}
private Path validateBaseDir(AgentOperateToolSpec spec) {
private WorkspacePathGuard createPathGuard(AgentOperateToolSpec spec) {
String baseDir = spec.getBaseDir();
if (baseDir == null || baseDir.isBlank()) {
throw new AgentRuntimeException("Agent operate tool baseDir is required.");
}
Path path = Path.of(baseDir).toAbsolutePath().normalize();
if (!Path.of(baseDir).isAbsolute()) {
throw new AgentRuntimeException("Agent operate tool baseDir must be an absolute path: " + baseDir);
}
return path;
}
private Charset parseCharset(AgentOperateToolSpec spec) {
String charsetName = spec.getShellCharset();
if (charsetName == null || charsetName.isBlank()) {
return StandardCharsets.UTF_8;
throw new AgentRuntimeException("Agent operate tool baseDir must be an absolute path.");
}
try {
return Charset.forName(charsetName.trim());
} catch (Exception error) {
throw new AgentRuntimeException("Invalid shell charset: " + charsetName, error);
return new WorkspacePathGuard(Path.of(baseDir).toAbsolutePath().normalize());
} catch (RuntimeException error) {
if (error instanceof AgentRuntimeException runtimeError) {
throw runtimeError;
}
throw new AgentRuntimeException("Agent operate tool baseDir is invalid.", error);
}
}
@@ -152,7 +160,7 @@ public class AgentOperateToolAdapter {
toolSpec.setVisibility(AgentToolVisibility.VISIBLE);
toolSpec.setApprovalRequired(approvalRequired);
toolSpec.setApprovalRequest(approvalRequest(operateSpec, approvalRequired));
toolSpec.setMetadata(metadata(operateSpec));
toolSpec.setMetadata(metadata(operateSpec, approvalRequired));
return toolSpec;
}
@@ -168,11 +176,14 @@ public class AgentOperateToolAdapter {
return defaultRequest;
}
private Map<String, Object> metadata(AgentOperateToolSpec spec) {
private Map<String, Object> metadata(AgentOperateToolSpec spec, boolean approvalRequired) {
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("operateTool", true);
metadata.put("operateToolType", spec.getType().name());
metadata.put("baseDir", spec.getBaseDir());
if (spec.getType() == AgentOperateToolType.SHELL && approvalRequired) {
metadata.put("forceApprovalCommands", List.of("rm"));
metadata.put("forceApprovalCommandArgument", "command");
}
return metadata;
}

View File

@@ -4,13 +4,13 @@ import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
import java.util.LinkedHashSet;
import java.util.Set;
import java.time.Duration;
/**
* Agent 操作类工具声明。
*
* <p>操作类工具 runtime 直接适配的 AgentScope 内置工具,用于读文件、写文件和执行 Shell。
* 这些工具直接作用于后端 JVM 所在宿主环境,调用方必须按 agent、session 或 user 维度传入受控
* 的绝对工作目录。
* <p>操作类工具 runtime 适配为与 AgentScope 1.x 契约兼容的受控工具。调用方必须按
* agent、session 或 user 维度传入独立的绝对工作目录,并通过配额与 Shell 参数限制资源使用。
*/
public class AgentOperateToolSpec {
@@ -19,8 +19,18 @@ public class AgentOperateToolSpec {
private String baseDir;
private Boolean approvalRequired;
private AgentToolApprovalRequest approvalRequest;
private Set<String> shellAllowedCommands = new LinkedHashSet<>();
private String shellCharset;
private WorkspaceQuotaLimits workspaceQuotaLimits = WorkspaceQuotaLimits.unlimited();
private transient WorkspaceQuotaHook workspaceQuotaHook = WorkspaceQuotaHook.noop();
private Set<String> shellAllowedCommands = new LinkedHashSet<>(ControlledShellTool.DEFAULT_ALLOWED_COMMANDS);
private String shellCharset = "UTF-8";
private Duration shellDefaultTimeout = Duration.ofSeconds(60);
private Duration shellMaxTimeout = Duration.ofSeconds(300);
private int shellMaxCommandLength = 4096;
private long shellMaxOutputSize = 1024L * 1024L;
private int shellMaxConcurrency = 2;
private long patchMaxSize = 1024L * 1024L;
private int patchMaxFiles = 100;
private long patchMaxAffectedBytes = 16L * 1024L * 1024L;
/**
* 获取操作工具类型。
@@ -76,6 +86,43 @@ public class AgentOperateToolSpec {
this.baseDir = baseDir;
}
/**
* 获取工作区配额。
*
* @return 工作区配额
*/
public WorkspaceQuotaLimits getWorkspaceQuotaLimits() {
return workspaceQuotaLimits;
}
/**
* 设置工作区配额。
*
* @param workspaceQuotaLimits 工作区配额null 表示不限制
*/
public void setWorkspaceQuotaLimits(WorkspaceQuotaLimits workspaceQuotaLimits) {
this.workspaceQuotaLimits = workspaceQuotaLimits == null
? WorkspaceQuotaLimits.unlimited() : workspaceQuotaLimits;
}
/**
* 获取业务侧附加配额校验 Hook。
*
* @return 配额校验 Hook
*/
public WorkspaceQuotaHook getWorkspaceQuotaHook() {
return workspaceQuotaHook;
}
/**
* 设置业务侧附加配额校验 Hook。
*
* @param workspaceQuotaHook 配额校验 Hooknull 表示无附加校验
*/
public void setWorkspaceQuotaHook(WorkspaceQuotaHook workspaceQuotaHook) {
this.workspaceQuotaHook = workspaceQuotaHook == null ? WorkspaceQuotaHook.noop() : workspaceQuotaHook;
}
/**
* 获取审批开关覆盖值。
*
@@ -147,4 +194,148 @@ public class AgentOperateToolSpec {
public void setShellCharset(String shellCharset) {
this.shellCharset = shellCharset;
}
/**
* 获取 Shell 默认超时。
*
* @return 默认超时
*/
public Duration getShellDefaultTimeout() {
return shellDefaultTimeout;
}
/**
* 设置 Shell 默认超时。
*
* @param shellDefaultTimeout 默认超时
*/
public void setShellDefaultTimeout(Duration shellDefaultTimeout) {
this.shellDefaultTimeout = shellDefaultTimeout;
}
/**
* 获取 Shell 最大超时。
*
* @return 最大超时
*/
public Duration getShellMaxTimeout() {
return shellMaxTimeout;
}
/**
* 设置 Shell 最大超时。
*
* @param shellMaxTimeout 最大超时
*/
public void setShellMaxTimeout(Duration shellMaxTimeout) {
this.shellMaxTimeout = shellMaxTimeout;
}
/**
* 获取 Shell 命令最大长度。
*
* @return 最大字符数
*/
public int getShellMaxCommandLength() {
return shellMaxCommandLength;
}
/**
* 设置 Shell 命令最大长度。
*
* @param shellMaxCommandLength 最大字符数
*/
public void setShellMaxCommandLength(int shellMaxCommandLength) {
this.shellMaxCommandLength = shellMaxCommandLength;
}
/**
* 获取 Shell 单次标准输出和错误输出各自的最大字节数。
*
* @return 最大字节数
*/
public long getShellMaxOutputSize() {
return shellMaxOutputSize;
}
/**
* 设置 Shell 单次标准输出和错误输出各自的最大字节数。
*
* @param shellMaxOutputSize 最大字节数
*/
public void setShellMaxOutputSize(long shellMaxOutputSize) {
this.shellMaxOutputSize = shellMaxOutputSize;
}
/**
* 获取 JVM 实例级 Shell 最大并发数。
*
* @return 最大并发数
*/
public int getShellMaxConcurrency() {
return shellMaxConcurrency;
}
/**
* 设置 JVM 实例级 Shell 最大并发数。
*
* @param shellMaxConcurrency 最大并发数
*/
public void setShellMaxConcurrency(int shellMaxConcurrency) {
this.shellMaxConcurrency = shellMaxConcurrency;
}
/**
* 获取 Patch 输入最大字节数。
*
* @return 最大字节数
*/
public long getPatchMaxSize() {
return patchMaxSize;
}
/**
* 设置 Patch 输入最大字节数。
*
* @param patchMaxSize 最大字节数
*/
public void setPatchMaxSize(long patchMaxSize) {
this.patchMaxSize = patchMaxSize;
}
/**
* 获取 Patch 最大影响文件数。
*
* @return 最大文件数
*/
public int getPatchMaxFiles() {
return patchMaxFiles;
}
/**
* 设置 Patch 最大影响文件数。
*
* @param patchMaxFiles 最大文件数
*/
public void setPatchMaxFiles(int patchMaxFiles) {
this.patchMaxFiles = patchMaxFiles;
}
/**
* 获取 Patch 影响内容最大总字节数。
*
* @return 最大字节数
*/
public long getPatchMaxAffectedBytes() {
return patchMaxAffectedBytes;
}
/**
* 设置 Patch 影响内容最大总字节数。
*
* @param patchMaxAffectedBytes 最大字节数
*/
public void setPatchMaxAffectedBytes(long patchMaxAffectedBytes) {
this.patchMaxAffectedBytes = patchMaxAffectedBytes;
}
}

View File

@@ -15,6 +15,11 @@ public enum AgentOperateToolType {
*/
WRITE_FILE,
/**
* 以补丁方式新增、更新或删除工作区文本文件。
*/
PATCH,
/**
* 在服务进程所在宿主环境执行 Shell 命令。
*/

View File

@@ -0,0 +1,300 @@
package com.easyagents.agent.runtime.tool.operate;
import com.easyagents.agent.runtime.AgentRuntimeException;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.ToolCallParam;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 有界 unified diff / context hunk 工作区补丁工具。
*/
final class ApplyPatchTool implements AgentTool {
private static final Logger logger = LoggerFactory.getLogger(ApplyPatchTool.class);
private final WorkspacePathGuard pathGuard;
private final WorkspaceQuotaGuard quotaGuard;
private final long maxPatchSize;
private final int maxFiles;
private final long maxAffectedBytes;
/**
* 创建补丁工具。
*
* @param pathGuard 路径保护器
* @param quotaGuard 配额保护器
* @param maxPatchSize Patch 输入最大字节数
* @param maxFiles 单次最大影响文件数
* @param maxAffectedBytes 原内容与新内容合计最大字节数
*/
ApplyPatchTool(WorkspacePathGuard pathGuard,
WorkspaceQuotaGuard quotaGuard,
long maxPatchSize,
int maxFiles,
long maxAffectedBytes) {
if (maxPatchSize <= 0 || maxFiles <= 0 || maxAffectedBytes <= 0) {
throw new WorkspaceToolException("WORKSPACE_CONFIG_INVALID", "Patch limits must be positive.", false);
}
this.pathGuard = pathGuard;
this.quotaGuard = quotaGuard;
this.maxPatchSize = maxPatchSize;
this.maxFiles = maxFiles;
this.maxAffectedBytes = maxAffectedBytes;
}
/**
* 获取工具名。
*
* @return `apply_patch`
*/
@Override
public String getName() {
return AgentOperateToolAdapter.APPLY_PATCH_TOOL;
}
/**
* 获取工具描述。
*
* @return 工具描述
*/
@Override
public String getDescription() {
return "Apply a bounded unified diff to workspace-relative UTF-8 text files atomically per file.";
}
/**
* 获取参数 Schema。
*
* @return JSON Schema
*/
@Override
public Map<String, Object> getParameters() {
return Map.of(
"type", "object",
"properties", Map.of("patch", Map.of(
"type", "string",
"description", "Unified diff or *** Begin Patch context patch")),
"required", List.of("patch"));
}
/**
* 解析、预检并应用补丁。
*
* @param param Tool 调用参数
* @return Tool 结果
*/
@Override
public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
return Mono.fromCallable(() -> apply(param)).subscribeOn(Schedulers.boundedElastic());
}
private ToolResultBlock apply(ToolCallParam param) {
try {
Object value = param == null ? null : param.getInput().get("patch");
if (!(value instanceof String patch) || patch.isBlank()) {
throw new WorkspaceToolException("PATCH_INVALID", "Missing required string parameter: patch.", false);
}
if (patch.getBytes(StandardCharsets.UTF_8).length > maxPatchSize) {
throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
"Patch input exceeds the configured maximum size.", false);
}
List<FilePatch> patches = UnifiedPatchParser.parse(patch);
if (patches.isEmpty()) {
throw new WorkspaceToolException("PATCH_INVALID", "Patch does not contain file changes.", false);
}
if (patches.size() > maxFiles) {
throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
"Patch affects too many files.", false);
}
PatchPlan plan = prepare(patches);
commit(plan);
return ToolResultBlock.text("Patch applied successfully: " + plan.changes().size()
+ " file(s), " + plan.addedLines() + " insertion(s), "
+ plan.deletedLines() + " deletion(s).");
} catch (AgentRuntimeException error) {
return WorkspaceToolResults.error(error);
} catch (RuntimeException error) {
return WorkspaceToolResults.error(
new AgentRuntimeException("Unexpected patch execution failure.", error));
}
}
private PatchPlan prepare(List<FilePatch> patches) {
Map<Path, byte[]> originals = new LinkedHashMap<>();
Map<Path, byte[]> desired = new LinkedHashMap<>();
Map<Path, Long> resultingSizes = new LinkedHashMap<>();
long affectedBytes = 0;
int addedLines = 0;
int deletedLines = 0;
for (FilePatch patch : patches) {
Path target = patch.type() == PatchType.ADD
? pathGuard.resolveForWrite(patch.path()) : pathGuard.resolveExistingFile(patch.path());
if (originals.containsKey(target)) {
throw new WorkspaceToolException("PATCH_INVALID", "Patch contains a duplicate target.", false);
}
byte[] original = null;
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
quotaGuard.validateFullRead(target);
original = readBytes(target);
}
if (patch.type() == PatchType.ADD && original != null) {
throw new WorkspaceToolException("PATCH_CONFLICT", "Patch add target already exists.", false);
}
String current = original == null ? "" : WorkspaceTextFiles.decodeUtf8(original);
String updated = UnifiedPatchParser.apply(patch, current);
byte[] next = null;
if (patch.type() != PatchType.DELETE) {
next = updated.getBytes(StandardCharsets.UTF_8);
}
affectedBytes = addBounded(affectedBytes, original == null ? 0 : original.length);
affectedBytes = addBounded(affectedBytes, next == null ? 0 : next.length);
originals.put(target, original);
desired.put(target, next);
resultingSizes.put(target, next == null ? -1L : (long) next.length);
addedLines += patch.addedLines();
deletedLines += patch.deletedLines();
}
quotaGuard.validateBatch(resultingSizes);
return new PatchPlan(originals, desired, List.copyOf(desired.keySet()), addedLines, deletedLines);
}
private long addBounded(long left, long right) {
long value;
try {
value = Math.addExact(left, right);
} catch (ArithmeticException error) {
throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
"Patch affected content exceeds the configured maximum size.", false, error);
}
if (value > maxAffectedBytes) {
throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
"Patch affected content exceeds the configured maximum size.", false);
}
return value;
}
private void commit(PatchPlan plan) {
List<Path> committed = new ArrayList<>();
try {
for (Path target : plan.changes()) {
byte[] next = plan.desired().get(target);
pathGuard.revalidate(target);
if (next == null) {
Files.delete(target);
} else {
WorkspaceTextFiles.atomicWrite(pathGuard, target, next);
}
committed.add(target);
}
} catch (Exception commitError) {
Collections.reverse(committed);
Exception rollbackError = null;
for (Path target : committed) {
try {
byte[] original = plan.originals().get(target);
if (original == null) {
Files.deleteIfExists(target);
} else {
WorkspaceTextFiles.atomicWrite(pathGuard, target, original);
}
} catch (Exception error) {
if (rollbackError == null) {
rollbackError = error;
} else {
rollbackError.addSuppressed(error);
}
}
}
if (rollbackError != null) {
commitError.addSuppressed(rollbackError);
logger.error("Patch commit and rollback failed; workspace requires inspection", commitError);
throw new WorkspaceToolException("PATCH_ROLLBACK_FAILED",
"Patch commit and rollback failed; workspace requires inspection.", false, commitError);
}
logger.error("Patch commit failed and was rolled back", commitError);
throw new WorkspaceToolException("WORKSPACE_IO_FAILED",
"Patch commit failed and all changes were rolled back.", true, commitError);
}
}
private byte[] readBytes(Path target) {
return WorkspaceTextFiles.readUtf8(target).getBytes(StandardCharsets.UTF_8);
}
/**
* 补丁事务计划。
*
* @param originals 提交前原内容
* @param desired 提交后内容null 表示删除
* @param changes 有序目标列表
* @param addedLines 新增行数
* @param deletedLines 删除行数
*/
private record PatchPlan(Map<Path, byte[]> originals,
Map<Path, byte[]> desired,
List<Path> changes,
int addedLines,
int deletedLines) {
}
/**
* 文件变更类型。
*/
enum PatchType {
/** 新增文件。 */
ADD,
/** 更新文件。 */
UPDATE,
/** 删除文件。 */
DELETE
}
/**
* 单文件补丁。
*
* @param type 变更类型
* @param path 工作区相对路径
* @param hunks 上下文块
* @param addedLines 新增行数
* @param deletedLines 删除行数
*/
record FilePatch(PatchType type,
String path,
List<Hunk> hunks,
int addedLines,
int deletedLines) {
}
/**
* 单个上下文块。
*
* @param oldStart unified diff 声明的原起始行,可空
* @param lines 上下文行
*/
record Hunk(Integer oldStart, List<DiffLine> lines) {
}
/**
* 上下文行。
*
* @param kind 空格表示上下文,减号表示删除,加号表示新增
* @param text 行内容
*/
record DiffLine(char kind, String text) {
}
}

View File

@@ -0,0 +1,777 @@
package com.easyagents.agent.runtime.tool.operate;
import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalEvaluation;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.ToolCallParam;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
/**
* 不经过系统 Shell 解释器的受控命令执行工具。
*
* <p>命令先按受限引号规则拆分为参数,再直接交给 {@link ProcessBuilder}。因此管道、重定向、
* 命令替换和环境变量展开既会被显式拒绝,也不会被二次解释。
*/
public final class ControlledShellTool implements AgentTool {
/** L22 首版固定命令白名单。 */
public static final Set<String> DEFAULT_ALLOWED_COMMANDS = Set.of(
"pwd", "ls", "cat", "head", "tail", "wc", "grep", "rg", "sed", "awk", "sort", "uniq",
"cut", "tr", "basename", "dirname", "stat", "file", "date", "sha256sum", "shasum", "jq",
"diff", "cmp", "du", "tree",
"mkdir", "touch", "cp", "mv", "rm", "python", "python3", "node",
"gzip", "gunzip", "zip", "unzip", "tar",
"pandoc", "soffice", "pdftoppm", "pdfinfo", "pdftotext", "pdfimages", "qpdf");
private static final Set<String> APPROVAL_REQUIRED_COMMANDS = Set.of(
"mkdir", "touch", "cp", "mv", "gzip", "gunzip", "zip", "unzip", "tar",
"pandoc", "soffice", "pdftoppm", "pdfimages", "qpdf");
private static final Map<Integer, Semaphore> INSTANCE_LIMITERS = new ConcurrentHashMap<>();
private static final Map<Integer, ExecutorService> OUTPUT_EXECUTORS = new ConcurrentHashMap<>();
private static final Map<Process, ActiveProcess> ACTIVE_PROCESS_TREES = new ConcurrentHashMap<>();
private static final AtomicInteger OUTPUT_THREAD_SEQUENCE = new AtomicInteger();
private static final String FORBIDDEN_METACHARACTERS = ";|&><`$";
private static final String TRUSTED_EXECUTABLE_PATH = "/usr/local/bin:/usr/bin:/bin";
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
for (Map.Entry<Process, ActiveProcess> entry : ACTIVE_PROCESS_TREES.entrySet()) {
ActiveProcess active = entry.getValue();
active.processGroupSupport().terminate(active.processGroupId());
terminateProcessTreeNow(entry.getKey(), active.observedDescendants());
}
OUTPUT_EXECUTORS.values().forEach(ExecutorService::shutdownNow);
}, "easyagents-shell-shutdown"));
}
private final WorkspacePathGuard pathGuard;
private final WorkspaceQuotaGuard quotaGuard;
private final Set<String> allowedCommands;
private final int defaultTimeoutSeconds;
private final int maxTimeoutSeconds;
private final int maxCommandLength;
private final int maxOutputSize;
private final Semaphore limiter;
private final ExecutorService outputExecutor;
private final ShellCommandOptionValidator optionValidator;
private final SafeArchiveCommandExecutor archiveCommandExecutor;
private final ShellProcessGroupSupport processGroupSupport;
/**
* 创建受控 Shell 工具。
*
* @param pathGuard 路径保护器
* @param quotaGuard 配额保护器
* @param spec 操作工具配置
*/
public ControlledShellTool(WorkspacePathGuard pathGuard,
WorkspaceQuotaGuard quotaGuard,
AgentOperateToolSpec spec) {
this.pathGuard = pathGuard;
this.quotaGuard = quotaGuard;
this.allowedCommands = validateAllowedCommands(spec.getShellAllowedCommands());
this.defaultTimeoutSeconds = seconds(spec.getShellDefaultTimeout(), "shellDefaultTimeout");
this.maxTimeoutSeconds = seconds(spec.getShellMaxTimeout(), "shellMaxTimeout");
if (defaultTimeoutSeconds > maxTimeoutSeconds) {
throw new AgentRuntimeException("Shell default timeout must not exceed max timeout.");
}
if (spec.getShellMaxCommandLength() <= 0 || spec.getShellMaxOutputSize() <= 0
|| spec.getShellMaxOutputSize() > Integer.MAX_VALUE || spec.getShellMaxConcurrency() <= 0
|| spec.getShellMaxConcurrency() > 64) {
throw new AgentRuntimeException("Shell limits must be positive and output size must fit in memory.");
}
if (spec.getShellCharset() != null && !spec.getShellCharset().isBlank()
&& !"UTF-8".equalsIgnoreCase(spec.getShellCharset().trim())) {
throw new AgentRuntimeException("Shell charset must be UTF-8.");
}
this.maxCommandLength = spec.getShellMaxCommandLength();
this.maxOutputSize = (int) spec.getShellMaxOutputSize();
this.limiter = INSTANCE_LIMITERS.computeIfAbsent(spec.getShellMaxConcurrency(), Semaphore::new);
this.outputExecutor = OUTPUT_EXECUTORS.computeIfAbsent(
spec.getShellMaxConcurrency(), ControlledShellTool::createOutputExecutor);
this.optionValidator = new ShellCommandOptionValidator(pathGuard);
this.archiveCommandExecutor = new SafeArchiveCommandExecutor(pathGuard, quotaGuard, maxOutputSize);
this.processGroupSupport = ShellProcessGroupSupport.detect();
}
/**
* 获取工具名。
*
* @return `execute_shell_command`
*/
@Override
public String getName() {
return AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL;
}
/**
* 获取工具描述。
*
* @return 工具描述
*/
@Override
public String getDescription() {
return "Execute one allowlisted command in the workspace without shell operators or host path access.";
}
/**
* 获取与 AgentScope 1.x 兼容的参数 Schema。
*
* @return JSON Schema
*/
@Override
public Map<String, Object> getParameters() {
return Map.of(
"type", "object",
"properties", Map.of(
"command", Map.of("type", "string", "description", "The single command to execute"),
"timeout", Map.of("type", "integer", "description", "Execution timeout in seconds"),
"charset", Map.of("type", "string", "description", "Must be UTF-8 when supplied")),
"required", List.of("command"));
}
/**
* 校验并异步执行命令。
*
* @param param Tool 调用参数
* @return Tool 结果
*/
@Override
public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
return Mono.fromCallable(() -> execute(param)).subscribeOn(Schedulers.boundedElastic());
}
/**
* 在 HITL 事件生成前校验命令并计算单次调用的审批策略。
*
* <p>无效命令不弹出审批随后由工具调用返回结构化拒绝结果。Python/Node 脚本以
* 脚本内容和参数的摘要作为本轮复用作用域,脚本变化后必须重新审批。</p>
*
* @param toolInput Shell 工具入参
* @return 动态审批判定
*/
public AgentToolApprovalEvaluation approvalEvaluation(Map<String, Object> toolInput) {
try {
String command = requiredCommand(toolInput);
List<String> arguments = parse(command);
validate(arguments);
String executable = arguments.get(0);
if ("rm".equals(executable)) {
return AgentToolApprovalEvaluation.valid(true, true, null);
}
if (Set.of("python", "python3", "node").contains(executable)) {
return AgentToolApprovalEvaluation.valid(true, false, scriptApprovalScope(arguments));
}
if ("pdftotext".equals(executable)) {
boolean stdoutOnly = arguments.size() >= 3 && "-".equals(arguments.get(arguments.size() - 1));
return AgentToolApprovalEvaluation.valid(!stdoutOnly, false, null);
}
return AgentToolApprovalEvaluation.valid(
APPROVAL_REQUIRED_COMMANDS.contains(executable), false, null);
} catch (RuntimeException error) {
return AgentToolApprovalEvaluation.invalid();
}
}
private ToolResultBlock execute(ToolCallParam param) {
boolean acquired = false;
Process process = null;
long processGroupId = -1;
Set<ProcessHandle> observedDescendants = ConcurrentHashMap.newKeySet();
try {
String command = requiredCommand(param);
int timeout = requestedTimeout(param);
validateCharset(param);
List<String> arguments = parse(command);
validate(arguments);
quotaGuard.validateCurrentUsage();
acquired = limiter.tryAcquire(Math.min(timeout, defaultTimeoutSeconds), TimeUnit.SECONDS);
if (!acquired) {
return WorkspaceToolResults.error(
"SHELL_CONCURRENCY_LIMIT", "Shell execution queue is full.", true);
}
long startedAt = System.nanoTime();
if (SafeArchiveCommandExecutor.COMMANDS.contains(arguments.get(0))) {
SafeArchiveCommandExecutor.ArchiveExecutionResult archiveResult =
archiveCommandExecutor.execute(arguments,
startedAt + TimeUnit.SECONDS.toNanos(timeout));
quotaGuard.validateCurrentUsage();
long durationMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
return result(0,
new BoundedOutput(archiveResult.output(), archiveResult.truncated()),
new BoundedOutput("", false), null, null, false, durationMillis);
}
ProcessBuilder processBuilder = new ProcessBuilder(processGroupSupport.wrap(arguments));
processBuilder.directory(pathGuard.root().toFile());
sanitizeEnvironment(processBuilder.environment());
process = processBuilder.start();
processGroupId = processGroupSupport.enabled() ? process.pid() : -1;
ACTIVE_PROCESS_TREES.put(process,
new ActiveProcess(observedDescendants, processGroupSupport, processGroupId));
CompletableFuture<BoundedOutput> stdout = readBounded(process.getInputStream());
CompletableFuture<BoundedOutput> stderr = readBounded(process.getErrorStream());
boolean completed;
try {
completed = waitForProcess(process, timeout, observedDescendants);
} catch (InterruptedException interrupted) {
terminateProcessTree(process, observedDescendants, processGroupId);
Thread.currentThread().interrupt();
return WorkspaceToolResults.error("SHELL_INTERRUPTED", "Shell command was interrupted.", true);
}
if (!completed) {
terminateProcessTree(process, observedDescendants, processGroupId);
} else {
// 白名单脚本不允许在 Tool 正常返回后遗留后台子进程。
processGroupSupport.terminate(processGroupId);
terminateObservedDescendants(observedDescendants);
}
BoundedOutput stdoutValue = awaitOutput(stdout);
BoundedOutput stderrValue = awaitOutput(stderr);
quotaGuard.validateCurrentUsage();
long durationMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
if (!completed) {
return result(-1, stdoutValue, stderrValue,
"SHELL_TIMEOUT", "Shell command exceeded " + timeout + " seconds.", true, durationMillis);
}
return result(process.exitValue(), stdoutValue, stderrValue, null, null, false, durationMillis);
} catch (WorkspaceToolException error) {
return WorkspaceToolResults.error(error);
} catch (AgentRuntimeException error) {
return WorkspaceToolResults.error("SHELL_COMMAND_DENIED", error.getMessage(), false);
} catch (IOException error) {
return WorkspaceToolResults.error(
"SHELL_EXECUTION_FAILED", "Command is unavailable or could not be started.", false);
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
return WorkspaceToolResults.error("SHELL_INTERRUPTED", "Shell execution queue wait was interrupted.", true);
} catch (RuntimeException error) {
return WorkspaceToolResults.error(
new AgentRuntimeException("Unexpected shell execution failure.", error));
} finally {
if (process != null) {
if (process.isAlive()) {
terminateProcessTree(process, observedDescendants, processGroupId);
} else {
processGroupSupport.terminate(processGroupId);
terminateObservedDescendants(observedDescendants);
}
ACTIVE_PROCESS_TREES.remove(process);
}
if (acquired) {
limiter.release();
}
}
}
private List<String> parse(String command) {
List<String> tokens = new ArrayList<>();
StringBuilder current = new StringBuilder();
char quote = 0;
boolean escaping = false;
for (int index = 0; index < command.length(); index++) {
char character = command.charAt(index);
if (character == '\n' || character == '\r' || character == '\0'
|| Character.isISOControl(character)) {
throw new AgentRuntimeException("Shell control characters are not allowed.");
}
if (FORBIDDEN_METACHARACTERS.indexOf(character) >= 0 || character == '~') {
throw new AgentRuntimeException("Shell operators, substitutions, and expansions are not allowed.");
}
if (escaping) {
current.append(character);
escaping = false;
} else if (character == '\\' && quote != '\'') {
escaping = true;
} else if ((character == '\'' || character == '"')) {
if (quote == 0) {
quote = character;
} else if (quote == character) {
quote = 0;
} else {
current.append(character);
}
} else if (Character.isWhitespace(character) && quote == 0) {
if (!current.isEmpty()) {
tokens.add(current.toString());
current.setLength(0);
}
} else {
current.append(character);
}
}
if (escaping || quote != 0) {
throw new AgentRuntimeException("Shell command contains an unfinished escape or quote.");
}
if (!current.isEmpty()) {
tokens.add(current.toString());
}
if (tokens.isEmpty()) {
throw new AgentRuntimeException("Shell command is required.");
}
return tokens;
}
private void validate(List<String> arguments) {
String executable = arguments.get(0);
if (executable.contains("/") || executable.contains("\\") || !allowedCommands.contains(executable)) {
throw new AgentRuntimeException("Shell command is not allowlisted: " + executable);
}
for (int index = 1; index < arguments.size(); index++) {
String argument = arguments.get(index);
rejectHostOrTraversalPath(argument);
validateExistingPathArgument(argument);
}
optionValidator.validate(arguments);
if ("python".equals(executable) || "python3".equals(executable)) {
validateScript(arguments, Set.of(".py"), "-c", "-m");
} else if ("node".equals(executable)) {
validateScript(arguments, Set.of(".js", ".mjs", ".cjs"), "-e", "--eval");
} else if ("rm".equals(executable)) {
validateRemove(arguments);
}
}
private void validateScript(List<String> arguments, Set<String> extensions, String... deniedOptions) {
if (arguments.size() < 2 || arguments.get(1).startsWith("-")) {
throw new AgentRuntimeException("Script command requires a workspace script file as its first argument.");
}
for (String denied : deniedOptions) {
if (arguments.contains(denied)) {
throw new AgentRuntimeException("Inline or module script execution is not allowed.");
}
}
String script = arguments.get(1);
if (extensions.stream().noneMatch(script::endsWith)) {
throw new AgentRuntimeException("Script file extension is not allowed.");
}
pathGuard.resolveExistingFile(script);
}
private void validateRemove(List<String> arguments) {
boolean hasTarget = false;
boolean recursive = false;
boolean force = false;
for (int index = 1; index < arguments.size(); index++) {
String argument = arguments.get(index);
if (argument.startsWith("-")) {
String flags = argument.replace("-", "");
recursive |= flags.contains("r") || flags.contains("R") || "recursive".equals(flags);
force |= flags.contains("f") || "force".equals(flags);
continue;
}
if (".".equals(argument) || "./".equals(argument)) {
throw new AgentRuntimeException("Workspace root cannot be removed.");
}
hasTarget = true;
}
if (!hasTarget) {
throw new AgentRuntimeException("rm requires at least one workspace target.");
}
if (recursive && force) {
throw new AgentRuntimeException("Recursive forced removal is not allowed.");
}
}
private void rejectHostOrTraversalPath(String argument) {
if (argument.startsWith("-")
&& (argument.contains("/") || argument.contains("\\") || argument.contains("~"))) {
throw new AgentRuntimeException("Shell option-embedded paths are not allowed.");
}
String candidate = optionValue(argument);
if (candidate.isEmpty() || candidate.startsWith("-")) {
return;
}
if (candidate.startsWith("/") || candidate.startsWith("\\")
|| candidate.matches("^[A-Za-z]:[\\\\/].*") || candidate.startsWith("~")) {
throw new AgentRuntimeException("Shell absolute paths are not allowed.");
}
if (candidate.matches("^[A-Za-z][A-Za-z0-9+.-]*://.*")
|| candidate.regionMatches(true, 0, "file:", 0, "file:".length())
|| candidate.regionMatches(true, 0, "data:", 0, "data:".length())) {
throw new AgentRuntimeException("Shell URI inputs are not allowed.");
}
for (String segment : candidate.replace('\\', '/').split("/")) {
if ("..".equals(segment)) {
throw new AgentRuntimeException("Shell path traversal is not allowed.");
}
}
}
private void validateExistingPathArgument(String argument) {
String candidate = optionValue(argument);
if (candidate.isEmpty() || candidate.startsWith("-") || candidate.equals(".")) {
return;
}
Path possible = pathGuard.root().resolve(candidate).normalize();
if (!possible.startsWith(pathGuard.root()) || !Files.exists(possible, LinkOption.NOFOLLOW_LINKS)) {
return;
}
pathGuard.resolveExistingEntry(candidate);
}
private String optionValue(String argument) {
int equals = argument.indexOf('=');
return equals >= 0 ? argument.substring(equals + 1) : argument;
}
private void sanitizeEnvironment(Map<String, String> environment) {
environment.clear();
// 固定搜索路径,避免宿主继承 PATH 中的可写目录劫持白名单命令。
environment.put("PATH", TRUSTED_EXECUTABLE_PATH);
environment.put("PYTHONPATH", "/opt/easyflow/python-packages");
environment.put("NODE_PATH", "/app/node_modules");
environment.put("HOME", pathGuard.root().toString());
environment.put("TMPDIR", pathGuard.root().toString());
environment.put("LANG", "C.UTF-8");
environment.put("LC_ALL", "C.UTF-8");
}
private String requiredCommand(ToolCallParam param) {
Object value = param == null ? null : param.getInput().get("command");
return requiredCommand(value);
}
/**
* 从动态审批入参中读取命令。
*
* @param input 工具调用入参
* @return 已完成基础校验的命令
*/
private String requiredCommand(Map<String, Object> input) {
Object value = input == null ? null : input.get("command");
return requiredCommand(value);
}
/**
* 校验命令值与最大长度。
*
* @param value 原始命令值
* @return 已完成基础校验的命令
*/
private String requiredCommand(Object value) {
if (!(value instanceof String command) || command.isBlank()) {
throw new AgentRuntimeException("Shell command is required.");
}
if (command.length() > maxCommandLength) {
throw new AgentRuntimeException("Shell command exceeds max-command-length.");
}
return command;
}
/**
* 根据脚本内容和完整参数计算当前 Turn 的复用审批作用域。
*
* @param arguments 命令参数
* @return 带类型前缀的 SHA-256 审批作用域
*/
private String scriptApprovalScope(List<String> arguments) {
Path script = pathGuard.resolveExistingFile(arguments.get(1));
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream input = Files.newInputStream(script)) {
byte[] buffer = new byte[8192];
int read;
while ((read = input.read(buffer)) >= 0) {
digest.update(buffer, 0, read);
}
}
for (String argument : arguments) {
digest.update((byte) 0);
digest.update(argument.getBytes(StandardCharsets.UTF_8));
}
return "SHELL_SCRIPT:" + java.util.HexFormat.of().formatHex(digest.digest());
} catch (IOException error) {
throw new WorkspaceToolException(
"WORKSPACE_IO_FAILED", "Script could not be hashed before approval.", true, error);
} catch (NoSuchAlgorithmException error) {
throw new AgentRuntimeException("SHA-256 is unavailable for script approval.", error);
}
}
private int requestedTimeout(ToolCallParam param) {
Object value = param == null ? null : param.getInput().get("timeout");
if (value == null) {
return defaultTimeoutSeconds;
}
if (!(value instanceof Number number)) {
throw new AgentRuntimeException("Shell timeout must be an integer number of seconds.");
}
int timeout = number.intValue();
if (timeout <= 0 || timeout > maxTimeoutSeconds) {
throw new AgentRuntimeException("Shell timeout is outside the configured range.");
}
return timeout;
}
private void validateCharset(ToolCallParam param) {
Object value = param == null ? null : param.getInput().get("charset");
if (value != null && (!(value instanceof String charset) || !"UTF-8".equalsIgnoreCase(charset.trim()))) {
throw new AgentRuntimeException("Shell charset override is limited to UTF-8.");
}
}
private CompletableFuture<BoundedOutput> readBounded(InputStream input) {
try {
return CompletableFuture.supplyAsync(() -> {
ByteArrayOutputStream retained = new ByteArrayOutputStream(Math.min(maxOutputSize, 8192));
boolean truncated = false;
byte[] buffer = new byte[8192];
try (input) {
int read;
while ((read = input.read(buffer)) >= 0) {
int remaining = maxOutputSize - retained.size();
if (remaining > 0) {
retained.write(buffer, 0, Math.min(read, remaining));
}
if (read > remaining) {
truncated = true;
}
}
} catch (IOException error) {
throw new WorkspaceToolException("SHELL_OUTPUT_FAILED",
"Shell output stream could not be read.", true, error);
}
return new BoundedOutput(retained.toString(StandardCharsets.UTF_8), truncated);
}, outputExecutor);
} catch (RejectedExecutionException error) {
throw new WorkspaceToolException("SHELL_CONCURRENCY_LIMIT",
"Shell output collector is at capacity.", true, error);
}
}
private BoundedOutput awaitOutput(CompletableFuture<BoundedOutput> future) {
try {
return future.get(2, TimeUnit.SECONDS);
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
throw new WorkspaceToolException("SHELL_INTERRUPTED",
"Shell output collection was interrupted.", true, error);
} catch (ExecutionException | java.util.concurrent.TimeoutException error) {
future.cancel(true);
Throwable cause = error instanceof ExecutionException && error.getCause() != null
? error.getCause() : error;
if (cause instanceof WorkspaceToolException typed) {
throw typed;
}
throw new WorkspaceToolException("SHELL_OUTPUT_FAILED",
"Shell output could not be collected.", true, cause);
}
}
private boolean waitForProcess(Process process,
int timeoutSeconds,
Set<ProcessHandle> observedDescendants) throws InterruptedException {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds);
while (process.isAlive()) {
observedDescendants.addAll(process.toHandle().descendants().toList());
long remainingMillis = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime());
if (remainingMillis <= 0) {
return false;
}
process.waitFor(Math.max(1, Math.min(remainingMillis, 10)), TimeUnit.MILLISECONDS);
}
observedDescendants.addAll(process.toHandle().descendants().toList());
return true;
}
private void terminateProcessTree(Process process,
Set<ProcessHandle> observedDescendants,
long processGroupId) {
processGroupSupport.terminate(processGroupId);
List<ProcessHandle> descendants = new ArrayList<>(observedDescendants);
descendants.addAll(process.toHandle().descendants().toList());
for (int index = descendants.size() - 1; index >= 0; index--) {
descendants.get(index).destroy();
}
process.destroy();
try {
if (!process.waitFor(500, TimeUnit.MILLISECONDS)) {
for (int index = descendants.size() - 1; index >= 0; index--) {
ProcessHandle descendant = descendants.get(index);
if (descendant.isAlive()) {
descendant.destroyForcibly();
}
}
process.destroyForcibly();
process.waitFor(500, TimeUnit.MILLISECONDS);
}
} catch (InterruptedException error) {
for (ProcessHandle descendant : descendants) {
if (descendant.isAlive()) {
descendant.destroyForcibly();
}
}
process.destroyForcibly();
Thread.currentThread().interrupt();
}
}
private void terminateObservedDescendants(Set<ProcessHandle> observedDescendants) {
Set<ProcessHandle> expanded = new LinkedHashSet<>(observedDescendants);
for (ProcessHandle descendant : observedDescendants) {
if (descendant.isAlive()) {
expanded.addAll(descendant.descendants().toList());
}
}
for (ProcessHandle descendant : expanded) {
if (descendant.isAlive()) {
descendant.destroy();
}
}
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(300);
while (expanded.stream().anyMatch(ProcessHandle::isAlive)
&& System.nanoTime() < deadline) {
try {
Thread.sleep(10);
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
break;
}
}
for (ProcessHandle descendant : expanded) {
if (descendant.isAlive()) {
descendant.destroyForcibly();
}
}
}
private static void terminateProcessTreeNow(Process process, Set<ProcessHandle> observedDescendants) {
if (process == null) {
return;
}
List<ProcessHandle> descendants = new ArrayList<>(observedDescendants);
descendants.addAll(process.toHandle().descendants().toList());
for (int index = descendants.size() - 1; index >= 0; index--) {
ProcessHandle descendant = descendants.get(index);
if (descendant.isAlive()) {
descendant.destroyForcibly();
}
}
if (process.isAlive()) {
process.destroyForcibly();
}
}
private ToolResultBlock result(int returnCode,
BoundedOutput stdout,
BoundedOutput stderr,
String errorCode,
String errorMessage,
boolean retryable,
long durationMillis) {
String error = errorCode == null ? "" : "<error><code>" + errorCode + "</code><message>"
+ xml(errorMessage) + "</message><retryable>" + retryable + "</retryable></error>";
String warning = errorCode == null && (stdout.truncated() || stderr.truncated())
? "<warning><code>OUTPUT_TRUNCATED</code><message>Shell output exceeded the configured limit.</message>"
+ "<retryable>false</retryable></warning>" : "";
String formatted = "<returncode>" + returnCode + "</returncode>"
+ "<stdout truncated=\"" + stdout.truncated() + "\">" + xml(sanitizeOutput(stdout.text())) + "</stdout>"
+ "<stderr truncated=\"" + stderr.truncated() + "\">" + xml(sanitizeOutput(stderr.text())) + "</stderr>"
+ "<duration_ms>" + durationMillis + "</duration_ms>" + error + warning;
return ToolResultBlock.text(formatted);
}
private String xml(String value) {
if (value == null) {
return "";
}
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
private String sanitizeOutput(String value) {
if (value == null || value.isEmpty()) {
return "";
}
return value.replace(pathGuard.root().toString(), ".");
}
private static int seconds(Duration duration, String name) {
if (duration == null || duration.isZero() || duration.isNegative() || duration.getSeconds() > Integer.MAX_VALUE) {
throw new AgentRuntimeException(name + " must be a positive whole-second duration.");
}
return Math.toIntExact(duration.getSeconds());
}
private static Set<String> validateAllowedCommands(Set<String> configured) {
if (configured == null || configured.isEmpty()) {
throw new AgentRuntimeException("Shell command whitelist must not be empty.");
}
Set<String> normalized = new LinkedHashSet<>();
for (String command : configured) {
if (command == null || command.isBlank() || !DEFAULT_ALLOWED_COMMANDS.contains(command.trim())) {
throw new AgentRuntimeException("Shell command is outside the fixed whitelist.");
}
normalized.add(command.trim());
}
return Set.copyOf(normalized);
}
private static ExecutorService createOutputExecutor(int maxConcurrency) {
int threads = Math.multiplyExact(maxConcurrency, 2);
ThreadFactory threadFactory = runnable -> {
Thread thread = new Thread(runnable,
"easyagents-shell-output-" + OUTPUT_THREAD_SEQUENCE.incrementAndGet());
thread.setDaemon(true);
return thread;
};
return new ThreadPoolExecutor(
threads,
threads,
0L,
TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(Math.max(threads * 2, 4)),
threadFactory,
new ThreadPoolExecutor.AbortPolicy());
}
/**
* 有界输出。
*
* @param text 保留文本
* @param truncated 是否截断
*/
private record BoundedOutput(String text, boolean truncated) {
}
/**
* 活跃命令及其进程组清理上下文。
*
* @param observedDescendants 执行期观察到的后代
* @param processGroupSupport Linux 进程组支持
* @param processGroupId Linux PGID降级模式为 -1
*/
private record ActiveProcess(Set<ProcessHandle> observedDescendants,
ShellProcessGroupSupport processGroupSupport,
long processGroupId) {
}
}

View File

@@ -0,0 +1,290 @@
package com.easyagents.agent.runtime.tool.operate;
import com.easyagents.agent.runtime.AgentRuntimeException;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.ToolCallParam;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;
import java.util.stream.Stream;
/**
* 与 AgentScope 1.x 文件读取 Schema 兼容的工作区安全工具。
*/
final class SafeReadFileTool {
private final ViewTextFileTool viewTextFileTool;
private final ListDirectoryTool listDirectoryTool;
/**
* 创建文件读取工具组。
*
* @param pathGuard 路径保护器
* @param quotaGuard 配额保护器
*/
SafeReadFileTool(WorkspacePathGuard pathGuard, WorkspaceQuotaGuard quotaGuard) {
this.viewTextFileTool = new ViewTextFileTool(pathGuard, quotaGuard);
this.listDirectoryTool = new ListDirectoryTool(pathGuard, quotaGuard);
}
/**
* 获取查看文本文件工具。
*
* @return AgentScope 工具
*/
AgentTool viewTextFileTool() {
return viewTextFileTool;
}
/**
* 获取列目录工具。
*
* @return AgentScope 工具
*/
AgentTool listDirectoryTool() {
return listDirectoryTool;
}
/**
* 查看工作区文本文件。
*/
private static final class ViewTextFileTool implements AgentTool {
private final WorkspacePathGuard pathGuard;
private final WorkspaceQuotaGuard quotaGuard;
private ViewTextFileTool(WorkspacePathGuard pathGuard, WorkspaceQuotaGuard quotaGuard) {
this.pathGuard = pathGuard;
this.quotaGuard = quotaGuard;
}
/**
* 获取工具名。
*
* @return `view_text_file`
*/
@Override
public String getName() {
return AgentOperateToolAdapter.VIEW_TEXT_FILE_TOOL;
}
/**
* 获取工具描述。
*
* @return 工具描述
*/
@Override
public String getDescription() {
return "View UTF-8 text file content in the workspace with optional line ranges.";
}
/**
* 获取与 AgentScope 1.x 兼容的参数 Schema。
*
* @return JSON Schema
*/
@Override
public Map<String, Object> getParameters() {
Map<String, Object> properties = new LinkedHashMap<>();
properties.put("file_path", Map.of("type", "string", "description", "The target file path"));
properties.put("ranges", Map.of(
"type", "string",
"description", "Optional inclusive line range such as '1,100' or '-100,-1'"));
return Map.of("type", "object", "properties", properties, "required", List.of("file_path"));
}
/**
* 读取并格式化指定行范围。
*
* @param param Tool 调用参数
* @return Tool 结果
*/
@Override
public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
return Mono.fromCallable(() -> view(param)).subscribeOn(Schedulers.boundedElastic());
}
private ToolResultBlock view(ToolCallParam param) {
try {
String filePath = requiredString(param, "file_path");
String ranges = optionalString(param, "ranges");
Path target = pathGuard.resolveExistingFile(filePath);
WorkspaceTextFiles.RangedLines rangedLines = WorkspaceTextFiles.readUtf8Lines(
target, ranges, quotaGuard.maxReadSize());
quotaGuard.validateRangeRead(target, rangedLines.readBytes());
StringBuilder content = new StringBuilder();
for (int index = 0; index < rangedLines.lines().size(); index++) {
content.append(rangedLines.startLine() + index).append(": ")
.append(rangedLines.lines().get(index)).append('\n');
}
int endLine = rangedLines.lines().isEmpty()
? rangedLines.startLine() - 1
: rangedLines.startLine() + rangedLines.lines().size() - 1;
return ToolResultBlock.text("The content of " + pathGuard.display(target)
+ " in lines [" + rangedLines.startLine() + ", " + endLine + "]:\n```\n"
+ content + "```");
} catch (AgentRuntimeException error) {
return WorkspaceToolResults.error(error);
} catch (RuntimeException error) {
return WorkspaceToolResults.error(
new AgentRuntimeException("Unexpected workspace read failure.", error));
}
}
}
/**
* 列出工作区单层目录内容。
*/
private static final class ListDirectoryTool implements AgentTool {
private final WorkspacePathGuard pathGuard;
private final WorkspaceQuotaGuard quotaGuard;
private ListDirectoryTool(WorkspacePathGuard pathGuard, WorkspaceQuotaGuard quotaGuard) {
this.pathGuard = pathGuard;
this.quotaGuard = quotaGuard;
}
/**
* 获取工具名。
*
* @return `list_directory`
*/
@Override
public String getName() {
return AgentOperateToolAdapter.LIST_DIRECTORY_TOOL;
}
/**
* 获取工具描述。
*
* @return 工具描述
*/
@Override
public String getDescription() {
return "List one level of files and directories using workspace-relative paths.";
}
/**
* 获取与 AgentScope 1.x 兼容的参数 Schema。
*
* @return JSON Schema
*/
@Override
public Map<String, Object> getParameters() {
return Map.of(
"type", "object",
"properties", Map.of("dir_path", Map.of(
"type", "string", "description", "The target directory path")),
"required", List.of("dir_path"));
}
/**
* 列出单层目录。
*
* @param param Tool 调用参数
* @return Tool 结果
*/
@Override
public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
return Mono.fromCallable(() -> list(param)).subscribeOn(Schedulers.boundedElastic());
}
private ToolResultBlock list(ToolCallParam param) {
try {
Path directory = pathGuard.resolveExistingDirectory(requiredString(param, "dir_path"));
quotaGuard.validateCurrentUsage();
int limit = quotaGuard.maxDirectoryEntries();
Comparator<Path> displayOrder = Comparator.comparing(pathGuard::display);
PriorityQueue<Path> retained = new PriorityQueue<>(limit, displayOrder.reversed());
long entryCount = 0;
try (Stream<Path> stream = Files.list(directory)) {
for (Path entry : (Iterable<Path>) stream::iterator) {
entryCount++;
if (retained.size() < limit) {
retained.add(entry);
} else if (displayOrder.compare(entry, retained.peek()) < 0) {
retained.poll();
retained.add(entry);
}
}
} catch (IOException error) {
throw new WorkspaceToolException("WORKSPACE_IO_FAILED",
"Workspace directory cannot be listed.", true, error);
}
List<Path> entries = new ArrayList<>(retained);
entries.sort(displayOrder);
StringBuilder result = new StringBuilder("Contents of directory ")
.append(pathGuard.display(directory)).append(":\n");
boolean truncated = entryCount > limit;
for (Path entry : entries) {
String type;
long size = 0;
if (Files.isSymbolicLink(entry)) {
type = "blocked-symlink";
} else if (Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS)) {
type = "directory";
} else if (Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) {
type = "file";
try {
size = Files.size(entry);
} catch (IOException error) {
throw new WorkspaceToolException("WORKSPACE_IO_FAILED",
"Workspace entry size cannot be inspected.", true, error);
}
} else {
type = "blocked-non-regular";
}
result.append(type).append('\t').append(pathGuard.display(entry));
if ("file".equals(type)) {
result.append('\t').append(size).append(" bytes");
}
result.append('\n');
}
if (truncated) {
result.append("Truncated: true; limit=")
.append(limit).append('\n');
}
return ToolResultBlock.text(result.toString());
} catch (AgentRuntimeException error) {
return WorkspaceToolResults.error(error);
} catch (RuntimeException error) {
return WorkspaceToolResults.error(
new AgentRuntimeException("Unexpected workspace listing failure.", error));
}
}
}
private static String requiredString(ToolCallParam param, String name) {
Object value = param == null ? null : param.getInput().get(name);
if (!(value instanceof String text) || text.isBlank()) {
throw new WorkspaceToolException("INVALID_ARGUMENT",
"Missing required string parameter: " + name, false);
}
return text;
}
private static String optionalString(ToolCallParam param, String name) {
Object value = param == null ? null : param.getInput().get(name);
if (value == null) {
return null;
}
if (!(value instanceof String text)) {
throw new WorkspaceToolException("INVALID_ARGUMENT",
"Invalid string parameter: " + name, false);
}
return text;
}
}

View File

@@ -0,0 +1,325 @@
package com.easyagents.agent.runtime.tool.operate;
import com.easyagents.agent.runtime.AgentRuntimeException;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.ToolCallParam;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 与 AgentScope 1.x 文件写入 Schema 兼容的原子工作区工具。
*/
final class SafeWriteFileTool {
private final WriteTextFileTool writeTextFileTool;
private final InsertTextFileTool insertTextFileTool;
/**
* 创建文件写入工具组。
*
* @param pathGuard 路径保护器
* @param quotaGuard 配额保护器
*/
SafeWriteFileTool(WorkspacePathGuard pathGuard, WorkspaceQuotaGuard quotaGuard) {
this.writeTextFileTool = new WriteTextFileTool(pathGuard, quotaGuard);
this.insertTextFileTool = new InsertTextFileTool(pathGuard, quotaGuard);
}
/**
* 获取写入文本文件工具。
*
* @return AgentScope 工具
*/
AgentTool writeTextFileTool() {
return writeTextFileTool;
}
/**
* 获取插入文本文件工具。
*
* @return AgentScope 工具
*/
AgentTool insertTextFileTool() {
return insertTextFileTool;
}
/**
* 新建、覆盖或范围替换文本文件。
*/
private static final class WriteTextFileTool implements AgentTool {
private final WorkspacePathGuard pathGuard;
private final WorkspaceQuotaGuard quotaGuard;
private WriteTextFileTool(WorkspacePathGuard pathGuard, WorkspaceQuotaGuard quotaGuard) {
this.pathGuard = pathGuard;
this.quotaGuard = quotaGuard;
}
/**
* 获取工具名。
*
* @return `write_text_file`
*/
@Override
public String getName() {
return AgentOperateToolAdapter.WRITE_TEXT_FILE_TOOL;
}
/**
* 获取工具描述。
*
* @return 工具描述
*/
@Override
public String getDescription() {
return "Create, overwrite, or replace an inclusive line range in a UTF-8 workspace file.";
}
/**
* 获取与 AgentScope 1.x 兼容的参数 Schema。
*
* @return JSON Schema
*/
@Override
public Map<String, Object> getParameters() {
Map<String, Object> properties = new LinkedHashMap<>();
properties.put("file_path", Map.of("type", "string", "description", "The target file path"));
properties.put("content", Map.of("type", "string", "description", "The content to be written"));
properties.put("ranges", Map.of(
"type", "string",
"description", "Optional inclusive replacement range such as '1,5'"));
return Map.of(
"type", "object",
"properties", properties,
"required", List.of("file_path", "content"));
}
/**
* 原子写入文件。
*
* @param param Tool 调用参数
* @return Tool 结果
*/
@Override
public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
return Mono.fromCallable(() -> write(param)).subscribeOn(Schedulers.boundedElastic());
}
private ToolResultBlock write(ToolCallParam param) {
try {
String filePath = requiredString(param, "file_path");
String content = requiredStringAllowEmpty(param, "content");
String ranges = optionalString(param, "ranges");
Path target = pathGuard.resolveForWrite(filePath);
byte[] bytes;
if (ranges == null || ranges.isBlank() || !Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
bytes = content.getBytes(StandardCharsets.UTF_8);
} else {
quotaGuard.validateFullRead(target);
List<String> lines = splitLines(WorkspaceTextFiles.readUtf8(target));
int[] range = parseReplacementRange(ranges, lines.size());
List<String> updated = new ArrayList<>();
updated.addAll(lines.subList(0, range[0] - 1));
updated.addAll(splitContentLines(content));
updated.addAll(lines.subList(range[1], lines.size()));
bytes = String.join("\n", updated).getBytes(StandardCharsets.UTF_8);
}
quotaGuard.validateWrite(target, bytes.length);
WorkspaceTextFiles.atomicWrite(pathGuard, target, bytes);
return ToolResultBlock.text("Write " + pathGuard.display(target) + " successfully.");
} catch (AgentRuntimeException error) {
return WorkspaceToolResults.error(error);
} catch (RuntimeException error) {
return WorkspaceToolResults.error(
new AgentRuntimeException("Unexpected workspace write failure.", error));
}
}
}
/**
* 在指定 1-based 行号插入文本。
*/
private static final class InsertTextFileTool implements AgentTool {
private final WorkspacePathGuard pathGuard;
private final WorkspaceQuotaGuard quotaGuard;
private InsertTextFileTool(WorkspacePathGuard pathGuard, WorkspaceQuotaGuard quotaGuard) {
this.pathGuard = pathGuard;
this.quotaGuard = quotaGuard;
}
/**
* 获取工具名。
*
* @return `insert_text_file`
*/
@Override
public String getName() {
return AgentOperateToolAdapter.INSERT_TEXT_FILE_TOOL;
}
/**
* 获取工具描述。
*
* @return 工具描述
*/
@Override
public String getDescription() {
return "Insert UTF-8 content at a 1-based line number in an existing workspace file.";
}
/**
* 获取与 AgentScope 1.x 兼容的参数 Schema。
*
* @return JSON Schema
*/
@Override
public Map<String, Object> getParameters() {
Map<String, Object> properties = new LinkedHashMap<>();
properties.put("file_path", Map.of("type", "string", "description", "The target file path"));
properties.put("content", Map.of("type", "string", "description", "The content to be inserted"));
properties.put("line_number", Map.of(
"type", "integer",
"description", "The 1-based line number where content is inserted"));
return Map.of(
"type", "object",
"properties", properties,
"required", List.of("file_path", "content", "line_number"));
}
/**
* 原子插入文件内容。
*
* @param param Tool 调用参数
* @return Tool 结果
*/
@Override
public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
return Mono.fromCallable(() -> insert(param)).subscribeOn(Schedulers.boundedElastic());
}
private ToolResultBlock insert(ToolCallParam param) {
try {
String filePath = requiredString(param, "file_path");
String content = requiredStringAllowEmpty(param, "content");
int lineNumber = requiredInteger(param, "line_number");
Path target = pathGuard.resolveExistingFile(filePath);
quotaGuard.validateFullRead(target);
List<String> lines = splitLines(WorkspaceTextFiles.readUtf8(target));
if (lineNumber < 1 || lineNumber > lines.size() + 1) {
throw new WorkspaceToolException("INVALID_ARGUMENT",
"line_number is outside the valid range [1, "
+ (lines.size() + 1) + "].", false);
}
List<String> updated = new ArrayList<>(lines);
updated.addAll(lineNumber - 1, splitContentLines(content));
byte[] bytes = String.join("\n", updated).getBytes(StandardCharsets.UTF_8);
quotaGuard.validateWrite(target, bytes.length);
WorkspaceTextFiles.atomicWrite(pathGuard, target, bytes);
return ToolResultBlock.text("Insert content into " + pathGuard.display(target)
+ " at line " + lineNumber + " successfully.");
} catch (AgentRuntimeException error) {
return WorkspaceToolResults.error(error);
} catch (RuntimeException error) {
return WorkspaceToolResults.error(
new AgentRuntimeException("Unexpected workspace insert failure.", error));
}
}
}
private static String requiredString(ToolCallParam param, String name) {
String text = requiredStringAllowEmpty(param, name);
if (text.isBlank()) {
throw new WorkspaceToolException("INVALID_ARGUMENT",
"Missing required string parameter: " + name, false);
}
return text;
}
private static String requiredStringAllowEmpty(ToolCallParam param, String name) {
Object value = param == null ? null : param.getInput().get(name);
if (!(value instanceof String text)) {
throw new WorkspaceToolException("INVALID_ARGUMENT",
"Missing required string parameter: " + name, false);
}
return text;
}
private static String optionalString(ToolCallParam param, String name) {
Object value = param == null ? null : param.getInput().get(name);
if (value == null) {
return null;
}
if (!(value instanceof String text)) {
throw new WorkspaceToolException("INVALID_ARGUMENT",
"Invalid string parameter: " + name, false);
}
return text;
}
private static int requiredInteger(ToolCallParam param, String name) {
Object value = param == null ? null : param.getInput().get(name);
if (!(value instanceof Number number)) {
throw new WorkspaceToolException("INVALID_ARGUMENT",
"Missing required integer parameter: " + name, false);
}
return number.intValue();
}
private static int[] parseReplacementRange(String ranges, int lineCount) {
String normalized = ranges.trim().replace("[", "").replace("]", "");
String[] parts = normalized.split(",", -1);
if (parts.length != 2) {
throw new WorkspaceToolException("INVALID_ARGUMENT",
"Invalid range format. Expected 'start,end'.", false);
}
try {
int start = Integer.parseInt(parts[0].trim());
int end = Integer.parseInt(parts[1].trim());
if (start < 1 || end < start || start > lineCount || end > lineCount) {
throw new WorkspaceToolException("INVALID_ARGUMENT",
"Replacement range is outside the file.", false);
}
return new int[]{start, end};
} catch (NumberFormatException error) {
throw new WorkspaceToolException("INVALID_ARGUMENT",
"Invalid range format. Expected integer line numbers.", false, error);
}
}
private static List<String> splitLines(String content) {
if (content.isEmpty()) {
return new ArrayList<>();
}
String normalized = content.replace("\r\n", "\n").replace('\r', '\n');
String[] values = normalized.split("\n", -1);
int length = values.length;
if (length > 0 && values[length - 1].isEmpty()) {
length--;
}
List<String> lines = new ArrayList<>(length);
for (int index = 0; index < length; index++) {
lines.add(values[index]);
}
return lines;
}
private static List<String> splitContentLines(String content) {
if (content.isEmpty()) {
return List.of("");
}
return List.of(content.replace("\r\n", "\n").replace('\r', '\n').split("\n", -1));
}
}

View File

@@ -0,0 +1,601 @@
package com.easyagents.agent.runtime.tool.operate;
import com.easyagents.agent.runtime.AgentRuntimeException;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 白名单命令的命令级选项与路径参数校验器。
*
* <p>入口命令白名单不足以阻止工具通过合法命令的扩展选项启动子进程或访问第二路径。
* 该校验器集中关闭这些二级执行入口,并对已知文件参数执行工作区路径保护。
*/
final class ShellCommandOptionValidator {
private static final Pattern AWK_CODE_EXECUTION = Pattern.compile(
"(?is).*(\\bsystem\\s*\\(|\\bgetline\\b|\\bENVIRON\\b|@load\\b|\\bextension\\s*\\().*");
private static final Pattern SED_SIDE_EFFECT_COMMAND = Pattern.compile(
"(?is).*(^|[;{}\\n])\\s*(?:(?:\\d+|\\$|/([^/\\n\\\\]|\\\\.)*/)(?:\\s*,\\s*"
+ "(?:\\d+|\\$|/([^/\\n\\\\]|\\\\.)*/))?\\s*)?[eErRwW](?:\\s|$).*");
private static final Pattern JQ_EXTERNAL_INPUT = Pattern.compile(
"(?is).*(\\b(import|include|module|input|inputs|env)\\b|\\$ENV\\b).*");
private final WorkspacePathGuard pathGuard;
/**
* 创建命令选项校验器。
*
* @param pathGuard 工作区路径保护器
*/
ShellCommandOptionValidator(WorkspacePathGuard pathGuard) {
this.pathGuard = pathGuard;
}
/**
* 校验命令专属的子执行入口、文件选项和路径操作数。
*
* @param arguments 已完成安全分词的命令参数
*/
void validate(List<String> arguments) {
String command = arguments.get(0);
switch (command) {
case "ls" -> validateList(arguments);
case "awk" -> validateAwk(arguments);
case "sed" -> validateSed(arguments);
case "rg" -> validateRipgrep(arguments);
case "grep" -> validateGrep(arguments);
case "jq" -> validateJq(arguments);
case "sort" -> validateSort(arguments);
case "uniq" -> validateUniq(arguments);
case "diff", "cmp" -> validateExistingOperands(arguments);
case "du" -> validateDiskUsage(arguments);
case "tree" -> validateTree(arguments);
case "cp" -> validateCopy(arguments);
case "mkdir", "touch", "mv", "rm" -> validateAllOperands(arguments);
case "wc" -> validateWordCount(arguments);
case "file" -> validateFile(arguments);
case "sha256sum", "shasum" -> validateChecksum(arguments);
case "tail" -> validateTail(arguments);
case "pandoc" -> validatePandoc(arguments);
case "soffice" -> validateSoffice(arguments);
case "pdftoppm" -> validatePdfToPpm(arguments);
case "pdfinfo" -> validatePdfInfo(arguments);
case "pdftotext" -> validatePdfToText(arguments);
case "pdfimages" -> validatePdfImages(arguments);
case "qpdf" -> validateQpdf(arguments);
case "cat", "head", "cut", "stat" ->
validateExistingOperands(arguments);
default -> {
// pwd/date/tr/basename/dirname/python/python3/node 没有额外的子执行选项;脚本入口由外层单独校验。
}
}
}
private void validateDiskUsage(List<String> arguments) {
rejectOptions(arguments, Set.of(
"--files0-from", "--exclude-from", "-L", "--dereference", "-H", "-D",
"--dereference-args"));
validateExistingOperands(arguments);
}
private void validateTree(List<String> arguments) {
rejectOptions(arguments, Set.of(
"-l", "--follow-links", "-o", "--fromfile", "--gitfile", "--info"));
validateExistingOperands(arguments);
}
private void validatePandoc(List<String> arguments) {
rejectOptions(arguments, Set.of(
"-F", "--filter", "-L", "--lua-filter", "-d", "--defaults", "--data-dir",
"--resource-path", "--extract-media", "--pdf-engine", "--pdf-engine-opt"));
for (String argument : arguments.subList(1, arguments.size())) {
if (isAttachedShortOption(argument, "-o")) {
throw new AgentRuntimeException(
"pandoc attached output paths are not allowed; use -o followed by a workspace path.");
}
}
validateFollowingFileOptions(arguments, Set.of(
"--template", "--metadata-file", "--reference-doc", "--syntax-definition",
"--include-in-header", "--include-before-body", "--include-after-body",
"--bibliography", "--csl", "--citation-abbreviations"), false);
validateFollowingFileOptions(arguments, Set.of("-o", "--output", "--log"), true);
validateExistingOperands(arguments);
}
private void validateSoffice(List<String> arguments) {
String format = null;
String outputDirectory = null;
List<String> inputs = new ArrayList<>();
for (int index = 1; index < arguments.size(); index++) {
String argument = arguments.get(index);
String option = optionName(argument);
if (Set.of("--headless", "--nologo", "--nodefault", "--nolockcheck", "--norestore")
.contains(option)) {
continue;
}
if ("--convert-to".equals(option)) {
format = optionValue(arguments, index);
if (!argument.contains("=")) {
index++;
}
continue;
}
if ("--outdir".equals(option)) {
outputDirectory = optionValue(arguments, index);
if (!argument.contains("=")) {
index++;
}
continue;
}
if (argument.startsWith("-")) {
throw new AgentRuntimeException("soffice option is not allowed: " + option);
}
inputs.add(argument);
}
if (format == null || outputDirectory == null || inputs.isEmpty()) {
throw new AgentRuntimeException(
"soffice requires --convert-to, --outdir, and at least one workspace input file.");
}
String normalizedFormat = format.split(":", 2)[0].toLowerCase(java.util.Locale.ROOT);
if (!Set.of("pdf", "docx", "xlsx", "pptx", "odt", "ods", "odp", "html", "txt", "csv")
.contains(normalizedFormat)) {
throw new AgentRuntimeException("soffice output format is not allowed: " + normalizedFormat);
}
pathGuard.resolveExistingDirectory(outputDirectory);
inputs.forEach(pathGuard::resolveExistingFile);
}
private void validatePdfToPpm(List<String> arguments) {
List<String> operands = pdfOperands(arguments, Set.of(
"-f", "-l", "-r", "-rx", "-ry", "-scale-to", "-scale-to-x", "-scale-to-y",
"-x", "-y", "-W", "-H", "-sz"));
if (operands.size() != 2) {
throw new AgentRuntimeException("pdftoppm requires one PDF input and one output prefix.");
}
pathGuard.resolveExistingFile(operands.get(0));
pathGuard.resolveCommandPath(operands.get(1));
}
private void validatePdfInfo(List<String> arguments) {
rejectOptions(arguments, Set.of("-opw", "-upw"));
List<String> operands = pdfOperands(arguments, Set.of("-f", "-l"));
if (operands.size() != 1) {
throw new AgentRuntimeException("pdfinfo requires exactly one workspace PDF input.");
}
pathGuard.resolveExistingFile(operands.get(0));
}
private void validatePdfToText(List<String> arguments) {
rejectOptions(arguments, Set.of("-opw", "-upw"));
List<String> operands = pdfOperands(arguments, Set.of(
"-f", "-l", "-r", "-x", "-y", "-W", "-H", "-enc", "-eol"));
if (operands.size() < 1 || operands.size() > 2) {
throw new AgentRuntimeException("pdftotext requires one PDF input and an optional output file.");
}
pathGuard.resolveExistingFile(operands.get(0));
if (operands.size() == 2 && !"-".equals(operands.get(1))) {
pathGuard.resolveCommandPath(operands.get(1));
}
}
private void validatePdfImages(List<String> arguments) {
rejectOptions(arguments, Set.of("-opw", "-upw"));
List<String> operands = pdfOperands(arguments, Set.of("-f", "-l", "-jpegopt"));
if (operands.size() != 2) {
throw new AgentRuntimeException("pdfimages requires one PDF input and one output prefix.");
}
pathGuard.resolveExistingFile(operands.get(0));
pathGuard.resolveCommandPath(operands.get(1));
}
private void validateQpdf(List<String> arguments) {
rejectOptions(arguments, Set.of(
"--replace-input", "--password-file", "--encryption-file-password",
"--copy-attachments-from", "--overlay", "--underlay", "--json-input",
"--job-json-file"));
for (String argument : arguments.subList(1, arguments.size())) {
if (argument.startsWith("@")) {
throw new AgentRuntimeException("qpdf response files are not allowed.");
}
}
validateExistingOperands(arguments);
}
private List<String> pdfOperands(List<String> arguments, Set<String> optionsWithValues) {
List<String> result = new ArrayList<>();
boolean endOfOptions = false;
for (int index = 1; index < arguments.size(); index++) {
String argument = arguments.get(index);
if (!endOfOptions && "--".equals(argument)) {
endOfOptions = true;
continue;
}
if (!endOfOptions && argument.startsWith("-")) {
String option = optionName(argument);
if (optionsWithValues.contains(option) && !argument.contains("=")) {
if (++index >= arguments.size()) {
throw new AgentRuntimeException("PDF command option requires a value: " + option);
}
}
continue;
}
result.add(argument);
}
return result;
}
private void validateFollowingFileOptions(List<String> arguments,
Set<String> fileOptions,
boolean writable) {
for (int index = 1; index < arguments.size(); index++) {
String argument = arguments.get(index);
String option = optionName(argument);
if (!fileOptions.contains(option)) {
continue;
}
String path = optionValue(arguments, index);
if (writable) {
pathGuard.resolveCommandPath(path);
} else {
pathGuard.resolveExistingFile(path);
}
if (!argument.contains("=")) {
index++;
}
}
}
private void validateAwk(List<String> arguments) {
rejectOptions(arguments, Set.of(
"-f", "--file", "-e", "--exec", "-i", "--include", "-l", "--load", "-W",
"-d", "--dump-variables", "-o", "--pretty-print", "-p", "--profile"));
for (String argument : operands(arguments)) {
if (AWK_CODE_EXECUTION.matcher(argument).matches()) {
throw new AgentRuntimeException("awk sub-process and external input features are not allowed.");
}
}
validateExistingOperandsSkippingFirst(arguments);
}
private void validateSed(List<String> arguments) {
List<String> expressions = new ArrayList<>();
List<String> files = new ArrayList<>();
boolean endOfOptions = false;
for (int index = 1; index < arguments.size(); index++) {
String argument = arguments.get(index);
if (!endOfOptions && "--".equals(argument)) {
endOfOptions = true;
continue;
}
if (!endOfOptions && (argument.equals("-i") || argument.startsWith("-i")
|| argument.startsWith("--in-place") || argument.equals("--follow-symlinks")
|| argument.startsWith("-f") || argument.startsWith("--file"))) {
throw new AgentRuntimeException("sed in-place, external script, and symlink-following options are not allowed.");
}
if (!endOfOptions && ("-e".equals(argument) || "--expression".equals(argument))) {
if (++index >= arguments.size()) {
throw new AgentRuntimeException("sed expression option requires a value.");
}
expressions.add(arguments.get(index));
continue;
}
if (!endOfOptions && argument.startsWith("--expression=")) {
expressions.add(argument.substring("--expression=".length()));
continue;
}
if (!endOfOptions && argument.startsWith("-") && !isSafeSedFlag(argument)) {
throw new AgentRuntimeException("sed option is not allowed.");
}
if (!endOfOptions && argument.startsWith("-")) {
continue;
}
if (expressions.isEmpty()) {
expressions.add(argument);
} else {
files.add(argument);
}
}
if (expressions.isEmpty()) {
throw new AgentRuntimeException("sed requires an inline expression.");
}
for (String expression : expressions) {
if (SED_SIDE_EFFECT_COMMAND.matcher(expression).matches()
|| containsUnsafeSubstitutionFlag(expression)) {
throw new AgentRuntimeException("sed execute/read/write commands are not allowed.");
}
}
for (String file : files) {
validateExistingPath(file);
}
}
private void validateRipgrep(List<String> arguments) {
for (int index = 1; index < arguments.size(); index++) {
String argument = arguments.get(index);
String option = optionName(argument);
if (Set.of("--pre", "--pre-glob", "--hostname-bin", "--search-zip").contains(option)
|| isShortOptionPresent(argument, 'z') || "--follow".equals(option)
|| isShortOptionPresent(argument, 'L')) {
throw new AgentRuntimeException(
"rg preprocessors, archive search, and symlink-following options are not allowed.");
}
if (Set.of("-f", "--file", "--ignore-file").contains(option)
|| isAttachedShortOption(argument, "-f")) {
String path = attachedOrFollowingValue(arguments, index, "-f");
validateExistingPath(path);
if (!argument.contains("=") && !isAttachedShortOption(argument, "-f")) {
index++;
}
}
}
validateExistingOperands(arguments);
}
private void validateGrep(List<String> arguments) {
for (int index = 1; index < arguments.size(); index++) {
String argument = arguments.get(index);
String option = optionName(argument);
if (isShortOptionPresent(argument, 'R') || "--dereference-recursive".equals(option)) {
throw new AgentRuntimeException("grep symlink-following recursion is not allowed.");
}
if (Set.of("-f", "--file", "--exclude-from").contains(option)
|| isAttachedShortOption(argument, "-f")) {
String path = attachedOrFollowingValue(arguments, index, "-f");
validateExistingPath(path);
if (!argument.contains("=") && !isAttachedShortOption(argument, "-f")) {
index++;
}
}
}
validateExistingOperands(arguments);
}
private void validateJq(List<String> arguments) {
rejectOptions(arguments, Set.of("-f", "--from-file", "-L", "--library-path", "--run-tests"));
for (String operand : operands(arguments)) {
if (JQ_EXTERNAL_INPUT.matcher(operand).matches()) {
throw new AgentRuntimeException("jq module, environment, and external input functions are not allowed.");
}
}
for (int index = 1; index < arguments.size(); index++) {
String option = optionName(arguments.get(index));
if (Set.of("--argfile", "--slurpfile", "--rawfile").contains(option)) {
if (index + 2 >= arguments.size()) {
throw new AgentRuntimeException("jq file option requires a variable name and workspace file.");
}
validateExistingPath(arguments.get(index + 2));
index += 2;
}
}
validateExistingOperandsSkippingFirst(arguments);
}
private void validateSort(List<String> arguments) {
rejectOptions(arguments, Set.of("-o", "--output", "--compress-program", "-T", "--temporary-directory"));
for (int index = 1; index < arguments.size(); index++) {
String option = optionName(arguments.get(index));
if ("--random-source".equals(option)) {
String path = optionValue(arguments, index);
validateExistingPath(path);
if (!arguments.get(index).contains("=")) {
index++;
}
}
}
validateExistingOperands(arguments);
}
private void validateUniq(List<String> arguments) {
List<String> operands = operands(arguments);
if (operands.size() > 1) {
throw new AgentRuntimeException("uniq output-file operand is not allowed; use write_text_file instead.");
}
if (!operands.isEmpty()) {
validateExistingPath(operands.get(0));
}
}
private void validateCopy(List<String> arguments) {
for (String argument : arguments) {
if (isShortOptionPresent(argument, 'L') || isShortOptionPresent(argument, 'H')
|| isShortOptionPresent(argument, 'l') || isShortOptionPresent(argument, 's')
|| Set.of("--dereference", "--link", "--symbolic-link")
.contains(optionName(argument))) {
throw new AgentRuntimeException("cp link creation and symlink-following options are not allowed.");
}
}
validateAllOperands(arguments);
}
private void validateTail(List<String> arguments) {
for (String argument : arguments.subList(1, arguments.size())) {
String option = optionName(argument);
if (isShortOptionPresent(argument, 'f') || isShortOptionPresent(argument, 'F')
|| "--follow".equals(option)) {
throw new AgentRuntimeException("tail follow mode is not allowed.");
}
}
validateExistingOperands(arguments);
}
private void validateList(List<String> arguments) {
for (String argument : arguments.subList(1, arguments.size())) {
String option = optionName(argument);
if (isShortOptionPresent(argument, 'L') || "--dereference".equals(option)
|| "--dereference-command-line".equals(option)
|| "--dereference-command-line-symlink-to-dir".equals(option)) {
throw new AgentRuntimeException("ls symlink-following options are not allowed.");
}
}
validateExistingOperands(arguments);
}
private void validateWordCount(List<String> arguments) {
rejectOptions(arguments, Set.of("--files0-from"));
validateExistingOperands(arguments);
}
private void validateFile(List<String> arguments) {
rejectOptions(arguments, Set.of("-f", "--files-from", "-C", "--compile"));
validateExistingOperands(arguments);
}
private void validateChecksum(List<String> arguments) {
rejectOptions(arguments, Set.of("-c", "--check"));
validateExistingOperands(arguments);
}
private void validateAllOperands(List<String> arguments) {
for (String operand : operands(arguments)) {
pathGuard.resolveCommandPath(operand);
}
}
private void validateExistingOperands(List<String> arguments) {
for (String operand : operands(arguments)) {
validateExistingPathIfPresent(operand);
}
}
private void validateExistingOperandsSkippingFirst(List<String> arguments) {
List<String> operands = operands(arguments);
for (int index = 1; index < operands.size(); index++) {
validateExistingPathIfPresent(operands.get(index));
}
}
private void validateExistingPathIfPresent(String value) {
java.nio.file.Path candidate = pathGuard.root().resolve(value).normalize();
if (java.nio.file.Files.exists(candidate, java.nio.file.LinkOption.NOFOLLOW_LINKS)) {
pathGuard.resolveExistingEntry(value);
}
}
private void validateExistingPath(String value) {
pathGuard.resolveExistingFile(value);
}
private void rejectOptions(List<String> arguments, Set<String> rejected) {
for (String argument : arguments.subList(1, arguments.size())) {
String option = optionName(argument);
if (rejected.contains(option) || rejected.stream()
.filter(value -> value.startsWith("-") && !value.startsWith("--") && value.length() == 2)
.anyMatch(value -> isAttachedShortOption(argument, value))) {
throw new AgentRuntimeException("Command option is not allowed: " + option);
}
}
}
private List<String> operands(List<String> arguments) {
List<String> operands = new ArrayList<>();
boolean endOfOptions = false;
for (int index = 1; index < arguments.size(); index++) {
String argument = arguments.get(index);
if (!endOfOptions && "--".equals(argument)) {
endOfOptions = true;
continue;
}
if (!endOfOptions && argument.startsWith("-")) {
continue;
}
operands.add(argument);
}
return operands;
}
private String optionName(String argument) {
int equals = argument.indexOf('=');
return equals < 0 ? argument : argument.substring(0, equals);
}
private String optionValue(List<String> arguments, int optionIndex) {
String argument = arguments.get(optionIndex);
int equals = argument.indexOf('=');
if (equals >= 0) {
String value = argument.substring(equals + 1);
if (value.isBlank()) {
throw new AgentRuntimeException("Command file option requires a value.");
}
return value;
}
if (optionIndex + 1 >= arguments.size()) {
throw new AgentRuntimeException("Command file option requires a value.");
}
return arguments.get(optionIndex + 1);
}
private String attachedOrFollowingValue(List<String> arguments, int optionIndex, String shortOption) {
String argument = arguments.get(optionIndex);
if (isAttachedShortOption(argument, shortOption)) {
return argument.substring(shortOption.length());
}
return optionValue(arguments, optionIndex);
}
private boolean isAttachedShortOption(String argument, String option) {
return argument.startsWith(option) && argument.length() > option.length()
&& !argument.startsWith("--");
}
private boolean isShortOptionPresent(String argument, char option) {
return argument.startsWith("-") && !argument.startsWith("--")
&& argument.length() > 1 && argument.substring(1).indexOf(option) >= 0;
}
private boolean containsUnsafeSubstitutionFlag(String expression) {
for (int index = 0; index + 1 < expression.length(); index++) {
if (expression.charAt(index) != 's' || Character.isLetterOrDigit(expression.charAt(index + 1))) {
continue;
}
char delimiter = expression.charAt(index + 1);
int patternEnd = findUnescaped(expression, delimiter, index + 2);
if (patternEnd < 0) {
continue;
}
int replacementEnd = findUnescaped(expression, delimiter, patternEnd + 1);
if (replacementEnd < 0) {
continue;
}
for (int flagIndex = replacementEnd + 1; flagIndex < expression.length(); flagIndex++) {
char flag = expression.charAt(flagIndex);
if (flag == ';' || flag == '\n' || flag == '}') {
break;
}
if (flag == 'e' || flag == 'w' || flag == 'W') {
return true;
}
if (!Character.isWhitespace(flag) && !Character.isDigit(flag)
&& "gIpMm".indexOf(flag) < 0) {
break;
}
}
}
return false;
}
private int findUnescaped(String value, char delimiter, int start) {
boolean escaped = false;
for (int index = start; index < value.length(); index++) {
char current = value.charAt(index);
if (escaped) {
escaped = false;
} else if (current == '\\') {
escaped = true;
} else if (current == delimiter) {
return index;
}
}
return -1;
}
private boolean isSafeSedFlag(String argument) {
if (Set.of("-n", "--quiet", "--silent", "-E", "-r", "--regexp-extended", "--sandbox")
.contains(argument)) {
return true;
}
return argument.matches("-[nEr]+");
}
}

View File

@@ -0,0 +1,150 @@
package com.easyagents.agent.runtime.tool.operate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
/**
* Linux Shell 独立会话与进程组清理支持。
*
* <p>Linux 使用受信任的 util-linux {@code setsid} 创建独立会话,并通过系统 {@code kill}
* 向负 PGID 发送信号。JDK 17 没有可移植的 killpg API非 Linux 平台保留 ProcessHandle
* 后代跟踪降级;脚本显式创建第二个会话仍属于无 OS 沙箱时无法消除的边界。
*/
final class ShellProcessGroupSupport {
private static final Logger logger = LoggerFactory.getLogger(ShellProcessGroupSupport.class);
private static final List<Path> SETSID_CANDIDATES = List.of(
Path.of("/usr/bin/setsid"), Path.of("/bin/setsid"));
private static final List<Path> KILL_CANDIDATES = List.of(
Path.of("/bin/kill"), Path.of("/usr/bin/kill"));
private final Path setsid;
private final Path kill;
private ShellProcessGroupSupport(Path setsid, Path kill) {
this.setsid = setsid;
this.kill = kill;
}
/**
* 检测当前平台的进程组能力。
*
* @return Linux 进程组支持或可移植降级实例
*/
static ShellProcessGroupSupport detect() {
String osName = System.getProperty("os.name", "");
if (!isLinux(osName)) {
return new ShellProcessGroupSupport(null, null);
}
return detect(osName, firstExecutable(SETSID_CANDIDATES), firstExecutable(KILL_CANDIDATES));
}
/**
* 使用显式路径检测平台能力,供启动校验测试使用。
*
* @param osName 操作系统名称
* @param setsidPath setsid 路径,可空
* @param killPath kill 路径,可空
* @return 检测结果
*/
static ShellProcessGroupSupport detect(String osName, Path setsidPath, Path killPath) {
if (!isLinux(osName)) {
return new ShellProcessGroupSupport(null, null);
}
if (!isTrustedExecutable(setsidPath) || !isTrustedExecutable(killPath)) {
throw new WorkspaceToolException("WORKSPACE_CONFIG_INVALID",
"Linux controlled shell requires executable setsid and kill utilities.", false);
}
return new ShellProcessGroupSupport(setsidPath.toAbsolutePath().normalize(),
killPath.toAbsolutePath().normalize());
}
/**
* 返回是否启用 Linux 独立进程组。
*
* @return 启用时为 true
*/
boolean enabled() {
return setsid != null && kill != null;
}
/**
* 为 Linux 命令增加受信任 setsid 前缀。
*
* @param command 已校验命令参数
* @return 实际 ProcessBuilder 参数
*/
List<String> wrap(List<String> command) {
if (!enabled()) {
return command;
}
List<String> wrapped = new ArrayList<>(command.size() + 1);
wrapped.add(setsid.toString());
wrapped.addAll(command);
return wrapped;
}
/**
* 对独立进程组发送 TERM随后发送 KILL 清理残留成员。
*
* @param processGroupId setsid 进程 PID同时也是 PGID
*/
void terminate(long processGroupId) {
if (!enabled() || processGroupId <= 1) {
return;
}
if (!signal("-TERM", processGroupId)) {
return;
}
try {
Thread.sleep(100);
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
}
signal("-KILL", processGroupId);
}
private boolean signal(String signal, long processGroupId) {
try {
Process process = new ProcessBuilder(
kill.toString(), signal, "--", "-" + processGroupId)
.redirectInput(ProcessBuilder.Redirect.from(Path.of("/dev/null").toFile()))
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
.redirectError(ProcessBuilder.Redirect.DISCARD)
.start();
if (!process.waitFor(500, TimeUnit.MILLISECONDS)) {
process.destroyForcibly();
return false;
}
return process.exitValue() == 0;
} catch (IOException error) {
logger.error("Failed to signal controlled shell process group", error);
return false;
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
logger.warn("Interrupted while signaling controlled shell process group", error);
return false;
}
}
private static boolean isLinux(String osName) {
return osName != null && osName.toLowerCase(Locale.ROOT).contains("linux");
}
private static Path firstExecutable(List<Path> candidates) {
return candidates.stream().filter(ShellProcessGroupSupport::isTrustedExecutable)
.findFirst().orElse(null);
}
private static boolean isTrustedExecutable(Path path) {
return path != null && path.isAbsolute() && Files.isRegularFile(path) && Files.isExecutable(path);
}
}

View File

@@ -0,0 +1,287 @@
package com.easyagents.agent.runtime.tool.operate;
import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.tool.operate.ApplyPatchTool.DiffLine;
import com.easyagents.agent.runtime.tool.operate.ApplyPatchTool.FilePatch;
import com.easyagents.agent.runtime.tool.operate.ApplyPatchTool.Hunk;
import com.easyagents.agent.runtime.tool.operate.ApplyPatchTool.PatchType;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* `*** Begin Patch` 和标准 unified diff 解析器。
*/
final class UnifiedPatchParser {
private static final Pattern HUNK_HEADER = Pattern.compile(
"^@@(?:\\s+-(\\d+)(?:,\\d+)?\\s+\\+\\d+(?:,\\d+)?\\s+@@.*)?$");
private UnifiedPatchParser() {
}
/**
* 解析补丁文本。
*
* @param patch 补丁文本
* @return 有序文件补丁
*/
static List<FilePatch> parse(String patch) {
String normalized = patch.replace("\r\n", "\n").replace('\r', '\n');
List<String> lines = List.of(normalized.split("\n", -1));
if (!lines.isEmpty() && "*** Begin Patch".equals(lines.get(0))) {
return parseEnvelope(lines);
}
return parseUnified(lines);
}
/**
* 将单文件补丁应用到当前文本。
*
* @param patch 单文件补丁
* @param current 当前 UTF-8 文本
* @return 修改后文本
*/
static String apply(FilePatch patch, String current) {
boolean trailingNewline = patch.type() == PatchType.ADD || current.endsWith("\n") || current.endsWith("\r");
List<String> content = splitDocument(current);
if (patch.type() == PatchType.DELETE && patch.hunks().isEmpty()) {
return "";
}
for (Hunk hunk : patch.hunks()) {
List<String> oldLines = hunk.lines().stream()
.filter(line -> line.kind() != '+')
.map(DiffLine::text)
.toList();
List<String> newLines = hunk.lines().stream()
.filter(line -> line.kind() != '-')
.map(DiffLine::text)
.toList();
int position = locateUnique(content, oldLines, hunk.oldStart());
for (int index = 0; index < oldLines.size(); index++) {
if (!content.get(position + index).equals(oldLines.get(index))) {
throw new WorkspaceToolException("PATCH_CONFLICT",
"Patch hunk context does not match the target file.", false);
}
}
content.subList(position, position + oldLines.size()).clear();
content.addAll(position, newLines);
}
String result = String.join("\n", content);
if (patch.type() == PatchType.DELETE && !result.isEmpty()) {
throw new WorkspaceToolException("PATCH_CONFLICT",
"Delete patch does not match the complete target file.", false);
}
return trailingNewline && !content.isEmpty() ? result + "\n" : result;
}
private static List<FilePatch> parseEnvelope(List<String> lines) {
List<FilePatch> patches = new ArrayList<>();
Set<String> targets = new LinkedHashSet<>();
int index = 1;
while (index < lines.size()) {
String line = lines.get(index);
if ("*** End Patch".equals(line)) {
return patches;
}
PatchType type;
String path;
if (line.startsWith("*** Add File: ")) {
type = PatchType.ADD;
path = line.substring("*** Add File: ".length()).trim();
} else if (line.startsWith("*** Update File: ")) {
type = PatchType.UPDATE;
path = line.substring("*** Update File: ".length()).trim();
} else if (line.startsWith("*** Delete File: ")) {
type = PatchType.DELETE;
path = line.substring("*** Delete File: ".length()).trim();
} else if (line.isEmpty()) {
index++;
continue;
} else {
throw patchInvalid("Invalid patch section header.");
}
if (path.isBlank() || !targets.add(path)) {
throw patchInvalid("Patch target is empty or duplicated.");
}
index++;
List<String> body = new ArrayList<>();
while (index < lines.size() && !lines.get(index).startsWith("*** ")) {
body.add(lines.get(index++));
}
if (!body.isEmpty() && body.get(body.size() - 1).isEmpty()) {
body.remove(body.size() - 1);
}
patches.add(buildFilePatch(type, path, body));
}
throw patchInvalid("Patch is missing *** End Patch.");
}
private static List<FilePatch> parseUnified(List<String> lines) {
List<FilePatch> patches = new ArrayList<>();
Set<String> targets = new LinkedHashSet<>();
int index = 0;
while (index < lines.size()) {
if (!lines.get(index).startsWith("--- ")) {
if (lines.get(index).isEmpty()) {
index++;
continue;
}
throw patchInvalid("Invalid unified diff: expected '---' header.");
}
String oldPath = headerPath(lines.get(index++).substring(4));
if (index >= lines.size() || !lines.get(index).startsWith("+++ ")) {
throw patchInvalid("Invalid unified diff: expected '+++' header.");
}
String newPath = headerPath(lines.get(index++).substring(4));
PatchType type = "/dev/null".equals(oldPath) ? PatchType.ADD
: "/dev/null".equals(newPath) ? PatchType.DELETE : PatchType.UPDATE;
String path = type == PatchType.DELETE ? stripPrefix(oldPath) : stripPrefix(newPath);
if (path.isBlank() || !targets.add(path)) {
throw patchInvalid("Patch target is empty or duplicated.");
}
List<String> body = new ArrayList<>();
while (index < lines.size() && !lines.get(index).startsWith("--- ")) {
body.add(lines.get(index++));
}
if (!body.isEmpty() && body.get(body.size() - 1).isEmpty()) {
body.remove(body.size() - 1);
}
patches.add(buildFilePatch(type, path, body));
}
return patches;
}
private static FilePatch buildFilePatch(PatchType type, String path, List<String> body) {
if (type == PatchType.DELETE && body.isEmpty()) {
return new FilePatch(type, path, List.of(), 0, 0);
}
if (type == PatchType.ADD && body.stream().noneMatch(line -> line.startsWith("@@"))) {
List<DiffLine> lines = new ArrayList<>();
for (String line : body) {
if (!line.startsWith("+")) {
throw patchInvalid("Added file lines must start with '+'.");
}
lines.add(new DiffLine('+', line.substring(1)));
}
return new FilePatch(type, path, List.of(new Hunk(1, lines)), lines.size(), 0);
}
List<Hunk> hunks = new ArrayList<>();
List<DiffLine> current = null;
Integer oldStart = null;
int added = 0;
int deleted = 0;
for (String line : body) {
Matcher header = HUNK_HEADER.matcher(line);
if (header.matches()) {
if (current != null) {
hunks.add(new Hunk(oldStart, List.copyOf(current)));
}
current = new ArrayList<>();
oldStart = header.group(1) == null ? null : Integer.parseInt(header.group(1));
continue;
}
if ("\\ No newline at end of file".equals(line)) {
continue;
}
if (current == null) {
throw patchInvalid("Patch hunk is missing an @@ header.");
}
if (line.isEmpty() || (line.charAt(0) != ' ' && line.charAt(0) != '+' && line.charAt(0) != '-')) {
throw patchInvalid("Invalid patch hunk line.");
}
char kind = line.charAt(0);
current.add(new DiffLine(kind, line.substring(1)));
if (kind == '+') {
added++;
} else if (kind == '-') {
deleted++;
}
}
if (current != null) {
hunks.add(new Hunk(oldStart, List.copyOf(current)));
}
if (hunks.isEmpty() && type != PatchType.DELETE) {
throw patchInvalid("Patch file section does not contain a hunk.");
}
return new FilePatch(type, path, List.copyOf(hunks), added, deleted);
}
private static int locateUnique(List<String> content, List<String> oldLines, Integer declaredStart) {
if (oldLines.isEmpty()) {
if (declaredStart == null) {
if (content.isEmpty()) {
return 0;
}
throw new WorkspaceToolException("PATCH_CONFLICT",
"Insertion hunk needs a line position or context.", false);
}
int position = Math.max(0, declaredStart - 1);
if (position > content.size()) {
throw new WorkspaceToolException("PATCH_CONFLICT",
"Insertion position is outside the target file.", false);
}
return position;
}
int match = -1;
for (int start = 0; start + oldLines.size() <= content.size(); start++) {
boolean equal = true;
for (int offset = 0; offset < oldLines.size(); offset++) {
if (!content.get(start + offset).equals(oldLines.get(offset))) {
equal = false;
break;
}
}
if (equal) {
if (match >= 0) {
throw new WorkspaceToolException("PATCH_CONFLICT",
"Patch hunk context is not unique.", false);
}
match = start;
}
}
if (match < 0) {
throw new WorkspaceToolException("PATCH_CONFLICT",
"Patch hunk context was not found.", false);
}
return match;
}
private static List<String> splitDocument(String content) {
if (content.isEmpty()) {
return new ArrayList<>();
}
String normalized = content.replace("\r\n", "\n").replace('\r', '\n');
String[] values = normalized.split("\n", -1);
int length = values.length;
if (length > 0 && values[length - 1].isEmpty()) {
length--;
}
List<String> lines = new ArrayList<>(length);
for (int index = 0; index < length; index++) {
lines.add(values[index]);
}
return lines;
}
private static String headerPath(String header) {
String trimmed = header.trim();
int tab = trimmed.indexOf('\t');
return tab < 0 ? trimmed : trimmed.substring(0, tab);
}
private static String stripPrefix(String path) {
if (path.startsWith("a/") || path.startsWith("b/")) {
return path.substring(2);
}
return path;
}
private static WorkspaceToolException patchInvalid(String message) {
return new WorkspaceToolException("PATCH_INVALID", message, false);
}
}

View File

@@ -0,0 +1,311 @@
package com.easyagents.agent.runtime.tool.operate;
import com.easyagents.agent.runtime.AgentRuntimeException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.regex.Pattern;
/**
* 工作区路径安全边界。
*
* <p>调用方只能提交工作区相对路径。该类拒绝路径穿越、宿主绝对路径、符号链接、设备文件和
* 其他非普通文件目标,并只向上层返回相对展示路径。
*/
public final class WorkspacePathGuard {
private static final Pattern WINDOWS_ABSOLUTE_PATH = Pattern.compile("^[A-Za-z]:[\\\\/].*");
private final Path workspaceRoot;
/**
* 创建路径保护器并确保工作区根目录存在。
*
* @param workspaceRoot 受信任的工作区绝对目录
* @throws AgentRuntimeException 根目录无效或无法创建时抛出
*/
public WorkspacePathGuard(Path workspaceRoot) {
if (workspaceRoot == null || !workspaceRoot.isAbsolute()) {
throw new WorkspaceToolException("WORKSPACE_CONFIG_INVALID",
"Workspace root must be an absolute path.", false);
}
try {
Files.createDirectories(workspaceRoot.normalize());
this.workspaceRoot = workspaceRoot.normalize().toRealPath();
if (!Files.isDirectory(this.workspaceRoot, LinkOption.NOFOLLOW_LINKS)) {
throw new WorkspaceToolException("WORKSPACE_CONFIG_INVALID",
"Workspace root is not a directory.", false);
}
} catch (IOException error) {
throw new WorkspaceToolException("WORKSPACE_CONFIG_INVALID",
"Workspace root cannot be initialized.", false, error);
}
}
/**
* 获取仅供受信任 Runtime 内部使用的真实工作区根目录。
*
* @return 真实工作区根目录
*/
Path root() {
return workspaceRoot;
}
/**
* 解析已存在的普通文件。
*
* @param relativePath 模型提交的工作区相对路径
* @return 受控普通文件路径
* @throws AgentRuntimeException 路径不安全、目标不存在或不是普通文件时抛出
*/
public Path resolveExistingFile(String relativePath) {
Path target = resolve(relativePath, false);
if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
throw new WorkspaceToolException("FILE_NOT_FOUND", "Workspace file does not exist.", false);
}
if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) {
throw new WorkspaceToolException("FILE_TYPE_INVALID",
"Workspace target is not a regular file.", false);
}
rejectHardLink(target);
return target;
}
/**
* 解析已存在的普通文件或目录,用于受控命令参数预检。
*
* @param relativePath 模型提交的工作区相对路径
* @return 受控现有条目
* @throws AgentRuntimeException 目标不安全、不存在或属于特殊文件时抛出
*/
public Path resolveExistingEntry(String relativePath) {
Path target = resolve(relativePath, true);
if (Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) {
rejectHardLink(target);
return target;
}
if (Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS)) {
return target;
}
throw new WorkspaceToolException("FILE_TYPE_INVALID",
"Workspace target is not a regular file or directory.", false);
}
/**
* 解析命令声明的工作区路径,允许尚不存在的创建目标和已存在的普通文件或目录。
*
* @param relativePath 命令路径参数
* @return 受控工作区路径
* @throws AgentRuntimeException 路径越界、包含链接或属于特殊文件时抛出
*/
Path resolveCommandPath(String relativePath) {
Path target = resolve(relativePath, true);
if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
return target;
}
if (Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) {
rejectHardLink(target);
return target;
}
if (Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS)) {
return target;
}
throw new WorkspaceToolException("FILE_TYPE_INVALID",
"Shell target is not a regular file or directory.", false);
}
/**
* 解析已存在的目录。
*
* @param relativePath 模型提交的工作区相对路径,`.` 表示工作区根
* @return 受控目录路径
* @throws AgentRuntimeException 路径不安全、目标不存在或不是目录时抛出
*/
public Path resolveExistingDirectory(String relativePath) {
Path target = resolve(relativePath, true);
if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
throw new WorkspaceToolException("FILE_NOT_FOUND", "Workspace directory does not exist.", false);
}
if (!Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS)) {
throw new WorkspaceToolException("FILE_TYPE_INVALID",
"Workspace target is not a directory.", false);
}
return target;
}
/**
* 解析可写入的文件路径,允许目标和父目录尚未创建。
*
* @param relativePath 模型提交的工作区相对路径
* @return 受控文件路径
* @throws AgentRuntimeException 路径不安全或现有目标不是普通文件时抛出
*/
public Path resolveForWrite(String relativePath) {
Path target = resolve(relativePath, false);
if (target.equals(workspaceRoot)) {
throw new WorkspaceToolException("WORKSPACE_PATH_INVALID",
"Workspace root cannot be used as a file target.", false);
}
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) {
throw new WorkspaceToolException("FILE_TYPE_INVALID",
"Workspace target is not a regular file.", false);
}
if (Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) {
rejectHardLink(target);
}
return target;
}
/**
* 安全创建目标文件的父目录。
*
* @param target 已由本保护器解析的目标路径
* @throws AgentRuntimeException 父目录创建失败或出现符号链接时抛出
*/
public void ensureParentDirectories(Path target) {
requireInsideWorkspace(target);
Path parent = target.getParent();
if (parent == null || parent.equals(workspaceRoot)) {
return;
}
Path relative = workspaceRoot.relativize(parent);
Path current = workspaceRoot;
try {
for (Path segment : relative) {
current = current.resolve(segment);
if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)) {
rejectSymbolicLink(current);
if (!Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
throw new WorkspaceToolException("FILE_TYPE_INVALID",
"Workspace parent is not a directory.", false);
}
continue;
}
Files.createDirectory(current);
rejectSymbolicLink(current);
}
} catch (IOException error) {
throw new WorkspaceToolException("WORKSPACE_IO_FAILED",
"Workspace parent directory cannot be created.", true, error);
}
}
/**
* 再次校验目标路径的现有链路不包含符号链接,供原子提交前缩短竞态窗口。
*
* @param target 已解析目标
* @throws AgentRuntimeException 路径越界或包含符号链接时抛出
*/
public void revalidate(Path target) {
requireInsideWorkspace(target);
rejectExistingSymbolicLinks(target);
if (Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) {
rejectHardLink(target);
}
}
/**
* 将内部路径转换为不泄露宿主目录的工作区相对展示路径。
*
* @param target 工作区内路径
* @return 使用正斜杠的相对路径,根目录返回 `.`
*/
public String display(Path target) {
requireInsideWorkspace(target);
Path relative = workspaceRoot.relativize(target.normalize());
if (relative.toString().isEmpty()) {
return ".";
}
return relative.toString().replace(target.getFileSystem().getSeparator(), "/");
}
private Path resolve(String relativePath, boolean allowRoot) {
validateRelativeInput(relativePath, allowRoot);
Path submitted;
try {
submitted = Path.of(relativePath);
} catch (RuntimeException error) {
throw new WorkspaceToolException("WORKSPACE_PATH_INVALID", "Invalid workspace path.", false, error);
}
Path target = workspaceRoot.resolve(submitted).normalize();
requireInsideWorkspace(target);
rejectExistingSymbolicLinks(target);
return target;
}
private void validateRelativeInput(String relativePath, boolean allowRoot) {
if (relativePath == null || relativePath.isBlank() || relativePath.indexOf('\0') >= 0) {
throw new WorkspaceToolException("WORKSPACE_PATH_INVALID",
"Workspace path is required and must not contain NUL.", false);
}
String trimmed = relativePath.trim();
if (trimmed.startsWith("~") || WINDOWS_ABSOLUTE_PATH.matcher(trimmed).matches()) {
throw new WorkspaceToolException("WORKSPACE_PATH_INVALID",
"Only workspace-relative paths are allowed.", false);
}
Path submitted;
try {
submitted = Path.of(trimmed);
} catch (RuntimeException error) {
throw new WorkspaceToolException("WORKSPACE_PATH_INVALID", "Invalid workspace path.", false, error);
}
if (submitted.isAbsolute()) {
throw new WorkspaceToolException("WORKSPACE_PATH_INVALID",
"Only workspace-relative paths are allowed.", false);
}
for (Path segment : submitted) {
if ("..".equals(segment.toString())) {
throw new WorkspaceToolException("WORKSPACE_PATH_INVALID",
"Workspace path traversal is not allowed.", false);
}
}
if (!allowRoot && (".".equals(trimmed) || submitted.getNameCount() == 0)) {
throw new WorkspaceToolException("WORKSPACE_PATH_INVALID",
"Workspace root cannot be used as a file target.", false);
}
}
private void rejectExistingSymbolicLinks(Path target) {
Path relative = workspaceRoot.relativize(target);
Path current = workspaceRoot;
for (Path segment : relative) {
current = current.resolve(segment);
if (!Files.exists(current, LinkOption.NOFOLLOW_LINKS)) {
break;
}
rejectSymbolicLink(current);
}
}
private void rejectSymbolicLink(Path path) {
if (Files.isSymbolicLink(path)) {
throw new WorkspaceToolException("WORKSPACE_PATH_INVALID",
"Symbolic links are not allowed in workspace paths.", false);
}
}
private void rejectHardLink(Path path) {
try {
Object value = Files.getAttribute(path, "unix:nlink", LinkOption.NOFOLLOW_LINKS);
if (value instanceof Number number && number.longValue() > 1) {
throw new WorkspaceToolException("WORKSPACE_PATH_INVALID",
"Hard-linked files are not allowed in workspace paths.", false);
}
} catch (UnsupportedOperationException ignored) {
// 非 Unix 文件系统没有 unix:nlink 属性,仍保留 NOFOLLOW 与普通文件类型校验。
} catch (IOException error) {
throw new WorkspaceToolException("WORKSPACE_IO_FAILED",
"Workspace file link count cannot be inspected.", true, error);
}
}
private void requireInsideWorkspace(Path target) {
if (target == null || !target.normalize().startsWith(workspaceRoot)) {
throw new WorkspaceToolException("WORKSPACE_PATH_INVALID",
"Workspace path escapes the configured root.", false);
}
}
}

View File

@@ -0,0 +1,263 @@
package com.easyagents.agent.runtime.tool.operate;
import com.easyagents.agent.runtime.AgentRuntimeException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.Map;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Stream;
/**
* 工作区容量与文件数量校验器。
*/
final class WorkspaceQuotaGuard {
private static final long MAX_SCANNED_ENTRIES = 100_000L;
private static final int DEFAULT_ARCHIVE_ENTRY_LIMIT = 10_000;
private static final long DEFAULT_ARCHIVE_TOTAL_LIMIT = 512L * 1024L * 1024L;
private static final long DEFAULT_ARCHIVE_FILE_LIMIT = 64L * 1024L * 1024L;
private final WorkspacePathGuard pathGuard;
private final WorkspaceQuotaLimits limits;
private final WorkspaceQuotaHook hook;
/**
* 创建配额校验器。
*
* @param pathGuard 路径保护器
* @param limits 配额限制
* @param hook 业务侧附加校验 Hook
*/
WorkspaceQuotaGuard(WorkspacePathGuard pathGuard,
WorkspaceQuotaLimits limits,
WorkspaceQuotaHook hook) {
this.pathGuard = pathGuard;
this.limits = limits == null ? WorkspaceQuotaLimits.unlimited() : limits;
this.hook = hook == null ? WorkspaceQuotaHook.noop() : hook;
}
/**
* 校验文件是否允许被完整读取。
*
* @param target 目标普通文件
*/
void validateFullRead(Path target) {
try {
long size = Files.size(target);
if (limits.getMaxReadSize() > 0 && size > limits.getMaxReadSize()) {
throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
"Workspace full-file read exceeds max-read-size.", false);
}
hook.beforeRead(pathGuard.root(), target, size);
} catch (IOException error) {
throw new WorkspaceToolException("WORKSPACE_IO_FAILED",
"Workspace file size cannot be inspected.", true, error);
}
}
/**
* 记录一次范围读取并调用业务侧配额 Hook。
*
* @param target 目标文件
* @param readBytes 实际返回字节数
*/
void validateRangeRead(Path target, long readBytes) {
if (limits.getMaxReadSize() > 0 && readBytes > limits.getMaxReadSize()) {
throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
"Workspace range read exceeds max-read-size.", false);
}
hook.beforeRead(pathGuard.root(), target, readBytes);
}
/**
* 获取范围读取字节上限。
*
* @return 字节上限,零表示使用 Runtime 固定安全上限
*/
long maxReadSize() {
return limits.getMaxReadSize() > 0 ? limits.getMaxReadSize() : 2L * 1024L * 1024L;
}
/**
* 获取单层目录最大返回条目数。
*
* @return 最大条目数
*/
int maxDirectoryEntries() {
long configured = limits.getMaxFileCount();
return configured > 0 ? (int) Math.min(configured, 1000) : 1000;
}
/**
* 获取安全归档单次最大条目数。
*
* @return 条目数上限
*/
int maxArchiveEntries() {
long configured = limits.getMaxFileCount();
return configured > 0
? (int) Math.min(configured, DEFAULT_ARCHIVE_ENTRY_LIMIT)
: DEFAULT_ARCHIVE_ENTRY_LIMIT;
}
/**
* 获取安全归档展开总量上限。
*
* @return 展开总字节数上限
*/
long maxArchiveTotalSize() {
long configured = limits.getMaxTotalSize();
return configured > 0 ? Math.min(configured, DEFAULT_ARCHIVE_TOTAL_LIMIT) : DEFAULT_ARCHIVE_TOTAL_LIMIT;
}
/**
* 获取安全归档单文件上限。
*
* @return 单文件字节数上限
*/
long maxArchiveSingleFileSize() {
long configured = limits.getMaxSingleFileSize();
return configured > 0 ? Math.min(configured, DEFAULT_ARCHIVE_FILE_LIMIT) : DEFAULT_ARCHIVE_FILE_LIMIT;
}
/**
* 校验单个文件变更后的工作区配额。
*
* @param target 目标文件
* @param resultingBytes 变更后的文件字节数,删除时为零
*/
void validateWrite(Path target, long resultingBytes) {
validateBatch(Map.of(target, resultingBytes));
}
/**
* 校验一批文件变更后的工作区配额。
*
* @param resultingSizes 目标路径到变更后字节数的映射,负数表示删除
*/
void validateBatch(Map<Path, Long> resultingSizes) {
if (resultingSizes == null || resultingSizes.isEmpty()) {
return;
}
WorkspaceUsage usage = scanUsage();
long projectedSize = usage.totalSize();
long projectedCount = usage.entryCount();
Set<Path> plannedEntries = new HashSet<>();
for (Map.Entry<Path, Long> entry : resultingSizes.entrySet()) {
Path target = entry.getKey();
long resultingBytes = entry.getValue() == null ? 0 : entry.getValue();
boolean exists = Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS);
long previousBytes = sizeIfRegular(target);
projectedSize -= previousBytes;
if (resultingBytes < 0) {
if (exists) {
projectedCount--;
}
hook.beforeWrite(pathGuard.root(), target, previousBytes, 0);
continue;
}
if (limits.getMaxSingleFileSize() > 0 && resultingBytes > limits.getMaxSingleFileSize()) {
throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
"Workspace file exceeds max-single-file-size.", false);
}
try {
projectedSize = Math.addExact(projectedSize, resultingBytes);
} catch (ArithmeticException error) {
throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
"Workspace exceeds max-total-size.", false, error);
}
if (!exists) {
if (plannedEntries.add(target)) {
projectedCount++;
}
Path parent = target.getParent();
while (parent != null && !parent.equals(pathGuard.root())) {
if (!Files.exists(parent, LinkOption.NOFOLLOW_LINKS) && plannedEntries.add(parent)) {
projectedCount++;
}
parent = parent.getParent();
}
}
hook.beforeWrite(pathGuard.root(), target, previousBytes, resultingBytes);
}
if (limits.getMaxTotalSize() > 0 && projectedSize > limits.getMaxTotalSize()) {
throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
"Workspace exceeds max-total-size.", false);
}
if (limits.getMaxFileCount() > 0 && projectedCount > limits.getMaxFileCount()) {
throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
"Workspace exceeds max-file-count.", false);
}
}
/**
* 校验当前工作区已处于配额范围内。
*/
void validateCurrentUsage() {
WorkspaceUsage usage = scanUsage();
if (limits.getMaxTotalSize() > 0 && usage.totalSize() > limits.getMaxTotalSize()) {
throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
"Workspace exceeds max-total-size.", false);
}
if (limits.getMaxFileCount() > 0 && usage.entryCount() > limits.getMaxFileCount()) {
throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
"Workspace exceeds max-file-count.", false);
}
}
private WorkspaceUsage scanUsage() {
long totalSize = 0;
long entryCount = 0;
try (Stream<Path> paths = Files.walk(pathGuard.root())) {
for (Path path : (Iterable<Path>) paths::iterator) {
if (path.equals(pathGuard.root())) {
continue;
}
entryCount++;
if (entryCount > MAX_SCANNED_ENTRIES) {
throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
"Workspace contains too many entries to inspect safely.", false);
}
if (Files.isSymbolicLink(path)) {
throw new WorkspaceToolException("WORKSPACE_PATH_INVALID",
"Workspace contains a symbolic link.", false);
}
if (Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
totalSize = Math.addExact(totalSize, Files.size(path));
} else if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new WorkspaceToolException("FILE_TYPE_INVALID",
"Workspace contains a non-regular entry.", false);
}
}
return new WorkspaceUsage(totalSize, entryCount);
} catch (IOException | ArithmeticException error) {
throw new WorkspaceToolException("WORKSPACE_IO_FAILED",
"Workspace usage cannot be inspected.", true, error);
}
}
private long sizeIfRegular(Path target) {
if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) {
return 0;
}
try {
return Files.size(target);
} catch (IOException error) {
throw new WorkspaceToolException("WORKSPACE_IO_FAILED",
"Workspace file size cannot be inspected.", true, error);
}
}
/**
* 工作区当前使用量。
*
* @param totalSize 普通文件总字节数
* @param entryCount 文件与目录条目数量,不包含工作区根
*/
private record WorkspaceUsage(long totalSize, long entryCount) {
}
}

View File

@@ -0,0 +1,74 @@
package com.easyagents.agent.runtime.tool.operate;
import java.nio.file.Path;
/**
* 业务侧可选的工作区配额校验 Hook。
*
* <p>Runtime 会先执行内置容量校验,再调用该 Hook。参数中的路径仅供受信任的服务端实现使用
* 不会进入 Tool Schema、metadata 或模型结果。
*/
public interface WorkspaceQuotaHook {
/**
* 在读取普通文件前执行附加校验。
*
* @param workspaceRoot 工作区根目录
* @param target 目标普通文件
* @param requestedBytes 预计读取字节数
*/
void beforeRead(Path workspaceRoot, Path target, long requestedBytes);
/**
* 在提交文件变更前执行附加校验。
*
* @param workspaceRoot 工作区根目录
* @param target 目标文件
* @param previousBytes 原文件字节数,不存在时为零
* @param resultingBytes 新文件字节数,删除时为零
*/
void beforeWrite(Path workspaceRoot, Path target, long previousBytes, long resultingBytes);
/**
* 获取无操作 Hook。
*
* @return 无操作 Hook
*/
static WorkspaceQuotaHook noop() {
return NoopWorkspaceQuotaHook.INSTANCE;
}
/**
* 无操作 Hook 实现。
*/
final class NoopWorkspaceQuotaHook implements WorkspaceQuotaHook {
private static final NoopWorkspaceQuotaHook INSTANCE = new NoopWorkspaceQuotaHook();
private NoopWorkspaceQuotaHook() {
}
/**
* 不执行附加读取校验。
*
* @param workspaceRoot 工作区根目录
* @param target 目标普通文件
* @param requestedBytes 预计读取字节数
*/
@Override
public void beforeRead(Path workspaceRoot, Path target, long requestedBytes) {
}
/**
* 不执行附加写入校验。
*
* @param workspaceRoot 工作区根目录
* @param target 目标文件
* @param previousBytes 原文件字节数
* @param resultingBytes 新文件字节数
*/
@Override
public void beforeWrite(Path workspaceRoot, Path target, long previousBytes, long resultingBytes) {
}
}
}

View File

@@ -0,0 +1,78 @@
package com.easyagents.agent.runtime.tool.operate;
/**
* 工作区资源配额。
*
* <p>所有大小均以字节计。小于等于零的值表示对应维度不限制,便于通用 Runtime 保持兼容,
* 生产系统应由业务侧显式传入有界配置。
*/
public final class WorkspaceQuotaLimits {
private final long maxTotalSize;
private final long maxSingleFileSize;
private final long maxFileCount;
private final long maxReadSize;
/**
* 创建工作区配额。
*
* @param maxTotalSize 工作区普通文件总字节数
* @param maxSingleFileSize 单个普通文件最大字节数
* @param maxFileCount 工作区文件与目录条目最大数量,不包含工作区根
* @param maxReadSize 单次读取文件最大字节数
*/
public WorkspaceQuotaLimits(long maxTotalSize,
long maxSingleFileSize,
long maxFileCount,
long maxReadSize) {
this.maxTotalSize = maxTotalSize;
this.maxSingleFileSize = maxSingleFileSize;
this.maxFileCount = maxFileCount;
this.maxReadSize = maxReadSize;
}
/**
* 创建无限制配额。
*
* @return 无限制配额
*/
public static WorkspaceQuotaLimits unlimited() {
return new WorkspaceQuotaLimits(0, 0, 0, 0);
}
/**
* 获取工作区总量上限。
*
* @return 总字节数上限
*/
public long getMaxTotalSize() {
return maxTotalSize;
}
/**
* 获取单文件上限。
*
* @return 单文件字节数上限
*/
public long getMaxSingleFileSize() {
return maxSingleFileSize;
}
/**
* 获取工作区条目数量上限。
*
* @return 文件与目录条目数量上限
*/
public long getMaxFileCount() {
return maxFileCount;
}
/**
* 获取单次读取上限。
*
* @return 读取字节数上限
*/
public long getMaxReadSize() {
return maxReadSize;
}
}

View File

@@ -0,0 +1,269 @@
package com.easyagents.agent.runtime.tool.operate;
import java.io.IOException;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.SeekableByteChannel;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.LinkOption;
import java.nio.file.OpenOption;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.Set;
/**
* 工作区 UTF-8 文本文件原子读写辅助方法。
*/
final class WorkspaceTextFiles {
private WorkspaceTextFiles() {
}
/**
* 严格按 UTF-8 读取文件。
*
* @param target 目标普通文件
* @return 文件文本
*/
static String readUtf8(Path target) {
Set<OpenOption> options = Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS);
try (SeekableByteChannel channel = Files.newByteChannel(target, options);
java.io.InputStream input = Channels.newInputStream(channel)) {
byte[] bytes = input.readAllBytes();
return decodeUtf8(bytes);
} catch (IOException error) {
throw new WorkspaceToolException("WORKSPACE_IO_FAILED",
"Workspace text file cannot be read.", true, error);
}
}
/**
* 以流式方式读取有界行范围,避免为了返回少量行先加载完整文本。
*
* @param target 目标普通文件
* @param ranges 可选行范围,支持 `start,end` 与负数尾部索引
* @return 带真实起始行号的行范围
*/
static RangedLines readUtf8Lines(Path target, String ranges, long maxReadBytes) {
ParsedRange range = ParsedRange.parse(ranges);
Set<OpenOption> options = Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS);
try (SeekableByteChannel channel = Files.newByteChannel(target, options);
BufferedReader reader = new BufferedReader(new InputStreamReader(
Channels.newInputStream(channel), StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)))) {
if (range.negative()) {
return readTail(reader, range, maxReadBytes);
}
List<String> selected = new ArrayList<>();
long selectedBytes = 0;
int lineNumber = 0;
String line;
while ((line = reader.readLine()) != null) {
lineNumber++;
if (lineNumber >= range.start() && lineNumber <= range.end()) {
selectedBytes = addLineBytes(selectedBytes, line, maxReadBytes);
selected.add(line);
}
if (lineNumber >= range.end()) {
break;
}
}
if (lineNumber < range.start() && lineNumber > 0) {
throw invalidRange("Invalid range: start line is outside the file.");
}
return new RangedLines(range.start(), selected, selectedBytes);
} catch (CharacterCodingException error) {
throw new WorkspaceToolException("FILE_ENCODING_INVALID",
"Workspace file is not valid UTF-8 text.", false, error);
} catch (IOException error) {
throw new WorkspaceToolException("WORKSPACE_IO_FAILED",
"Workspace text file cannot be read.", true, error);
}
}
/**
* 严格解码 UTF-8 字节。
*
* @param bytes 文本字节
* @return UTF-8 文本
*/
static String decodeUtf8(byte[] bytes) {
try {
CharBuffer decoded = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes));
return decoded.toString();
} catch (CharacterCodingException error) {
throw new WorkspaceToolException("FILE_ENCODING_INVALID",
"Workspace file is not valid UTF-8 text.", false, error);
}
}
/**
* 使用同目录临时文件原子替换目标内容。
*
* @param pathGuard 路径保护器
* @param target 目标文件
* @param bytes 新文件字节
*/
static void atomicWrite(WorkspacePathGuard pathGuard, Path target, byte[] bytes) {
pathGuard.ensureParentDirectories(target);
Path parent = target.getParent();
Path temporary = null;
try {
temporary = Files.createTempFile(parent, ".easyagents-write-", ".tmp");
try (FileChannel channel = FileChannel.open(
temporary, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
ByteBuffer buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) {
channel.write(buffer);
}
channel.force(true);
}
pathGuard.revalidate(target);
Files.move(temporary, target,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
forceDirectory(parent);
} catch (IOException error) {
throw new WorkspaceToolException("WORKSPACE_IO_FAILED",
"Workspace text file cannot be committed.", true, error);
} finally {
if (temporary != null) {
try {
Files.deleteIfExists(temporary);
} catch (IOException ignored) {
// 提交失败已经向上抛出,临时文件清理失败由后续工作区清理任务兜底。
}
}
}
}
private static RangedLines readTail(BufferedReader reader,
ParsedRange range,
long maxReadBytes) throws IOException {
long requestedKeep = Math.max(Math.abs((long) range.start()), Math.abs((long) range.end()));
if (requestedKeep > Math.min(maxReadBytes, 100_000L)) {
throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
"Requested tail range exceeds the configured read bound.", false);
}
int keep = Math.toIntExact(requestedKeep);
Deque<String> tail = new ArrayDeque<>(keep);
int lineCount = 0;
String line;
while ((line = reader.readLine()) != null) {
lineCount++;
if (tail.size() == keep) {
tail.removeFirst();
}
long lineBytes = line.getBytes(StandardCharsets.UTF_8).length + 1L;
if (lineBytes > maxReadBytes) {
throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
"Workspace range read exceeds max-read-size.", false);
}
tail.addLast(line);
}
if (lineCount == 0) {
return new RangedLines(1, List.of(), 0);
}
int start = Math.max(1, lineCount + range.start() + 1);
int end = Math.min(lineCount, lineCount + range.end() + 1);
if (start > end) {
throw invalidRange("Invalid range: start line is greater than end line.");
}
int retainedStart = lineCount - tail.size() + 1;
List<String> retained = new ArrayList<>(tail);
List<String> selected = new ArrayList<>(
retained.subList(start - retainedStart, end - retainedStart + 1));
long selectedBytes = 0;
for (String selectedLine : selected) {
selectedBytes = addLineBytes(selectedBytes, selectedLine, maxReadBytes);
}
return new RangedLines(start, selected, selectedBytes);
}
private static long addLineBytes(long current, String line, long maxReadBytes) {
long updated;
try {
updated = Math.addExact(current, line.getBytes(StandardCharsets.UTF_8).length + 1L);
} catch (ArithmeticException error) {
throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
"Workspace range read exceeds max-read-size.", false, error);
}
if (updated > maxReadBytes) {
throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
"Workspace range read exceeds max-read-size.", false);
}
return updated;
}
private static void forceDirectory(Path directory) {
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
} catch (IOException | UnsupportedOperationException ignored) {
// 某些文件系统不支持目录 fsync文件内容和原子 rename 已经完成。
}
}
private static WorkspaceToolException invalidRange(String message) {
return new WorkspaceToolException("INVALID_ARGUMENT", message, false);
}
/**
* 流式读取结果。
*
* @param startLine 第一行真实 1-based 行号
* @param lines 文本行
* @param readBytes 返回文本字节数
*/
record RangedLines(int startLine, List<String> lines, long readBytes) {
}
/**
* 归一化行范围。
*
* @param start 起始行,允许负数
* @param end 结束行,允许负数
* @param negative 是否为尾部范围
*/
private record ParsedRange(int start, int end, boolean negative) {
private static ParsedRange parse(String ranges) {
if (ranges == null || ranges.isBlank()) {
return new ParsedRange(1, Integer.MAX_VALUE, false);
}
String normalized = ranges.trim().replace("[", "").replace("]", "");
String[] parts = normalized.split(",", -1);
if (parts.length != 2) {
throw invalidRange("Invalid range format. Expected 'start,end'.");
}
try {
int start = Integer.parseInt(parts[0].trim());
int end = Integer.parseInt(parts[1].trim());
if (start == 0 || end == 0 || (start < 0) != (end < 0)) {
throw invalidRange("Invalid range: use either positive or negative line numbers.");
}
if (start > end) {
throw invalidRange("Invalid range: start line is greater than end line.");
}
return new ParsedRange(start, end, start < 0);
} catch (NumberFormatException error) {
throw new WorkspaceToolException("INVALID_ARGUMENT",
"Invalid range format. Expected integer line numbers.", false, error);
}
}
}
}

View File

@@ -0,0 +1,57 @@
package com.easyagents.agent.runtime.tool.operate;
import com.easyagents.agent.runtime.AgentRuntimeException;
/**
* 带稳定工具错误码和重试语义的工作区异常。
*/
final class WorkspaceToolException extends AgentRuntimeException {
private final String code;
private final boolean retryable;
/**
* 创建工具异常。
*
* @param code 稳定错误码
* @param message 可安全返回给模型的信息
* @param retryable 是否可重试
*/
WorkspaceToolException(String code, String message, boolean retryable) {
super(message);
this.code = code;
this.retryable = retryable;
}
/**
* 创建带内部原因的工具异常。
*
* @param code 稳定错误码
* @param message 可安全返回给模型的信息
* @param retryable 是否可重试
* @param cause 仅写入服务端日志的内部原因
*/
WorkspaceToolException(String code, String message, boolean retryable, Throwable cause) {
super(message, cause);
this.code = code;
this.retryable = retryable;
}
/**
* 获取稳定错误码。
*
* @return 错误码
*/
String code() {
return code;
}
/**
* 返回是否可重试。
*
* @return 可重试时为 true
*/
boolean retryable() {
return retryable;
}
}

View File

@@ -0,0 +1,58 @@
package com.easyagents.agent.runtime.tool.operate;
import com.easyagents.agent.runtime.AgentRuntimeException;
import io.agentscope.core.message.ToolResultBlock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 内置工作区工具的稳定错误结果工厂。
*/
final class WorkspaceToolResults {
private static final Logger logger = LoggerFactory.getLogger(WorkspaceToolResults.class);
private WorkspaceToolResults() {
}
/**
* 将内部异常转换为不含宿主路径的稳定错误对象。
*
* @param error 内部异常
* @return Tool 错误结果
*/
static ToolResultBlock error(AgentRuntimeException error) {
if (error instanceof WorkspaceToolException typed) {
if (typed.getCause() != null) {
logger.error("Workspace tool failed with code {}", typed.code(), typed);
}
return error(typed.code(), typed.getMessage(), typed.retryable());
}
logger.error("Unexpected workspace tool failure", error);
return error("WORKSPACE_OPERATION_FAILED", "Workspace operation failed.", false);
}
/**
* 创建稳定错误结果。
*
* @param code 错误码
* @param message 安全错误信息
* @param retryable 是否可重试
* @return Tool 错误结果
*/
static ToolResultBlock error(String code, String message, boolean retryable) {
String json = "{\"code\":\"" + escape(code) + "\",\"message\":\""
+ escape(message) + "\",\"retryable\":" + retryable + "}";
return ToolResultBlock.error(json);
}
private static String escape(String value) {
if (value == null) {
return "";
}
return value.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r");
}
}

View File

@@ -241,6 +241,7 @@ public class AgentScopeStatefulRuntimeTest {
request.getAgentDefinition().setOperateToolSpecs(List.of(
operateToolSpec(AgentOperateToolType.READ_FILE),
operateToolSpec(AgentOperateToolType.WRITE_FILE),
operateToolSpec(AgentOperateToolType.PATCH),
operateToolSpec(AgentOperateToolType.SHELL)));
AgentScopeReActRuntime runtime = fakeRuntime();
@@ -251,6 +252,7 @@ public class AgentScopeStatefulRuntimeTest {
Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.LIST_DIRECTORY_TOOL));
Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.WRITE_TEXT_FILE_TOOL));
Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.INSERT_TEXT_FILE_TOOL));
Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.APPLY_PATCH_TOOL));
Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL));
}
@@ -258,14 +260,13 @@ public class AgentScopeStatefulRuntimeTest {
public void shouldSuspendShellOperateToolWithToolHitlInterceptor() {
AgentInitRequest request = initRequest();
AgentOperateToolSpec shell = operateToolSpec(AgentOperateToolType.SHELL);
shell.setShellAllowedCommands(Set.of());
request.getAgentDefinition().setOperateToolSpecs(List.of(shell));
AgentScopeReActRuntime runtime = runtimeWithModel(List.of(ChatResponse.builder()
.id("shell-call-message")
.content(List.of(ToolUseBlock.builder()
.id("call-shell")
.name(AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL)
.input(Map.of("command", "echo hello"))
.input(Map.of("command", "pwd"))
.build()))
.finishReason("tool_calls")
.build()));
@@ -280,6 +281,36 @@ public class AgentScopeStatefulRuntimeTest {
Assert.assertTrue(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.SUSPENDED));
}
@Test
public void shouldBypassRemoveApprovalWhenShellApprovalIsDisabled() {
AgentInitRequest request = initRequest();
AgentOperateToolSpec shell = operateToolSpec(AgentOperateToolType.SHELL);
shell.setApprovalRequired(false);
request.getAgentDefinition().setOperateToolSpecs(List.of(shell));
AgentScopeReActRuntime runtime = runtimeWithModel(List.of(ChatResponse.builder()
.id("remove-message")
.content(List.of(ToolUseBlock.builder()
.id("call-remove")
.name(AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL)
.input(Map.of("command", "'rm' removable.txt"))
.build()))
.finishReason("tool_calls")
.build()));
runtime.init(request);
List<AgentRuntimeEvent> events = runtime.stream(AgentMessage.text(AgentMessageRole.USER, "remove file"))
.collectList()
.block(Duration.ofSeconds(5));
Assert.assertNotNull(events);
Assert.assertFalse(events.stream()
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED));
Assert.assertFalse(events.stream()
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.SUSPENDED));
Assert.assertTrue(events.stream()
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_RESULT));
}
@Test(expected = AgentRuntimeException.class)
public void shouldRejectOperateToolNameConflictWithBusinessTool() {
AgentInitRequest request = initRequest();
@@ -498,10 +529,12 @@ public class AgentScopeStatefulRuntimeTest {
ToolUseBlock toolUse = ToolUseBlock.builder()
.id("call-1")
.name("search")
.input(Map.of("q", "easyflow"))
.input(Map.of("q", "sentinel-secret-input"))
.metadata(Map.of("authorization", "sentinel-secret-metadata"))
.build();
ToolResultBlock toolResult = ToolResultBlock.of("call-1", "search",
TextBlock.builder().text("done").build(), Map.of("success", true));
TextBlock.builder().text("sentinel-secret-result").build(),
Map.of("success", true, "token", "sentinel-secret-result-metadata"));
observer.observe(new PreActingEvent(agent, toolkit, toolUse)).block();
observer.observe(new PostActingEvent(agent, toolkit, toolUse, toolResult)).block();
@@ -509,13 +542,16 @@ public class AgentScopeStatefulRuntimeTest {
Assert.assertEquals(AgentRuntimeEventType.TOOL_CALL, events.get(0).getEventType());
Assert.assertEquals("RUNNING", events.get(0).getPayload().get("status"));
Assert.assertEquals("PRE_ACTING", events.get(0).getPayload().get("phase"));
Assert.assertEquals("Search Tool", events.get(0).getPayload().get("toolDisplayName"));
Assert.assertEquals("search", events.get(0).getPayload().get("rawMcpToolName"));
Assert.assertFalse(events.get(0).getPayload().containsKey("input"));
Assert.assertFalse(events.get(0).getPayload().containsKey("content"));
Assert.assertFalse(events.get(0).getMetadata().toString().contains("sentinel-secret"));
Assert.assertEquals(AgentRuntimeEventType.TOOL_RESULT, events.get(1).getEventType());
Assert.assertEquals("SUCCESS", events.get(1).getPayload().get("status"));
Assert.assertEquals("POST_ACTING", events.get(1).getPayload().get("phase"));
Assert.assertEquals("Search Tool", events.get(1).getPayload().get("toolDisplayName"));
Assert.assertFalse(events.get(1).getPayload().containsKey("text"));
Assert.assertFalse(events.get(1).getMetadata().toString().contains("sentinel-secret"));
Assert.assertFalse(events.toString().contains("sentinel-secret"));
}
@Test
@@ -748,7 +784,12 @@ public class AgentScopeStatefulRuntimeTest {
@Test
public void shouldRejectConcurrentStatefulStream() {
AgentScopeReActRuntime runtime = fakeRuntime();
AgentScopeReActRuntime runtime = runtimeWithModel(List.of(
ChatResponse.builder()
.id("slow-response")
.content(List.of(TextBlock.builder().text("still running").build()))
.finishReason("stop")
.build()), Duration.ofSeconds(1));
runtime.init(initRequest());
reactor.core.Disposable disposable = runtime.stream(AgentMessage.text(AgentMessageRole.USER, "first"))
@@ -974,6 +1015,188 @@ public class AgentScopeStatefulRuntimeTest {
Assert.assertTrue(sessionStore.exists("session-1"));
}
/**
* 验证同一 Turn 内同一 MCP 的后续工具复用一次批准,新 Turn 会重新请求批准。
*/
@Test
public void shouldReuseMcpApprovalWithinTurnAndResetForNextTurn() {
AgentInitRequest request = initRequest();
AgentToolSpec resolveSpec = approvalRequiredMcpTool(
"mcp_101_resolve_library_id", "101");
AgentToolSpec querySpec = approvalRequiredMcpTool(
"mcp_101_query_docs", "101");
request.getAgentDefinition().setToolSpecs(List.of(resolveSpec, querySpec));
AtomicInteger invocationCount = new AtomicInteger();
request.setToolInvokers(Map.of(
resolveSpec.getName(), (arguments, context) -> {
invocationCount.incrementAndGet();
return AgentToolResult.success("library-id");
},
querySpec.getName(), (arguments, context) -> {
invocationCount.incrementAndGet();
return AgentToolResult.success("docs");
}));
AgentScopeReActRuntime runtime = runtimeWithSequentialModel(List.of(
toolResponse("resolve-call", "call-resolve", resolveSpec.getName()),
toolResponse("query-call", "call-query", querySpec.getName()),
ChatResponse.builder()
.id("final-message")
.content(List.of(TextBlock.builder().text("done").build()))
.finishReason("stop")
.build(),
toolResponse("next-turn-call", "call-next", resolveSpec.getName())));
runtime.init(request);
List<AgentRuntimeEvent> initialEvents = runtime.stream(
AgentMessage.text(AgentMessageRole.USER, "介绍 AG-UI"))
.collectList()
.block();
AgentRuntimeEvent approval = initialEvents.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)
.findFirst()
.orElseThrow();
List<AgentRuntimeEvent> resumeEvents = runtime.resume(resumeFromApproval(approval, true))
.collectList()
.block();
Assert.assertEquals(2, invocationCount.get());
Assert.assertFalse(resumeEvents.stream()
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED));
Assert.assertTrue(resumeEvents.stream()
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED));
List<AgentRuntimeEvent> nextTurnEvents = runtime.stream(
AgentMessage.text(AgentMessageRole.USER, "再查一次"))
.collectList()
.block();
Assert.assertEquals(1, nextTurnEvents.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)
.count());
Assert.assertTrue(nextTurnEvents.stream()
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.SUSPENDED));
}
/**
* 验证同一推理消息中同一 MCP 的多个工具只生成一个审批请求。
*/
@Test
public void shouldRequestOneApprovalForParallelToolsFromSameMcp() {
AgentInitRequest request = initRequest();
AgentToolSpec resolveSpec = approvalRequiredMcpTool(
"mcp_101_resolve_library_id", "101");
AgentToolSpec querySpec = approvalRequiredMcpTool(
"mcp_101_query_docs", "101");
request.getAgentDefinition().setToolSpecs(List.of(resolveSpec, querySpec));
AtomicInteger invocationCount = new AtomicInteger();
request.setToolInvokers(Map.of(
resolveSpec.getName(), (arguments, context) -> {
invocationCount.incrementAndGet();
return AgentToolResult.success("library-id");
},
querySpec.getName(), (arguments, context) -> {
invocationCount.incrementAndGet();
return AgentToolResult.success("docs");
}));
AgentScopeReActRuntime runtime = runtimeWithSequentialModel(List.of(
ChatResponse.builder()
.id("parallel-mcp-tools")
.content(List.of(
ToolUseBlock.builder()
.id("call-resolve")
.name(resolveSpec.getName())
.input(Map.of())
.build(),
ToolUseBlock.builder()
.id("call-query")
.name(querySpec.getName())
.input(Map.of())
.build()))
.finishReason("tool_calls")
.build(),
ChatResponse.builder()
.id("parallel-final")
.content(List.of(TextBlock.builder().text("done").build()))
.finishReason("stop")
.build()));
runtime.init(request);
List<AgentRuntimeEvent> initialEvents = runtime.stream(
AgentMessage.text(AgentMessageRole.USER, "并行查询"))
.collectList()
.block();
List<AgentRuntimeEvent> approvals = initialEvents.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)
.toList();
Assert.assertEquals(1, approvals.size());
List<AgentRuntimeEvent> resumeEvents = runtime.resume(
resumeFromApproval(approvals.get(0), true))
.collectList()
.block();
Assert.assertEquals(2, invocationCount.get());
Assert.assertTrue(resumeEvents.stream()
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED));
}
/**
* 验证模型返回的 ToolUse 元数据不能覆盖 ToolSpec 中受信任的 MCP 审批作用域。
*/
@Test
public void shouldIgnoreForgedMcpScopeFromToolUseMetadata() {
AgentInitRequest request = initRequest();
AgentToolSpec resolveSpec = approvalRequiredMcpTool(
"mcp_101_resolve_library_id", "101");
AgentToolSpec querySpec = approvalRequiredMcpTool(
"mcp_101_query_docs", "101");
request.getAgentDefinition().setToolSpecs(List.of(resolveSpec, querySpec));
request.setToolInvokers(Map.of(
resolveSpec.getName(), (arguments, context) -> AgentToolResult.success("library-id"),
querySpec.getName(), (arguments, context) -> AgentToolResult.success("docs")));
AgentScopeReActRuntime runtime = runtimeWithSequentialModel(List.of(
ChatResponse.builder()
.id("forged-scope-call")
.content(List.of(ToolUseBlock.builder()
.id("call-resolve")
.name(resolveSpec.getName())
.input(Map.of())
.metadata(Map.of("toolType", "MCP", "mcpId", "forged"))
.build()))
.finishReason("tool_calls")
.build(),
toolResponse("query-call", "call-query", querySpec.getName()),
ChatResponse.builder()
.id("final-message")
.content(List.of(TextBlock.builder().text("done").build()))
.finishReason("stop")
.build()));
runtime.init(request);
List<AgentRuntimeEvent> initialEvents = runtime.stream(
AgentMessage.text(AgentMessageRole.USER, "介绍 AG-UI"))
.collectList()
.block();
AgentRuntimeEvent approval = initialEvents.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)
.findFirst()
.orElseThrow();
@SuppressWarnings("unchecked")
Map<String, Object> approvalMetadata =
(Map<String, Object>) approval.getPayload().get("approvalMetadata");
Assert.assertEquals("101", approvalMetadata.get("mcpId"));
List<AgentRuntimeEvent> resumeEvents = runtime.resume(resumeFromApproval(approval, true))
.collectList()
.block();
Assert.assertFalse(resumeEvents.stream()
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED));
Assert.assertTrue(resumeEvents.stream()
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED));
}
/**
* 验证同一轮推理包含多个审批工具时,全部批准前不会执行任何工具。
*/
@@ -1413,6 +1636,27 @@ public class AgentScopeStatefulRuntimeTest {
new AgentScopeMessageAdapter());
}
/**
* 创建每次模型调用仅返回下一条预设响应的运行时。
*
* @param responses 按模型调用顺序排列的响应
* @return 测试运行时
*/
private AgentScopeReActRuntime runtimeWithSequentialModel(List<ChatResponse> responses) {
AgentScopeModelFactory modelFactory = new AgentScopeModelFactory() {
@Override
public Model create(AgentModelSpec modelSpec,
com.easyagents.agent.runtime.model.AgentGenerationOptions generationOptions) {
return new SequentialScriptedModel(
modelSpec == null ? "fake-model" : modelSpec.getModelName(),
responses);
}
};
return new AgentScopeReActRuntime(modelFactory, new AgentScopeToolAdapter(),
new AgentScopeKnowledgeAdapter(), new AgentScopeMemoryAdapter(), new AgentScopeSkillAdapter(),
new AgentScopeMessageAdapter());
}
/**
* 创建单次模型调用返回多个增量响应的运行时。
*
@@ -1450,6 +1694,44 @@ public class AgentScopeStatefulRuntimeTest {
return request;
}
/**
* 创建需要批准且归属于指定 MCP 的工具定义。
*
* @param toolName 工具名称
* @param mcpId MCP 标识
* @return MCP 工具定义
*/
private AgentToolSpec approvalRequiredMcpTool(String toolName, String mcpId) {
AgentToolSpec spec = new AgentToolSpec();
spec.setName(toolName);
spec.setDescription(toolName);
spec.setApprovalRequired(true);
spec.getMetadata().put("toolType", "MCP");
spec.getMetadata().put("mcpId", mcpId);
spec.getMetadata().put("mcpTitle", "Context7");
return spec;
}
/**
* 创建包含一次工具调用的模型响应。
*
* @param messageId 响应消息标识
* @param toolCallId 工具调用标识
* @param toolName 工具名称
* @return 模型响应
*/
private ChatResponse toolResponse(String messageId, String toolCallId, String toolName) {
return ChatResponse.builder()
.id(messageId)
.content(List.of(ToolUseBlock.builder()
.id(toolCallId)
.name(toolName)
.input(Map.of())
.build()))
.finishReason("tool_calls")
.build();
}
private static class ScriptedModel implements Model {
private final String modelName;
@@ -1483,6 +1765,56 @@ public class AgentScopeStatefulRuntimeTest {
}
}
/**
* 每次调用按顺序返回一条响应的测试模型。
*/
private static class SequentialScriptedModel implements Model {
private final AtomicInteger invocationIndex = new AtomicInteger();
private final String modelName;
private final List<ChatResponse> responses;
/**
* 创建顺序响应模型。
*
* @param modelName 模型名称
* @param responses 按调用顺序排列的响应
*/
private SequentialScriptedModel(String modelName, List<ChatResponse> responses) {
this.modelName = modelName;
this.responses = responses;
}
/**
* 返回当前模型调用对应的单条响应。
*
* @param messages 输入消息
* @param toolSchemas 工具定义
* @param options 生成配置
* @return 单条响应流
*/
@Override
public Flux<ChatResponse> stream(List<Msg> messages,
List<ToolSchema> toolSchemas,
GenerateOptions options) {
int index = invocationIndex.getAndIncrement();
if (index >= responses.size()) {
return Flux.error(new IllegalStateException("No scripted response for invocation " + index));
}
return Flux.just(responses.get(index));
}
/**
* 返回模型名称。
*
* @return 模型名称
*/
@Override
public String getModelName() {
return modelName;
}
}
/**
* 单次调用按顺序返回全部响应增量的测试模型。
*/

View File

@@ -158,6 +158,138 @@ public class AgentToolApprovalCoordinatorTest {
coordinator, "call-1", "search", Map.of("q", "easyflow"));
}
/**
* 验证受信任恢复可以签发当前 Turn 内可复用的 MCP 审批作用域。
*/
@Test
public void shouldAuthorizeTrustedMcpScope() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
AgentResumeRequest request = new AgentResumeRequest();
AgentResumeToken token = new AgentResumeToken();
token.setValue("persisted-token");
request.setResumeToken(token);
request.setApproved(true);
request.setTrusted(true);
request.setMetadata(Map.of(
"toolCallId", "call-1",
"toolName", "mcp_101_search",
"toolInput", Map.of("q", "easyflow"),
"toolType", "MCP",
"mcpId", "101"));
coordinator.authorizeTrustedExecution(request);
Assert.assertTrue(coordinator.isReusableApprovalGranted(
Map.of("toolType", "MCP", "mcpId", "101")));
}
/**
* 验证 MCP 批准可在当前 Turn 按稳定 mcpId 复用。
*/
@Test
public void shouldReuseApprovedMcpScopeWithinCurrentTurn() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
Map<String, Object> mcpMetadata = Map.of("toolType", "MCP", "mcpId", "101");
AgentPendingState pending = coordinator.register(
"session-1",
"agent-1",
"call-resolve",
"mcp_101_resolve_library_id",
"approve",
Map.of("libraryName", "AG-UI"),
mcpMetadata,
Instant.now().plusSeconds(60),
"batch-mcp");
coordinator.resolve(resume(pending, true));
Assert.assertTrue(coordinator.isReusableApprovalGranted(mcpMetadata));
Assert.assertFalse(coordinator.isReusableApprovalGranted(
Map.of("toolType", "MCP", "mcpId", "102")));
Assert.assertFalse(coordinator.isReusableApprovalGranted(
Map.of("toolType", "MCP", "mcpName", "context7")));
coordinator.clearReusableApprovalScopes();
Assert.assertFalse(coordinator.isReusableApprovalGranted(mcpMetadata));
}
/**
* 验证受控 Shell 脚本只能按受信任内容摘要在当前 Turn 复用审批。
*/
@Test
public void shouldReuseApprovedShellScriptScopeWithinCurrentTurn() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
Map<String, Object> scriptMetadata = Map.of(
"operateTool", true,
"operateToolType", "SHELL",
"approvalScope", "SHELL_SCRIPT:abc123");
AgentPendingState pending = coordinator.register(
"session-1", "agent-1", "call-script", "execute_shell_command", "approve",
Map.of("command", "python3 report.py"), scriptMetadata,
Instant.now().plusSeconds(60), "batch-script");
coordinator.resolve(resume(pending, true));
Assert.assertTrue(coordinator.isReusableApprovalGranted(scriptMetadata));
Assert.assertFalse(coordinator.isReusableApprovalGranted(Map.of(
"operateTool", true,
"operateToolType", "SHELL",
"approvalScope", "SHELL_SCRIPT:changed")));
Assert.assertNull(coordinator.reusableApprovalScope(Map.of(
"approvalScope", "SHELL_SCRIPT:abc123")));
}
/**
* 验证跨节点受信任恢复可恢复 Shell 脚本内容摘要作用域。
*/
@Test
public void shouldAuthorizeTrustedShellScriptScope() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
AgentResumeRequest request = new AgentResumeRequest();
AgentResumeToken token = new AgentResumeToken();
token.setValue("persisted-token");
request.setResumeToken(token);
request.setApproved(true);
request.setTrusted(true);
request.setMetadata(Map.of(
"toolCallId", "call-script",
"toolName", "execute_shell_command",
"toolInput", Map.of("command", "node report.mjs"),
"operateTool", true,
"operateToolType", "SHELL",
"approvalScope", "SHELL_SCRIPT:def456"));
coordinator.authorizeTrustedExecution(request);
Assert.assertTrue(coordinator.isReusableApprovalGranted(Map.of(
"operateTool", true,
"operateToolType", "SHELL",
"approvalScope", "SHELL_SCRIPT:def456")));
}
/**
* 验证拒绝和过期不会产生可复用 MCP 批准。
*/
@Test
public void shouldNotReuseRejectedOrExpiredMcpApproval() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
Map<String, Object> rejectedMetadata = Map.of("toolType", "MCP", "mcpId", "201");
AgentPendingState rejected = coordinator.register(
"session-1", "agent-1", "call-rejected", "mcp_rejected", "approve",
Map.of(), rejectedMetadata, Instant.now().plusSeconds(60), "batch-rejected");
coordinator.resolve(resume(rejected, false));
Map<String, Object> expiredMetadata = Map.of("toolType", "MCP", "mcpId", "202");
AgentPendingState expired = coordinator.register(
"session-1", "agent-1", "call-expired-mcp", "mcp_expired", "approve",
Map.of(), expiredMetadata, Instant.now().minusSeconds(1), "batch-expired-mcp");
coordinator.resolve(resume(expired, true));
Assert.assertFalse(coordinator.isReusableApprovalGranted(rejectedMetadata));
Assert.assertFalse(coordinator.isReusableApprovalGranted(expiredMetadata));
}
/**
* 验证同一 toolCallId 不能被重新绑定到不同工具内容。
*/

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

View File

@@ -8,6 +8,9 @@ import org.junit.Test;
import java.util.List;
import java.util.Set;
import java.nio.file.Files;
import java.nio.file.Path;
import java.io.IOException;
/**
* 测试 Agent 操作类工具适配器。
@@ -30,7 +33,7 @@ public class AgentOperateToolAdapterTest {
}
@Test
public void shouldRegisterWriteFileToolsWithDefaultHitlEnabled() {
public void shouldRegisterWriteFileToolsWithDefaultHitlDisabled() {
Toolkit toolkit = new Toolkit();
AgentOperateToolSpec spec = spec(AgentOperateToolType.WRITE_FILE);
@@ -39,20 +42,57 @@ public class AgentOperateToolAdapterTest {
Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.WRITE_TEXT_FILE_TOOL));
Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.INSERT_TEXT_FILE_TOOL));
Assert.assertEquals(2, toolSpecs.size());
Assert.assertTrue(toolSpecs.stream().allMatch(AgentToolSpec::isApprovalRequired));
Assert.assertTrue(toolSpecs.stream().noneMatch(AgentToolSpec::isApprovalRequired));
}
@Test(expected = AgentRuntimeException.class)
public void shouldRejectEmptyShellWhitelist() {
AgentOperateToolSpec spec = spec(AgentOperateToolType.SHELL);
spec.setShellAllowedCommands(Set.of());
adapter.register(List.of(spec), new Toolkit());
}
@Test
public void shouldRegisterShellToolWithEmptyWhitelist() {
public void shouldRegisterShellWithForcedRmApprovalMetadata() {
Toolkit toolkit = new Toolkit();
AgentOperateToolSpec spec = spec(AgentOperateToolType.SHELL);
spec.setShellAllowedCommands(Set.of());
List<AgentToolSpec> toolSpecs = adapter.register(List.of(spec), toolkit);
Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL));
Assert.assertEquals(1, toolSpecs.size());
Assert.assertTrue(toolSpecs.get(0).isApprovalRequired());
Assert.assertNotNull(toolSpecs.get(0).getApprovalPolicy());
Assert.assertEquals(List.of("rm"), toolSpecs.get(0).getMetadata().get("forceApprovalCommands"));
Assert.assertFalse(toolSpecs.get(0).getMetadata().containsKey("baseDir"));
}
@Test
public void shouldDisableAllShellApprovalPoliciesWithAgentSwitch() {
Toolkit toolkit = new Toolkit();
AgentOperateToolSpec spec = spec(AgentOperateToolType.SHELL);
spec.setApprovalRequired(false);
List<AgentToolSpec> toolSpecs = adapter.register(List.of(spec), toolkit);
Assert.assertEquals(1, toolSpecs.size());
Assert.assertFalse(toolSpecs.get(0).isApprovalRequired());
Assert.assertNull(toolSpecs.get(0).getApprovalPolicy());
Assert.assertFalse(toolSpecs.get(0).getMetadata().containsKey("forceApprovalCommands"));
Assert.assertFalse(toolSpecs.get(0).getMetadata().containsKey("forceApprovalCommandArgument"));
}
@Test
public void shouldRegisterPatchWithDefaultHitlDisabled() {
Toolkit toolkit = new Toolkit();
AgentOperateToolSpec spec = spec(AgentOperateToolType.PATCH);
List<AgentToolSpec> toolSpecs = adapter.register(List.of(spec), toolkit);
Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.APPLY_PATCH_TOOL));
Assert.assertEquals(1, toolSpecs.size());
Assert.assertFalse(toolSpecs.get(0).isApprovalRequired());
}
@Test
@@ -95,7 +135,12 @@ public class AgentOperateToolAdapterTest {
private AgentOperateToolSpec spec(AgentOperateToolType type) {
AgentOperateToolSpec spec = new AgentOperateToolSpec();
spec.setType(type);
spec.setBaseDir(System.getProperty("java.io.tmpdir"));
try {
Path workspace = Files.createTempDirectory("operate-tool-adapter-");
spec.setBaseDir(workspace.toAbsolutePath().toString());
} catch (IOException error) {
throw new AssertionError(error);
}
return spec;
}
}

View File

@@ -0,0 +1,142 @@
package com.easyagents.agent.runtime.tool.operate;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.tool.ToolCallParam;
import org.junit.Assert;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
/**
* 测试有界补丁工具。
*/
public class ApplyPatchToolTest {
@Test
public void shouldApplyMultiFileAddUpdateDeletePatch() throws IOException {
Fixture fixture = fixture();
Files.writeString(fixture.root().resolve("existing.md"), "old\n");
Files.writeString(fixture.root().resolve("remove.md"), "remove\n");
String patch = """
*** Begin Patch
*** Update File: existing.md
@@
-old
+new
*** Add File: created.md
+created
*** Delete File: remove.md
*** End Patch""";
ToolResultBlock result = call(fixture.tool(), patch);
Assert.assertTrue(text(result).contains("3 file(s)"));
Assert.assertEquals("new\n", Files.readString(fixture.root().resolve("existing.md")));
Assert.assertEquals("created\n", Files.readString(fixture.root().resolve("created.md")));
Assert.assertFalse(Files.exists(fixture.root().resolve("remove.md")));
}
@Test
public void shouldApplyStandardUnifiedDiff() throws IOException {
Fixture fixture = fixture();
Files.writeString(fixture.root().resolve("standard.txt"), "before\n");
String patch = """
--- a/standard.txt
+++ b/standard.txt
@@ -1 +1 @@
-before
+after
""";
ToolResultBlock result = call(fixture.tool(), patch);
Assert.assertTrue(text(result).contains("successfully"));
Assert.assertEquals("after\n", Files.readString(fixture.root().resolve("standard.txt")));
}
@Test
public void shouldRejectAmbiguousContextWithoutChangingFile() throws IOException {
Fixture fixture = fixture();
Files.writeString(fixture.root().resolve("ambiguous.txt"), "same\nother\nsame\n");
String patch = """
*** Begin Patch
*** Update File: ambiguous.txt
@@
-same
+changed
*** End Patch""";
ToolResultBlock result = call(fixture.tool(), patch);
Assert.assertTrue(text(result).contains("PATCH_CONFLICT"));
Assert.assertEquals("same\nother\nsame\n", Files.readString(fixture.root().resolve("ambiguous.txt")));
}
@Test
public void shouldRejectPathEscapeBeforeChangingAnyFile() throws IOException {
Fixture fixture = fixture();
Files.writeString(fixture.root().resolve("safe.txt"), "safe\n");
String patch = """
*** Begin Patch
*** Update File: safe.txt
@@
-safe
+changed
*** Add File: ../escape.txt
+escape
*** End Patch""";
ToolResultBlock result = call(fixture.tool(), patch);
Assert.assertTrue(text(result).contains("WORKSPACE_PATH_INVALID"));
Assert.assertEquals("safe\n", Files.readString(fixture.root().resolve("safe.txt")));
}
@Test
public void shouldRejectDeleteDiffThatDoesNotMatchCompleteFile() throws IOException {
Fixture fixture = fixture();
Files.writeString(fixture.root().resolve("delete.txt"), "expected\nextra\n");
String patch = """
--- a/delete.txt
+++ /dev/null
@@ -1 +0,0 @@
-expected
""";
ToolResultBlock result = call(fixture.tool(), patch);
Assert.assertTrue(text(result).contains("PATCH_CONFLICT"));
Assert.assertEquals("expected\nextra\n", Files.readString(fixture.root().resolve("delete.txt")));
}
private Fixture fixture() throws IOException {
Path root = Files.createTempDirectory("apply-patch-tool-").toAbsolutePath();
WorkspacePathGuard pathGuard = new WorkspacePathGuard(root);
WorkspaceQuotaGuard quotaGuard = new WorkspaceQuotaGuard(
pathGuard,
new WorkspaceQuotaLimits(1024 * 1024, 1024 * 1024, 100, 1024 * 1024),
WorkspaceQuotaHook.noop());
return new Fixture(root, new ApplyPatchTool(pathGuard, quotaGuard, 1024 * 1024, 10, 1024 * 1024));
}
private ToolResultBlock call(ApplyPatchTool tool, String patch) {
return tool.callAsync(ToolCallParam.builder().input(Map.of("patch", patch)).build()).block();
}
private String text(ToolResultBlock result) {
return ((TextBlock) result.getOutput().get(0)).getText();
}
/**
* Patch 测试夹具。
*
* @param root 工作区根
* @param tool Patch 工具
*/
private record Fixture(Path root, ApplyPatchTool tool) {
}
}

View File

@@ -0,0 +1,347 @@
package com.easyagents.agent.runtime.tool.operate;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalEvaluation;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.tool.ToolCallParam;
import org.junit.Assert;
import org.junit.Assume;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* 测试受控 Shell 策略与执行边界。
*/
public class ControlledShellToolTest {
@Test
public void shouldExecuteAllowlistedCommandWithoutLeakingWorkspaceRoot() throws IOException {
Fixture fixture = fixture();
String result = execute(fixture.tool(), Map.of("command", "pwd"));
Assert.assertTrue(result, result.contains("<returncode>0</returncode>"));
Assert.assertFalse(result.contains(fixture.root().toString()));
Assert.assertTrue(result, result.contains("<stdout truncated=\"false\">.\n</stdout>"));
}
@Test
public void shouldRejectOperatorsExpansionAbsolutePathsAndUnknownCommands() throws IOException {
Fixture fixture = fixture();
assertRejected(fixture.tool(), "pwd | cat");
assertRejected(fixture.tool(), "cat $HOME/secret");
assertRejected(fixture.tool(), "cat /etc/passwd");
assertRejected(fixture.tool(), "curl https://example.com");
assertRejected(fixture.tool(), "pwd\ncat secret");
}
@Test
public void shouldRestrictScriptEntrypointsAndDangerousRemove() throws IOException {
Fixture fixture = fixture();
Files.writeString(fixture.root().resolve("safe.py"), "print('ok')\n");
assertRejected(fixture.tool(), "python3 -c 'print(1)'");
assertRejected(fixture.tool(), "python3 -m http.server");
assertRejected(fixture.tool(), "node --eval '1+1'");
assertRejected(fixture.tool(), "rm -rf .");
assertRejected(fixture.tool(), "rm -r -f output");
assertRejected(fixture.tool(), "rm --recursive --force output");
Assert.assertTrue(execute(fixture.tool(), Map.of("command", "python3 safe.py"))
.contains("<stdout truncated=\"false\">ok"));
}
@Test
public void shouldExposeOnlyFixedArchiveCommandSet() throws IOException {
Fixture fixture = fixture();
assertRejected(fixture.tool(), "tar --checkpoint-action=exec=sh -cf archive.tar input.txt");
assertRejected(fixture.tool(), "zip -TT sh archive.zip input.txt");
Assert.assertTrue(ControlledShellTool.DEFAULT_ALLOWED_COMMANDS.containsAll(
Set.of("gzip", "gunzip", "zip", "unzip", "tar")));
}
@Test
public void shouldClassifyReadOnlyWriteConversionAndScriptApproval() throws IOException {
Fixture fixture = fixture();
Files.writeString(fixture.root().resolve("safe.py"), "print('one')\n");
Files.writeString(fixture.root().resolve("input.pdf"), "%PDF-1.4\n");
Files.writeString(fixture.root().resolve("input.md"), "# title\n");
Files.createDirectory(fixture.root().resolve("output"));
assertApproval(fixture.tool(), "rg --files .", false, false, null);
assertApproval(fixture.tool(), "tree .", false, false, null);
assertApproval(fixture.tool(), "pdftotext input.pdf -", false, false, null);
assertApproval(fixture.tool(), "mkdir generated", true, false, null);
assertApproval(fixture.tool(), "rm generated.txt", true, true, null);
assertApproval(fixture.tool(), "pandoc input.md -o output/report.docx", true, false, null);
assertApproval(fixture.tool(), "soffice --convert-to pdf --outdir output input.md",
true, false, null);
assertApproval(fixture.tool(), "pdftotext input.pdf output/content.txt", true, false, null);
AgentToolApprovalEvaluation first = fixture.tool().approvalEvaluation(
Map.of("command", "python3 safe.py --mode report"));
Assert.assertTrue(first.valid());
Assert.assertTrue(first.approvalRequired());
Assert.assertFalse(first.forced());
Assert.assertTrue(first.reusableScope().startsWith("SHELL_SCRIPT:"));
Files.writeString(fixture.root().resolve("safe.py"), "print('two')\n");
AgentToolApprovalEvaluation changed = fixture.tool().approvalEvaluation(
Map.of("command", "python3 safe.py --mode report"));
Assert.assertNotEquals(first.reusableScope(), changed.reusableScope());
}
@Test
public void shouldRejectUnsafeProductivityCommandOptionsBeforeApproval() throws IOException {
Fixture fixture = fixture();
Files.writeString(fixture.root().resolve("input.md"), "# title\n");
Files.writeString(fixture.root().resolve("input.pdf"), "%PDF-1.4\n");
Files.writeString(fixture.root().resolve("paths.txt"), "input.md\n");
Files.writeString(fixture.root().resolve("args.txt"), "--empty\n");
Files.createDirectory(fixture.root().resolve("output"));
for (String command : new String[]{
"find .",
"tree -l .",
"du --files0-from=paths.txt",
"pandoc input.md --filter cat -o output/report.docx",
"pandoc input.md -ooutput/report.docx",
"pandoc https://example.com -o output/report.docx",
"soffice --accept=socket --convert-to pdf --outdir output input.md",
"pdftoppm /etc/passwd output/page",
"qpdf @args.txt output/result.pdf"}) {
AgentToolApprovalEvaluation evaluation = fixture.tool().approvalEvaluation(
Map.of("command", command));
Assert.assertFalse(command, evaluation.valid());
Assert.assertFalse(command, evaluation.approvalRequired());
}
}
@Test
public void shouldEnforceTimeoutAndOutputLimit() throws IOException {
Fixture fixture = fixture();
Files.writeString(fixture.root().resolve("slow.py"), "import time\ntime.sleep(5)\n");
Files.writeString(fixture.root().resolve("large.py"), "print('x' * 10000)\n");
String timeout = execute(fixture.tool(), Map.of("command", "python3 slow.py", "timeout", 1));
String truncated = execute(fixture.tool(), Map.of("command", "python3 large.py"));
Assert.assertTrue(timeout.contains("SHELL_TIMEOUT"));
Assert.assertTrue(truncated.contains("truncated=\"true\""));
Assert.assertTrue(truncated.contains("OUTPUT_TRUNCATED"));
}
@Test
public void shouldRejectSecondaryExecutionAndIndirectFileOptions() throws IOException {
Fixture fixture = fixture();
Files.writeString(fixture.root().resolve("input.txt"), "alpha\n");
Files.writeString(fixture.root().resolve("input.json"), "{\"value\":1}\n");
Files.writeString(fixture.root().resolve("paths.txt"), "/etc/passwd\n");
for (String command : new String[]{
"awk 'BEGIN { system(\"id\") }' input.txt",
"awk '{ getline value }' input.txt",
"awk '{ print ENVIRON }' input.txt",
"awk -fprogram.awk input.txt",
"awk --profile=profile.txt '{ print }' input.txt",
"sed -e 'e id' input.txt",
"sed '1r input.txt' input.txt",
"sed 's/alpha/beta/w stolen.txt' input.txt",
"sed -i 's/alpha/beta/' input.txt",
"rg --pre cat alpha .",
"rg --pre-glob '*.txt' alpha .",
"rg --hostname-bin pwd alpha .",
"rg -z alpha .",
"rg -L alpha .",
"rg --follow alpha .",
"ls -RL .",
"grep -Rfpatterns.txt alpha .",
"jq 'env' input.json",
"jq '$ENV' input.json",
"jq -Lmodules '.' input.json",
"sort -ooutput.txt input.txt",
"sort --compress-program=cat input.txt",
"uniq input.txt output.txt",
"file -fpaths.txt",
"sha256sum --check paths.txt",
"wc --files0-from=paths.txt",
"tail --follow=name input.txt",
"cp -L input.txt copied.txt",
"cp --symbolic-link input.txt copied.txt",
"cp -l input.txt copied.txt"}) {
assertRejected(fixture.tool(), command);
}
}
@Test
public void shouldBestEffortTerminateObservedChildAfterSuccessfulScript() throws Exception {
Fixture fixture = fixture();
Files.writeString(fixture.root().resolve("spawn.py"), """
import pathlib
import subprocess
import sys
import time
child = subprocess.Popen(
[sys.executable, '-c', 'import time; time.sleep(30)'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True)
pathlib.Path('child.pid').write_text(str(child.pid), encoding='utf-8')
time.sleep(0.3)
""");
ProcessHandle child = null;
try {
String result = execute(fixture.tool(), Map.of("command", "python3 spawn.py"));
Assert.assertTrue(result, result.contains("<returncode>0</returncode>"));
long pid = Long.parseLong(Files.readString(fixture.root().resolve("child.pid")).trim());
Optional<ProcessHandle> handle = ProcessHandle.of(pid);
child = handle.orElse(null);
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
while (child != null && child.isAlive() && System.nanoTime() < deadline) {
Thread.sleep(20);
}
Assert.assertTrue("Observed child process must be terminated", child == null || !child.isAlive());
} finally {
if (child != null && child.isAlive()) {
child.destroyForcibly();
}
}
}
@Test
public void shouldTerminateLinuxProcessGroupAfterFastParentExit() throws Exception {
assumeLinuxWithPython();
Fixture fixture = fixture();
Files.writeString(fixture.root().resolve("fast-spawn.py"), """
import pathlib
import subprocess
import sys
child = subprocess.Popen(
[sys.executable, '-c', 'import time; time.sleep(30)'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
pathlib.Path('fast-child.pid').write_text(str(child.pid), encoding='utf-8')
""");
ProcessHandle child = null;
try {
String result = execute(fixture.tool(), Map.of("command", "python3 fast-spawn.py"));
Assert.assertTrue(result, result.contains("<returncode>0</returncode>"));
child = ProcessHandle.of(readPid(fixture.root().resolve("fast-child.pid"))).orElse(null);
assertTerminates(child, "Linux process-group child must be terminated after parent exit");
} finally {
destroyIfAlive(child);
}
}
@Test
public void shouldTerminateLinuxProcessGroupOnTimeout() throws Exception {
assumeLinuxWithPython();
Fixture fixture = fixture();
Files.writeString(fixture.root().resolve("timeout-spawn.py"), """
import pathlib
import subprocess
import sys
import time
child = subprocess.Popen(
[sys.executable, '-c', 'import time; time.sleep(30)'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
pathlib.Path('timeout-child.pid').write_text(str(child.pid), encoding='utf-8')
time.sleep(30)
""");
ProcessHandle child = null;
try {
String result = execute(fixture.tool(), Map.of(
"command", "python3 timeout-spawn.py", "timeout", 1));
Assert.assertTrue(result, result.contains("SHELL_TIMEOUT"));
child = ProcessHandle.of(readPid(fixture.root().resolve("timeout-child.pid"))).orElse(null);
assertTerminates(child, "Linux process-group child must be terminated on timeout");
} finally {
destroyIfAlive(child);
}
}
private void assumeLinuxWithPython() {
Assume.assumeTrue(System.getProperty("os.name", "").toLowerCase().contains("linux"));
Assume.assumeTrue(Files.isExecutable(Path.of("/usr/bin/python3"))
|| Files.isExecutable(Path.of("/usr/local/bin/python3")));
}
private long readPid(Path path) throws Exception {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
while (!Files.exists(path) && System.nanoTime() < deadline) {
Thread.sleep(10);
}
Assert.assertTrue("Child PID file must be created", Files.exists(path));
return Long.parseLong(Files.readString(path).trim());
}
private void assertTerminates(ProcessHandle child, String message) throws InterruptedException {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
while (child != null && child.isAlive() && System.nanoTime() < deadline) {
Thread.sleep(20);
}
Assert.assertTrue(message, child == null || !child.isAlive());
}
private void destroyIfAlive(ProcessHandle process) {
if (process != null && process.isAlive()) {
process.destroyForcibly();
}
}
private Fixture fixture() throws IOException {
Path root = Files.createTempDirectory("controlled-shell-").toAbsolutePath();
WorkspacePathGuard pathGuard = new WorkspacePathGuard(root);
WorkspaceQuotaGuard quotaGuard = new WorkspaceQuotaGuard(
pathGuard,
new WorkspaceQuotaLimits(1024 * 1024, 1024 * 1024, 100, 1024 * 1024),
WorkspaceQuotaHook.noop());
AgentOperateToolSpec spec = new AgentOperateToolSpec();
spec.setShellDefaultTimeout(Duration.ofSeconds(2));
spec.setShellMaxTimeout(Duration.ofSeconds(3));
spec.setShellMaxOutputSize(256);
return new Fixture(root, new ControlledShellTool(pathGuard, quotaGuard, spec));
}
private String execute(ControlledShellTool tool, Map<String, Object> input) {
ToolResultBlock result = tool.callAsync(ToolCallParam.builder().input(input).build()).block();
return ((TextBlock) result.getOutput().get(0)).getText();
}
private void assertRejected(ControlledShellTool tool, String command) {
String result = execute(tool, Map.of("command", command));
Assert.assertTrue(result, result.contains("SHELL_COMMAND_DENIED")
|| result.contains("WORKSPACE_PATH_INVALID"));
}
private void assertApproval(ControlledShellTool tool,
String command,
boolean required,
boolean forced,
String scope) {
AgentToolApprovalEvaluation evaluation = tool.approvalEvaluation(Map.of("command", command));
Assert.assertTrue(command, evaluation.valid());
Assert.assertEquals(command, required, evaluation.approvalRequired());
Assert.assertEquals(command, forced, evaluation.forced());
Assert.assertEquals(command, scope, evaluation.reusableScope());
}
/**
* Shell 测试夹具。
*
* @param root 工作区根
* @param tool Shell 工具
*/
private record Fixture(Path root, ControlledShellTool tool) {
}
}

View File

@@ -0,0 +1,228 @@
package com.easyagents.agent.runtime.tool.operate;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.tool.ToolCallParam;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
import org.junit.Assert;
import org.junit.Test;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Map;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* 测试 Java 安全归档执行器的创建、展开与恶意条目边界。
*/
public class SafeArchiveCommandExecutorTest {
@Test
public void shouldCreateAndExtractGzipZipAndTarArchives() throws IOException {
Fixture fixture = fixture(new WorkspaceQuotaLimits(16 * 1024 * 1024L,
8 * 1024 * 1024L, 1000, 1024 * 1024));
Files.createDirectories(fixture.root().resolve("source/nested"));
Files.writeString(fixture.root().resolve("source/nested/value.txt"), "archive-value\n");
Files.writeString(fixture.root().resolve("plain.txt"), "plain-value\n");
Files.writeString(fixture.root().resolve("multi-a.txt"), "a\n");
Files.writeString(fixture.root().resolve("multi-b.txt"), "b\n");
assertSuccess(fixture, "gzip -k plain.txt");
Files.delete(fixture.root().resolve("plain.txt"));
assertSuccess(fixture, "gunzip -k plain.txt.gz");
Assert.assertEquals("plain-value\n", Files.readString(fixture.root().resolve("plain.txt")));
assertSuccess(fixture, "gzip multi-a.txt multi-b.txt");
Assert.assertFalse(Files.exists(fixture.root().resolve("multi-a.txt")));
Assert.assertFalse(Files.exists(fixture.root().resolve("multi-b.txt")));
assertSuccess(fixture, "gunzip multi-a.txt.gz multi-b.txt.gz");
Assert.assertEquals("a\n", Files.readString(fixture.root().resolve("multi-a.txt")));
Assert.assertEquals("b\n", Files.readString(fixture.root().resolve("multi-b.txt")));
assertSuccess(fixture, "zip -q -r bundle.zip source");
assertSuccess(fixture, "unzip -q bundle.zip -d zip-output");
Assert.assertEquals("archive-value\n",
Files.readString(fixture.root().resolve("zip-output/source/nested/value.txt")));
assertSuccess(fixture, "tar -czf bundle.tar.gz source");
String listed = execute(fixture, "tar -tzf bundle.tar.gz");
Assert.assertTrue(listed, listed.contains("source/nested/value.txt"));
Assert.assertFalse(listed.contains(fixture.root().toString()));
assertSuccess(fixture, "tar -xzf bundle.tar.gz -C tar-output");
Assert.assertEquals("archive-value\n",
Files.readString(fixture.root().resolve("tar-output/source/nested/value.txt")));
}
@Test
public void shouldRejectZipSlipDuplicateAndTargetConflictWithoutPartialOutput() throws IOException {
Fixture fixture = fixture(new WorkspaceQuotaLimits(8 * 1024 * 1024L,
4 * 1024 * 1024L, 1000, 1024 * 1024));
createZip(fixture.root().resolve("slip.zip"),
new ZipContent("../escape.txt", "escape"));
createZip(fixture.root().resolve("duplicate.zip"),
new ZipContent("same.txt", "first"), new ZipContent("same.txt", "second"));
createZip(fixture.root().resolve("backslash.zip"),
new ZipContent("..\\escape.txt", "escape"));
createZip(fixture.root().resolve("conflict.zip"),
new ZipContent("first.txt", "first"), new ZipContent("second.txt", "second"));
Files.createDirectories(fixture.root().resolve("output"));
Files.writeString(fixture.root().resolve("output/second.txt"), "existing");
Assert.assertTrue(execute(fixture, "unzip slip.zip -d slip-output")
.contains("ARCHIVE_ENTRY_DENIED"));
Assert.assertFalse(Files.exists(fixture.root().getParent().resolve("escape.txt")));
Assert.assertTrue(execute(fixture, "unzip duplicate.zip -d duplicate-output")
.contains("ARCHIVE_DUPLICATE_ENTRY"));
Assert.assertTrue(execute(fixture, "unzip backslash.zip -d backslash-output")
.contains("ARCHIVE_ENTRY_DENIED"));
Assert.assertTrue(execute(fixture, "unzip conflict.zip -d output")
.contains("ARCHIVE_TARGET_CONFLICT"));
Assert.assertFalse(Files.exists(fixture.root().resolve("output/first.txt")));
Assert.assertEquals("existing", Files.readString(fixture.root().resolve("output/second.txt")));
}
@Test
public void shouldRejectTarLinksDevicesAndFifoEntries() throws IOException {
Fixture fixture = fixture(new WorkspaceQuotaLimits(8 * 1024 * 1024L,
4 * 1024 * 1024L, 1000, 1024 * 1024));
createTarSpecial(fixture.root().resolve("link.tar"), "link", (byte) '2');
createTarSpecial(fixture.root().resolve("hard-link.tar"), "hard-link", (byte) '1');
createTarSpecial(fixture.root().resolve("device.tar"), "device", (byte) '3');
createTarSpecial(fixture.root().resolve("fifo.tar"), "fifo", (byte) '6');
Assert.assertTrue(execute(fixture, "tar -xf link.tar").contains("ARCHIVE_ENTRY_DENIED"));
Assert.assertTrue(execute(fixture, "tar -xf hard-link.tar").contains("ARCHIVE_ENTRY_DENIED"));
Assert.assertTrue(execute(fixture, "tar -xf device.tar").contains("ARCHIVE_ENTRY_DENIED"));
Assert.assertTrue(execute(fixture, "tar -xf fifo.tar").contains("ARCHIVE_ENTRY_DENIED"));
Assert.assertFalse(Files.exists(fixture.root().resolve("link")));
Assert.assertFalse(Files.exists(fixture.root().resolve("device")));
Assert.assertFalse(Files.exists(fixture.root().resolve("fifo")));
}
@Test
public void shouldRejectExpandedArchiveBeyondQuota() throws IOException {
Fixture fixture = fixture(new WorkspaceQuotaLimits(512, 8 * 1024, 100, 1024));
createZip(fixture.root().resolve("bomb.zip"),
new ZipContent("expanded.txt", "0".repeat(4096)));
String output = execute(fixture, "unzip bomb.zip -d expanded");
Assert.assertTrue(output, output.contains("WORKSPACE_QUOTA_EXCEEDED"));
Assert.assertFalse(Files.exists(fixture.root().resolve("expanded")));
}
@Test
public void shouldRejectUnknownArchiveOptions() throws IOException {
Fixture fixture = fixture(new WorkspaceQuotaLimits(1024 * 1024, 1024 * 1024, 100, 1024));
Files.writeString(fixture.root().resolve("input.txt"), "input");
for (String command : new String[]{
"gzip -c input.txt",
"zip -T bundle.zip input.txt",
"unzip -o bundle.zip",
"tar --checkpoint-action=exec=id -cf bundle.tar input.txt",
"tar -xf bundle.tar member.txt"}) {
String output = execute(fixture, command);
Assert.assertTrue(command + " => " + output, output.contains("SHELL_COMMAND_DENIED"));
}
}
@Test
public void shouldCompensateOutputsWhenCommitFailsAfterFirstMove() throws IOException {
Path root = Files.createTempDirectory("safe-archive-compensation-").toAbsolutePath();
createZip(root.resolve("two-files.zip"),
new ZipContent("first.txt", "first"), new ZipContent("second.txt", "second"));
WorkspacePathGuard pathGuard = new WorkspacePathGuard(root);
WorkspaceQuotaGuard quotaGuard = new WorkspaceQuotaGuard(
pathGuard,
new WorkspaceQuotaLimits(1024 * 1024, 1024 * 1024, 100, 1024),
WorkspaceQuotaHook.noop());
SafeArchiveCommandExecutor executor = new SafeArchiveCommandExecutor(
pathGuard, quotaGuard, 1024, committedCount -> {
if (committedCount == 1) {
throw new IllegalStateException("injected commit failure");
}
});
try {
executor.execute(List.of("unzip", "two-files.zip", "-d", "output"),
System.nanoTime() + TimeUnit.SECONDS.toNanos(5));
Assert.fail("Expected archive commit failure");
} catch (WorkspaceToolException expected) {
Assert.assertEquals("ARCHIVE_COMMIT_FAILED", expected.code());
}
Assert.assertFalse(Files.exists(root.resolve("output/first.txt")));
Assert.assertFalse(Files.exists(root.resolve("output/second.txt")));
Assert.assertFalse(Files.exists(root.resolve("output")));
}
private Fixture fixture(WorkspaceQuotaLimits limits) throws IOException {
Path root = Files.createTempDirectory("safe-archive-").toAbsolutePath();
WorkspacePathGuard pathGuard = new WorkspacePathGuard(root);
WorkspaceQuotaGuard quotaGuard = new WorkspaceQuotaGuard(pathGuard, limits, WorkspaceQuotaHook.noop());
AgentOperateToolSpec spec = new AgentOperateToolSpec();
spec.setShellDefaultTimeout(Duration.ofSeconds(5));
spec.setShellMaxTimeout(Duration.ofSeconds(10));
return new Fixture(root, new ControlledShellTool(pathGuard, quotaGuard, spec));
}
private void assertSuccess(Fixture fixture, String command) {
String output = execute(fixture, command);
Assert.assertTrue(command + " => " + output, output.contains("<returncode>0</returncode>"));
Assert.assertFalse(output.contains(fixture.root().toString()));
}
private String execute(Fixture fixture, String command) {
ToolResultBlock result = fixture.tool().callAsync(ToolCallParam.builder()
.input(Map.of("command", command)).build()).block();
return ((TextBlock) result.getOutput().get(0)).getText();
}
private void createZip(Path target, ZipContent... contents) throws IOException {
try (ZipArchiveOutputStream zip = new ZipArchiveOutputStream(target)) {
for (ZipContent content : contents) {
byte[] bytes = content.content().getBytes(StandardCharsets.UTF_8);
ZipArchiveEntry entry = new ZipArchiveEntry(content.name());
entry.setSize(bytes.length);
zip.putArchiveEntry(entry);
zip.write(bytes);
zip.closeArchiveEntry();
}
zip.finish();
}
}
private void createTarSpecial(Path target, String name, byte linkFlag) throws IOException {
try (OutputStream raw = Files.newOutputStream(target);
TarArchiveOutputStream tar = new TarArchiveOutputStream(raw)) {
TarArchiveEntry entry = new TarArchiveEntry(name, linkFlag);
entry.setSize(0);
if (linkFlag == '2') {
entry.setLinkName("../outside");
}
tar.putArchiveEntry(entry);
tar.closeArchiveEntry();
tar.finish();
}
}
private record ZipContent(String name, String content) {
}
/**
* 归档测试夹具。
*
* @param root 工作区根
* @param tool Shell 工具
*/
private record Fixture(Path root, ControlledShellTool tool) {
}
}

View File

@@ -0,0 +1,35 @@
package com.easyagents.agent.runtime.tool.operate;
import org.junit.Assert;
import org.junit.Test;
import java.nio.file.Path;
import java.util.List;
/**
* 测试 Linux 独立进程组能力的启动检查与非 Linux 降级。
*/
public class ShellProcessGroupSupportTest {
@Test
public void shouldFailFastWhenLinuxProcessGroupDependenciesAreMissing() {
try {
ShellProcessGroupSupport.detect("Linux", null, null);
Assert.fail("Expected missing dependency failure");
} catch (WorkspaceToolException expected) {
Assert.assertEquals("WORKSPACE_CONFIG_INVALID", expected.code());
Assert.assertFalse(expected.retryable());
}
}
@Test
public void shouldUsePortableFallbackOutsideLinux() {
ShellProcessGroupSupport support = ShellProcessGroupSupport.detect(
"Mac OS X", Path.of("/missing/setsid"), Path.of("/missing/kill"));
List<String> command = List.of("pwd");
Assert.assertFalse(support.enabled());
Assert.assertSame(command, support.wrap(command));
support.terminate(-1);
}
}

View File

@@ -0,0 +1,188 @@
package com.easyagents.agent.runtime.tool.operate;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.ToolCallParam;
import org.junit.Assert;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 测试安全读写工具与工作区配额。
*/
public class WorkspaceFileToolsTest {
@Test
public void shouldWriteAtomicallyAndReadOnlyRequestedRange() throws IOException {
Fixture fixture = fixture(1024, 1024, 10, 1024);
AgentTool write = new SafeWriteFileTool(fixture.pathGuard(), fixture.quotaGuard()).writeTextFileTool();
AgentTool read = new SafeReadFileTool(fixture.pathGuard(), fixture.quotaGuard()).viewTextFileTool();
ToolResultBlock writeResult = call(write, Map.of(
"file_path", "notes/example.txt",
"content", "first\nsecond\nthird\nfourth\n"));
ToolResultBlock readResult = call(read, Map.of(
"file_path", "notes/example.txt",
"ranges", "2,3"));
Assert.assertTrue(text(writeResult).contains("successfully"));
Assert.assertTrue(text(readResult).contains("2: second"));
Assert.assertTrue(text(readResult).contains("3: third"));
Assert.assertFalse(text(readResult).contains("1: first"));
Assert.assertFalse(text(readResult).contains(fixture.root().toString()));
try (var files = Files.list(fixture.root().resolve("notes"))) {
Assert.assertTrue(files.noneMatch(path -> path.getFileName().toString().startsWith(".easyagents-write-")));
}
}
@Test
public void shouldRejectWriteBeyondQuotaWithoutPartialFile() throws IOException {
Fixture fixture = fixture(5, 5, 1, 5);
AgentTool write = new SafeWriteFileTool(fixture.pathGuard(), fixture.quotaGuard()).writeTextFileTool();
ToolResultBlock result = call(write, Map.of("file_path", "too-large.txt", "content", "123456"));
Assert.assertTrue(text(result).contains("WORKSPACE_QUOTA_EXCEEDED"));
Assert.assertFalse(Files.exists(fixture.root().resolve("too-large.txt")));
}
@Test
public void shouldCountCreatedDirectoriesAgainstEntryQuota() throws IOException {
Fixture fixture = fixture(1024, 1024, 1, 1024);
AgentTool write = new SafeWriteFileTool(fixture.pathGuard(), fixture.quotaGuard()).writeTextFileTool();
String output = text(call(write, Map.of("file_path", "nested/value.txt", "content", "ok")));
Assert.assertTrue(output, output.contains("WORKSPACE_QUOTA_EXCEEDED"));
Assert.assertFalse(Files.exists(fixture.root().resolve("nested")));
}
@Test
public void shouldInvokeConfiguredQuotaHook() throws IOException {
Path root = Files.createTempDirectory("workspace-hook-").toAbsolutePath();
WorkspacePathGuard pathGuard = new WorkspacePathGuard(root);
AtomicInteger writes = new AtomicInteger();
WorkspaceQuotaHook hook = new WorkspaceQuotaHook() {
@Override
public void beforeRead(Path workspaceRoot, Path target, long requestedBytes) {
}
@Override
public void beforeWrite(Path workspaceRoot, Path target, long previousBytes, long resultingBytes) {
writes.incrementAndGet();
}
};
WorkspaceQuotaGuard quotaGuard = new WorkspaceQuotaGuard(
pathGuard, new WorkspaceQuotaLimits(1024, 1024, 10, 1024), hook);
AgentTool write = new SafeWriteFileTool(pathGuard, quotaGuard).writeTextFileTool();
call(write, Map.of("file_path", "hook.txt", "content", "ok"));
Assert.assertEquals(1, writes.get());
}
@Test
public void shouldRejectDirectoryListingWhenWorkspaceContainsSymlink() throws IOException {
Fixture fixture = fixture(1024, 1024, 10, 1024);
Files.writeString(fixture.root().resolve("safe.txt"), "safe");
Path outside = Files.createTempFile("workspace-list-outside-", ".txt");
try {
Files.createSymbolicLink(fixture.root().resolve("blocked"), outside);
} catch (UnsupportedOperationException error) {
return;
}
AgentTool list = new SafeReadFileTool(fixture.pathGuard(), fixture.quotaGuard()).listDirectoryTool();
String output = text(call(list, Map.of("dir_path", ".")));
Assert.assertTrue(output, output.contains("WORKSPACE_PATH_INVALID"));
Assert.assertFalse(output.contains(fixture.root().toString()));
Assert.assertFalse(output.contains(outside.toString()));
}
@Test
public void shouldReadSmallRangeFromFileLargerThanFullReadLimit() throws IOException {
Fixture fixture = fixture(64 * 1024, 64 * 1024, 10, 16);
Files.writeString(fixture.root().resolve("large.txt"), "first\n" + "x".repeat(4096) + "\n");
AgentTool read = new SafeReadFileTool(fixture.pathGuard(), fixture.quotaGuard()).viewTextFileTool();
String output = text(call(read, Map.of("file_path", "large.txt", "ranges", "1,1")));
Assert.assertTrue(output, output.contains("1: first"));
Assert.assertFalse(output.contains("WORKSPACE_QUOTA_EXCEEDED"));
}
@Test
public void shouldBoundDirectoryListingAndReportTruncation() throws IOException {
Fixture fixture = fixture(64 * 1024, 64 * 1024, 0, 1024);
for (int index = 0; index < 1001; index++) {
Files.writeString(fixture.root().resolve("entry-" + index + ".txt"), "x");
}
AgentTool list = new SafeReadFileTool(fixture.pathGuard(), fixture.quotaGuard()).listDirectoryTool();
String output = text(call(list, Map.of("dir_path", ".")));
Assert.assertTrue(output, output.contains("Truncated: true; limit=1000"));
Assert.assertEquals(1000, output.lines().filter(line -> line.startsWith("file\t")).count());
}
@Test
public void shouldRejectDirectoryListingBeforeSortingOverQuotaWorkspace() throws IOException {
Fixture fixture = fixture(64 * 1024, 64 * 1024, 3, 1024);
for (int index = 0; index < 4; index++) {
Files.createDirectory(fixture.root().resolve("directory-" + index));
}
AgentTool list = new SafeReadFileTool(fixture.pathGuard(), fixture.quotaGuard()).listDirectoryTool();
String output = text(call(list, Map.of("dir_path", ".")));
Assert.assertTrue(output, output.contains("WORKSPACE_QUOTA_EXCEEDED"));
}
@Test
public void shouldReturnStableReadErrorCodes() throws IOException {
Fixture fixture = fixture(1024, 1024, 10, 1024);
Files.createDirectory(fixture.root().resolve("folder"));
Files.writeString(fixture.root().resolve("valid.txt"), "ok\n");
AgentTool read = new SafeReadFileTool(fixture.pathGuard(), fixture.quotaGuard()).viewTextFileTool();
Assert.assertTrue(text(call(read, Map.of("file_path", "missing.txt"))).contains("FILE_NOT_FOUND"));
Assert.assertTrue(text(call(read, Map.of("file_path", "folder"))).contains("FILE_TYPE_INVALID"));
Assert.assertTrue(text(call(read, Map.of("file_path", "../outside")))
.contains("WORKSPACE_PATH_INVALID"));
Assert.assertTrue(text(call(read, Map.of("file_path", "valid.txt", "ranges", "x,y")))
.contains("INVALID_ARGUMENT"));
}
private Fixture fixture(long total, long single, long count, long read) throws IOException {
Path root = Files.createTempDirectory("workspace-file-tool-").toAbsolutePath();
WorkspacePathGuard pathGuard = new WorkspacePathGuard(root);
WorkspaceQuotaGuard quotaGuard = new WorkspaceQuotaGuard(
pathGuard, new WorkspaceQuotaLimits(total, single, count, read), WorkspaceQuotaHook.noop());
return new Fixture(root, pathGuard, quotaGuard);
}
private ToolResultBlock call(AgentTool tool, Map<String, Object> input) {
return tool.callAsync(ToolCallParam.builder().input(input).build()).block();
}
private String text(ToolResultBlock result) {
return ((TextBlock) result.getOutput().get(0)).getText();
}
/**
* 测试工具夹具。
*
* @param root 工作区根
* @param pathGuard 路径保护器
* @param quotaGuard 配额保护器
*/
private record Fixture(Path root, WorkspacePathGuard pathGuard, WorkspaceQuotaGuard quotaGuard) {
}
}

View File

@@ -0,0 +1,121 @@
package com.easyagents.agent.runtime.tool.operate;
import com.easyagents.agent.runtime.AgentRuntimeException;
import org.junit.Assert;
import org.junit.Assume;
import org.junit.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* 测试工作区路径边界。
*/
public class WorkspacePathGuardTest {
@Test
public void shouldRejectAbsoluteTraversalAndTildePaths() throws IOException {
WorkspacePathGuard guard = guard();
assertRejectedWithout(() -> guard.resolveForWrite("/etc/passwd"), "/etc/passwd");
assertRejected(() -> guard.resolveForWrite("../escape.txt"));
assertRejected(() -> guard.resolveForWrite("~/secret.txt"));
assertRejected(() -> guard.resolveForWrite("C:\\Windows\\system.ini"));
}
@Test
public void shouldRejectSymbolicLinkEscape() throws IOException {
Path root = Files.createTempDirectory("workspace-path-");
Path outside = Files.createTempDirectory("workspace-outside-");
try {
Files.createSymbolicLink(root.resolve("link"), outside);
} catch (UnsupportedOperationException error) {
Assume.assumeNoException(error);
}
WorkspacePathGuard guard = new WorkspacePathGuard(root.toAbsolutePath());
assertRejected(() -> guard.resolveForWrite("link/secret.txt"));
}
@Test
public void shouldRejectHardLinkedFileOnUnix() throws IOException {
Assume.assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("unix"));
Path root = Files.createTempDirectory("workspace-hardlink-");
Files.writeString(root.resolve("source.txt"), "secret");
Files.createLink(root.resolve("alias.txt"), root.resolve("source.txt"));
WorkspacePathGuard guard = new WorkspacePathGuard(root.toAbsolutePath());
assertRejected(() -> guard.resolveExistingFile("alias.txt"));
}
@Test
public void shouldRejectSymlinkReplacementBetweenResolveAndAtomicCommit() throws IOException {
Path root = Files.createTempDirectory("workspace-replacement-");
Path outside = Files.createTempFile("workspace-replacement-outside-", ".txt");
Files.writeString(outside, "outside");
WorkspacePathGuard guard = new WorkspacePathGuard(root.toAbsolutePath());
Path target = guard.resolveForWrite("target.txt");
try {
Files.createSymbolicLink(target, outside);
} catch (UnsupportedOperationException error) {
Assume.assumeNoException(error);
}
assertRejected(() -> WorkspaceTextFiles.atomicWrite(
guard, target, "changed".getBytes(StandardCharsets.UTF_8)));
Assert.assertEquals("outside", Files.readString(outside));
}
@Test
public void shouldRejectHardLinkReplacementBetweenResolveAndAtomicCommitOnUnix() throws IOException {
Assume.assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("unix"));
Path root = Files.createTempDirectory("workspace-hardlink-replacement-");
Path outside = Files.createTempFile("workspace-hardlink-outside-", ".txt");
Files.writeString(outside, "outside");
WorkspacePathGuard guard = new WorkspacePathGuard(root.toAbsolutePath());
Path target = guard.resolveForWrite("target.txt");
Files.createLink(target, outside);
assertRejected(() -> WorkspaceTextFiles.atomicWrite(
guard, target, "changed".getBytes(StandardCharsets.UTF_8)));
Assert.assertEquals("outside", Files.readString(outside));
}
@Test
public void shouldDisplayOnlyRelativePath() throws IOException {
Path root = Files.createTempDirectory("workspace-display-");
WorkspacePathGuard guard = new WorkspacePathGuard(root.toAbsolutePath());
Path target = guard.resolveForWrite("output/report.txt");
Assert.assertEquals(".", guard.display(root.toRealPath()));
Assert.assertEquals("output/report.txt", guard.display(target));
Assert.assertFalse(guard.display(target).contains(root.toString()));
}
private WorkspacePathGuard guard() throws IOException {
return new WorkspacePathGuard(Files.createTempDirectory("workspace-path-").toAbsolutePath());
}
private void assertRejected(Runnable action) {
try {
action.run();
Assert.fail("Expected AgentRuntimeException");
} catch (AgentRuntimeException expected) {
Assert.assertFalse(expected.getMessage().contains("/Users/"));
}
}
private void assertRejectedWithout(Runnable action, String forbiddenText) {
try {
action.run();
Assert.fail("Expected AgentRuntimeException");
} catch (AgentRuntimeException expected) {
Assert.assertFalse(expected.getMessage().contains(forbiddenText));
}
}
}

36
easy-agents-agui/pom.xml Normal file
View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents</artifactId>
<version>${revision}</version>
</parent>
<name>easy-agents-agui</name>
<artifactId>easy-agents-agui</artifactId>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-agent-runtime</artifactId>
</dependency>
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-extensions-agui</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,84 @@
package com.easyagents.agui;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import io.agentscope.core.agui.model.AguiMessage;
import java.util.List;
import java.util.Objects;
/**
* AgentScope 1.0.12 尚未提供的 AG-UI 标准线级事件。
*
* <p>该补充层只覆盖当前官方扩展缺失的标准事件,不复制 AG-UI 事件枚举。</p>
*/
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = AguiExtendedEvent.RunError.class, name = "RUN_ERROR"),
@JsonSubTypes.Type(value = AguiExtendedEvent.MessagesSnapshot.class, name = "MESSAGES_SNAPSHOT")
})
public sealed interface AguiExtendedEvent
permits AguiExtendedEvent.RunError, AguiExtendedEvent.MessagesSnapshot {
/**
* 运行失败事件。
*
* @param threadId AG-UI 线程 ID
* @param runId AG-UI 运行 ID
* @param message 可安全展示的错误消息
* @param code 稳定错误码
*/
record RunError(String threadId, String runId, String message, String code)
implements AguiExtendedEvent {
/**
* 创建运行失败事件。
*
* @param threadId AG-UI 线程 ID
* @param runId AG-UI 运行 ID
* @param message 可安全展示的错误消息
* @param code 稳定错误码
*/
@JsonCreator
public RunError(
@JsonProperty("threadId") String threadId,
@JsonProperty("runId") String runId,
@JsonProperty("message") String message,
@JsonProperty("code") String code) {
this.threadId = Objects.requireNonNull(threadId, "threadId cannot be null");
this.runId = Objects.requireNonNull(runId, "runId cannot be null");
this.message = Objects.requireNonNull(message, "message cannot be null");
this.code = code;
}
}
/**
* 消息全量快照事件。
*
* @param threadId AG-UI 线程 ID
* @param runId AG-UI 运行 ID
* @param messages 消息快照
*/
record MessagesSnapshot(String threadId, String runId, List<AguiMessage> messages)
implements AguiExtendedEvent {
/**
* 创建消息全量快照事件。
*
* @param threadId AG-UI 线程 ID
* @param runId AG-UI 运行 ID
* @param messages 消息快照
*/
@JsonCreator
public MessagesSnapshot(
@JsonProperty("threadId") String threadId,
@JsonProperty("runId") String runId,
@JsonProperty("messages") List<AguiMessage> messages) {
this.threadId = Objects.requireNonNull(threadId, "threadId cannot be null");
this.runId = Objects.requireNonNull(runId, "runId cannot be null");
this.messages = messages == null ? List.of() : List.copyOf(messages);
}
}
}

View File

@@ -0,0 +1,39 @@
package com.easyagents.agui;
import io.agentscope.core.agui.AguiException;
import io.agentscope.core.util.JsonException;
import io.agentscope.core.util.JsonUtils;
/**
* 将 AG-UI 事件编码为 JSON 或 SSE 数据帧。
*
* <p>编码器无可变状态,可安全跨请求复用。</p>
*/
public final class AguiProtocolEventEncoder {
/**
* 编码为 JSON。
*
* @param event AG-UI 官方事件或补充标准事件
* @return JSON 文本
* @throws AguiException.EncodingException 序列化失败时抛出
*/
public String encodeToJson(Object event) {
try {
return JsonUtils.getJsonCodec().toJson(event);
} catch (JsonException exception) {
throw new AguiException.EncodingException("Failed to encode AG-UI event", exception);
}
}
/**
* 编码为 SSE data 帧。
*
* @param event AG-UI 官方事件或补充标准事件
* @return 完整 SSE data 帧
* @throws AguiException.EncodingException 序列化失败时抛出
*/
public String encode(Object event) {
return "data: " + encodeToJson(event) + "\n\n";
}
}

View File

@@ -0,0 +1,249 @@
package com.easyagents.agui;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import io.agentscope.core.agui.event.AguiEvent;
import io.agentscope.core.util.JsonException;
import io.agentscope.core.util.JsonUtils;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* 将 Easy-Agents 中立运行时事件有序投影为 AG-UI 事件。
*
* <p>实例绑定单个 run 且非线程安全。调用方应按运行顺序串行调用 {@link #project(AgentRuntimeEvent)}。</p>
*/
public final class AguiRuntimeEventProjector {
private static final String DEFAULT_ERROR_MESSAGE = "Agent runtime failed.";
/** AG-UI thread ID。 */
private final String threadId;
/** AG-UI run ID。 */
private final String runId;
/** 已开始但尚未收到结果的工具调用。 */
private final Set<String> knownToolCallIds = new LinkedHashSet<>();
private boolean runStarted;
private boolean terminated;
private String openMessageId;
private String openReasoningMessageId;
private long generatedMessageSequence;
/**
* 创建不含业务 Custom Event 的投影器。
*
* @param threadId AG-UI thread ID
* @param runId AG-UI run ID
*/
public AguiRuntimeEventProjector(String threadId, String runId) {
this.threadId = requireText(threadId, "threadId");
this.runId = requireText(runId, "runId");
}
/**
* 按输入顺序投影一条运行时事件。
*
* @param event Easy-Agents 运行时事件
* @return 零到多条 AG-UI 官方或补充标准事件
*/
public List<Object> project(AgentRuntimeEvent event) {
if (event == null || event.getEventType() == null || terminated) {
return List.of();
}
List<Object> output = new ArrayList<>();
switch (event.getEventType()) {
case STARTED -> startRun(output);
case MESSAGE_DELTA -> projectMessageDelta(event, output);
case REASONING_STARTED -> startReasoning(event, output);
case REASONING_DELTA -> projectReasoningDelta(event, output);
case REASONING_COMPLETED -> closeReasoning(output);
case TOOL_CALL -> projectToolCall(event, output);
case TOOL_RESULT -> projectToolResult(event, output);
case COMPLETED -> finishSuccessfully(output);
case FAILED -> finishWithError(event, "AGENT_RUNTIME_FAILED", output);
case CANCELLED -> finishWithError(event, "RUN_CANCELLED", output);
default -> {
// EasyFlow 等上层业务扩展由各自协议边界映射为 CUSTOM通用模块保持业务无关。
}
}
return List.copyOf(output);
}
/**
* 判断当前投影是否已经产生协议终态。
*
* @return 已产生 RUN_FINISHED 或 RUN_ERROR 时为 true
*/
public boolean isTerminated() {
return terminated;
}
private void startRun(List<Object> output) {
if (!runStarted) {
output.add(new AguiEvent.RunStarted(threadId, runId));
runStarted = true;
}
}
private void projectMessageDelta(AgentRuntimeEvent event, List<Object> output) {
startRun(output);
String messageId = eventMessageId(event, "assistant");
if (!Objects.equals(openMessageId, messageId)) {
closeMessage(output);
output.add(new AguiEvent.TextMessageStart(threadId, runId, messageId, "assistant"));
openMessageId = messageId;
}
String delta = stringValue(event.getPayload(), "text");
if (!delta.isEmpty()) {
output.add(new AguiEvent.TextMessageContent(threadId, runId, messageId, delta));
}
}
private void startReasoning(AgentRuntimeEvent event, List<Object> output) {
startRun(output);
if (openReasoningMessageId != null) {
return;
}
openReasoningMessageId = eventMessageId(event, "reasoning");
output.add(new AguiEvent.ReasoningMessageStart(
threadId, runId, openReasoningMessageId, "reasoning"));
}
private void projectReasoningDelta(AgentRuntimeEvent event, List<Object> output) {
startReasoning(event, output);
String delta = stringValue(event.getPayload(), "reasoning");
if (!delta.isEmpty()) {
output.add(new AguiEvent.ReasoningMessageContent(
threadId, runId, openReasoningMessageId, delta));
}
}
private void projectToolCall(AgentRuntimeEvent event, List<Object> output) {
startRun(output);
String toolCallId = firstText(event.getToolCallId(), stringValue(event.getPayload(), "toolCallId"));
if (toolCallId == null || !knownToolCallIds.add(toolCallId)) {
return;
}
String toolName = firstText(
stringValue(event.getPayload(), "toolName"),
stringValue(event.getPayload(), "name"),
"tool");
output.add(new AguiEvent.ToolCallStart(threadId, runId, toolCallId, toolName));
output.add(new AguiEvent.ToolCallArgs(
threadId, runId, toolCallId, jsonValue(event.getPayload().get("input"))));
output.add(new AguiEvent.ToolCallEnd(threadId, runId, toolCallId));
}
private void projectToolResult(AgentRuntimeEvent event, List<Object> output) {
startRun(output);
String toolCallId = firstText(event.getToolCallId(), stringValue(event.getPayload(), "toolCallId"));
if (toolCallId == null || !knownToolCallIds.contains(toolCallId)) {
return;
}
String messageId = eventMessageId(event, "tool-" + toolCallId);
String content = nullToEmpty(firstText(
stringValue(event.getPayload(), "text"),
event.getPayload().containsKey("result")
? jsonValue(event.getPayload().get("result"))
: null));
output.add(new AguiEvent.ToolCallResult(
threadId, runId, toolCallId, content, "tool", messageId));
}
private void finishSuccessfully(List<Object> output) {
startRun(output);
closeOpenFragments(output);
output.add(new AguiEvent.RunFinished(threadId, runId));
terminated = true;
}
private void finishWithError(AgentRuntimeEvent event, String code, List<Object> output) {
startRun(output);
closeOpenFragments(output);
String message = firstText(
stringValue(event.getPayload(), "message"),
stringValue(event.getPayload(), "reason"),
DEFAULT_ERROR_MESSAGE);
output.add(new AguiExtendedEvent.RunError(threadId, runId, message, code));
terminated = true;
}
private void closeOpenFragments(List<Object> output) {
closeReasoning(output);
closeMessage(output);
}
private void closeMessage(List<Object> output) {
if (openMessageId != null) {
output.add(new AguiEvent.TextMessageEnd(threadId, runId, openMessageId));
openMessageId = null;
}
}
private void closeReasoning(List<Object> output) {
if (openReasoningMessageId != null) {
output.add(new AguiEvent.ReasoningMessageEnd(
threadId, runId, openReasoningMessageId));
openReasoningMessageId = null;
}
}
private String eventMessageId(AgentRuntimeEvent event, String suffix) {
String messageId = firstText(
event.getMessageId(),
event.getMessage() == null ? null : event.getMessage().getMessageId());
if (messageId != null) {
return messageId;
}
generatedMessageSequence++;
return runId + "-" + suffix + "-" + generatedMessageSequence;
}
private static String stringValue(Map<String, Object> payload, String key) {
if (payload == null) {
return "";
}
Object value = payload.get(key);
return value instanceof String text ? text : "";
}
private static String jsonValue(Object value) {
if (value == null) {
return "{}";
}
if (value instanceof String text) {
return text;
}
try {
return JsonUtils.getJsonCodec().toJson(value);
} catch (JsonException exception) {
throw new IllegalArgumentException("Failed to encode AG-UI payload", exception);
}
}
private static String firstText(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
private static String requireText(String value, String name) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " cannot be blank");
}
return value;
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
}

View File

@@ -0,0 +1,43 @@
package com.easyagents.agui;
import io.agentscope.core.agui.event.AguiEvent;
import io.agentscope.core.agui.model.AguiMessage;
import org.junit.Assert;
import org.junit.Test;
import java.util.List;
/**
* {@link AguiProtocolEventEncoder} 的线级协议测试。
*/
public class AguiProtocolEventEncoderTest {
private final AguiProtocolEventEncoder encoder = new AguiProtocolEventEncoder();
/**
* 验证 AgentScope 官方事件保留 AG-UI type 字段。
*/
@Test
public void shouldEncodeOfficialEvent() {
String json = encoder.encodeToJson(new AguiEvent.RunStarted("thread-1", "run-1"));
Assert.assertTrue(json.contains("\"type\":\"RUN_STARTED\""));
Assert.assertTrue(json.contains("\"threadId\":\"thread-1\""));
}
/**
* 验证补充的失败和消息快照事件使用现代 AG-UI 标准事件名。
*/
@Test
public void shouldEncodeExtendedStandardEvents() {
String error = encoder.encodeToJson(
new AguiExtendedEvent.RunError("thread-1", "run-1", "boom", "FAILED"));
String snapshot = encoder.encodeToJson(new AguiExtendedEvent.MessagesSnapshot(
"thread-1", "run-1", List.of(AguiMessage.userMessage("message-1", "hello"))));
Assert.assertTrue(error.contains("\"type\":\"RUN_ERROR\""));
Assert.assertTrue(error.contains("\"code\":\"FAILED\""));
Assert.assertTrue(snapshot.contains("\"type\":\"MESSAGES_SNAPSHOT\""));
Assert.assertTrue(snapshot.contains("\"role\":\"user\""));
}
}

View File

@@ -0,0 +1,90 @@
package com.easyagents.agui;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import io.agentscope.core.agui.event.AguiEvent;
import org.junit.Assert;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* {@link AguiRuntimeEventProjector} 的协议顺序与终态测试。
*/
public class AguiRuntimeEventProjectorTest {
/**
* 验证文本、推理、工具和成功终态按 AG-UI 顺序投影。
*/
@Test
public void shouldProjectSuccessfulRunInProtocolOrder() {
AguiRuntimeEventProjector projector = new AguiRuntimeEventProjector("thread-1", "run-1");
List<Object> events = new ArrayList<>();
events.addAll(projector.project(event(AgentRuntimeEventType.STARTED, null, null, Map.of())));
events.addAll(projector.project(event(
AgentRuntimeEventType.REASONING_STARTED, "reasoning-1", null, Map.of())));
events.addAll(projector.project(event(
AgentRuntimeEventType.REASONING_DELTA, "reasoning-1", null, Map.of("reasoning", "分析"))));
events.addAll(projector.project(event(
AgentRuntimeEventType.REASONING_COMPLETED, "reasoning-1", null, Map.of())));
events.addAll(projector.project(event(
AgentRuntimeEventType.MESSAGE_DELTA, "message-1", null, Map.of("text", "你好"))));
events.addAll(projector.project(event(
AgentRuntimeEventType.TOOL_CALL, null, "tool-1",
Map.of("toolName", "search", "input", Map.of("q", "AG-UI")))));
events.addAll(projector.project(event(
AgentRuntimeEventType.TOOL_RESULT, null, "tool-1", Map.of("text", "done"))));
events.addAll(projector.project(event(AgentRuntimeEventType.COMPLETED, null, null, Map.of())));
Assert.assertEquals(List.of(
"RunStarted",
"ReasoningMessageStart",
"ReasoningMessageContent",
"ReasoningMessageEnd",
"TextMessageStart",
"TextMessageContent",
"ToolCallStart",
"ToolCallArgs",
"ToolCallEnd",
"ToolCallResult",
"TextMessageEnd",
"RunFinished"), events.stream().map(value -> value.getClass().getSimpleName()).toList());
Assert.assertEquals(
"reasoning", ((AguiEvent.ReasoningMessageStart) events.get(1)).role());
Assert.assertTrue(projector.isTerminated());
}
/**
* 验证失败终态不会追加成功事件,终态后的迟到事件会被丢弃。
*/
@Test
public void shouldEmitSingleErrorTerminalAndIgnoreLateEvents() {
AguiRuntimeEventProjector projector = new AguiRuntimeEventProjector("thread-1", "run-1");
List<Object> failed = projector.project(event(
AgentRuntimeEventType.FAILED, null, null, Map.of("message", "boom")));
List<Object> late = projector.project(event(AgentRuntimeEventType.COMPLETED, null, null, Map.of()));
Assert.assertEquals(2, failed.size());
Assert.assertTrue(failed.get(0) instanceof AguiEvent.RunStarted);
Assert.assertEquals(
new AguiExtendedEvent.RunError("thread-1", "run-1", "boom", "AGENT_RUNTIME_FAILED"),
failed.get(1));
Assert.assertTrue(late.isEmpty());
}
private static AgentRuntimeEvent event(
AgentRuntimeEventType type,
String messageId,
String toolCallId,
Map<String, Object> payload) {
AgentRuntimeEvent event = AgentRuntimeEvent.of(type);
event.setMessageId(messageId);
event.setToolCallId(toolCallId);
event.setPayload(payload);
return event;
}
}

View File

@@ -264,6 +264,10 @@
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-agent-runtime</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-agui</artifactId>
</dependency>
<!--agent runtime end-->
<!--search engines start-->

View File

@@ -27,6 +27,9 @@ public class ChatConfig extends BaseModelConfig {
protected Boolean supportToolMessage;
protected Boolean supportThinking;
/** OpenAI-compatible 消息 content 的序列化格式。 */
protected ChatMessageContentFormat messageContentFormat = ChatMessageContentFormat.STANDARD;
// 在调用工具的时候,是否需要推理结果作为 reasoning_content 传给大模型, 比如 Deepseek
// 参考文档: https://api-docs.deepseek.com/zh-cn/guides/thinking_mode#%E5%B7%A5%E5%85%B7%E8%B0%83%E7%94%A8
protected Boolean needReasoningContentForToolMessage;
@@ -135,6 +138,35 @@ public class ChatConfig extends BaseModelConfig {
return supportThinking == null || supportThinking;
}
/**
* 获取消息 content 的序列化格式。
*
* @return 消息 content 格式
*/
public ChatMessageContentFormat getMessageContentFormat() {
return messageContentFormat;
}
/**
* 设置消息 content 的序列化格式。
*
* @param messageContentFormat 消息 content 格式null 时回退为标准格式
*/
public void setMessageContentFormat(ChatMessageContentFormat messageContentFormat) {
this.messageContentFormat = messageContentFormat == null
? ChatMessageContentFormat.STANDARD
: messageContentFormat;
}
/**
* 判断是否需要将纯文本 content 序列化为内容块数组。
*
* @return 配置为内容块数组时返回 true
*/
public boolean isTextPartsMessageContent() {
return messageContentFormat == ChatMessageContentFormat.TEXT_PARTS;
}
public Boolean getNeedReasoningContentForToolMessage() {
return needReasoningContentForToolMessage;
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.easyagents.core.model.chat;
/**
* OpenAI-compatible 消息 content 的序列化格式。
*/
public enum ChatMessageContentFormat {
/** 保持供应商默认格式,纯文本 content 使用字符串。 */
STANDARD,
/** 将各角色的纯文本 content 统一序列化为文本内容块数组。 */
TEXT_PARTS
}

View File

@@ -58,11 +58,39 @@ public class OpenAIChatMessageSerializer implements ChatMessageSerializer {
} else if (message instanceof ToolMessage) {
buildToolMessageObject(objectMap, (ToolMessage) message, config);
}
normalizeMessageContent(objectMap, config);
messageList.add(objectMap);
});
return messageList;
}
/**
* 根据模型配置将纯文本 content 规范为 OpenAI 文本内容块数组。
* 已经是多模态内容块数组的 content 保持不变。
*
* @param objectMap 已完成角色字段构建的消息
* @param config 模型配置
*/
protected void normalizeMessageContent(Map<String, Object> objectMap, ChatConfig config) {
if (config == null
|| !config.isTextPartsMessageContent()
|| !objectMap.containsKey("content")) {
return;
}
Object content = objectMap.get("content");
if (content instanceof List<?>) {
return;
}
if (content == null || content instanceof String) {
String text = content == null ? "" : (String) content;
objectMap.put("content", List.of(Maps.of("type", "text").set("text", text)));
return;
}
throw new IllegalStateException(
"Unsupported OpenAI message content type: " + content.getClass().getName());
}
protected void buildToolMessageObject(Map<String, Object> objectMap, ToolMessage message, ChatConfig config) {
if (config.isSupportToolMessage()) {
objectMap.put("role", "tool");
@@ -289,4 +317,3 @@ public class OpenAIChatMessageSerializer implements ChatMessageSerializer {
}
}
}

View File

@@ -1,7 +1,12 @@
package com.easyagents.core.test.model.client;
import com.easyagents.core.message.AiMessage;
import com.easyagents.core.message.SystemMessage;
import com.easyagents.core.message.ToolCall;
import com.easyagents.core.message.ToolMessage;
import com.easyagents.core.message.UserMessage;
import com.easyagents.core.model.chat.ChatConfig;
import com.easyagents.core.model.chat.ChatMessageContentFormat;
import com.easyagents.core.model.client.OpenAIChatMessageSerializer;
import org.junit.Assert;
import org.junit.Test;
@@ -14,6 +19,50 @@ import java.util.Map;
*/
public class OpenAIChatMessageSerializerTest {
/**
* 验证标准模式继续使用原有纯文本字符串格式。
*/
@Test
public void shouldKeepStringContentInStandardMode() {
ToolMessage toolMessage = toolMessage("call-1", "工具结果");
List<Map<String, Object>> messages = new OpenAIChatMessageSerializer()
.serializeMessages(List.of(
SystemMessage.of("系统提示"),
new UserMessage("用户问题"),
new AiMessage("助手回答"),
toolMessage
), new ChatConfig());
Assert.assertEquals("系统提示", messages.get(0).get("content"));
Assert.assertEquals("用户问题", messages.get(1).get("content"));
Assert.assertEquals("助手回答", messages.get(2).get("content"));
Assert.assertEquals("工具结果", messages.get(3).get("content"));
}
/**
* 验证内容块模式会转换全部纯文本消息角色。
*/
@Test
public void shouldSerializeAllTextRolesAsContentParts() {
ChatConfig config = textPartsConfig();
ToolMessage toolMessage = toolMessage("call-1", "工具结果");
List<Map<String, Object>> messages = new OpenAIChatMessageSerializer()
.serializeMessages(List.of(
SystemMessage.of("系统提示"),
new UserMessage("用户问题"),
new AiMessage("助手回答"),
toolMessage
), config);
assertTextPart(messages.get(0), "系统提示");
assertTextPart(messages.get(1), "用户问题");
assertTextPart(messages.get(2), "助手回答");
assertTextPart(messages.get(3), "工具结果");
Assert.assertEquals("call-1", messages.get(3).get("tool_call_id"));
}
/**
* 验证 Data URI 会写入标准的 image_url.url 字段。
*/
@@ -33,4 +82,71 @@ public class OpenAIChatMessageSerializerTest {
Assert.assertEquals("image_url", imageContent.get("type"));
Assert.assertEquals(dataUri, imageUrl.get("url"));
}
/**
* 验证内容块模式保留多模态数组和工具调用结构字段。
*/
@Test
public void shouldPreserveStructuredFieldsInTextPartsMode() {
String dataUri = "data:image/png;base64,AQID";
UserMessage userMessage = new UserMessage("识别图片");
userMessage.addImageUrl(dataUri);
AiMessage assistantMessage = new AiMessage(null);
assistantMessage.setReasoningContent("先分析");
assistantMessage.setToolCalls(List.of(
new ToolCall("call-1", "image_search", "{\"query\":\"license\"}")));
ToolMessage toolMessage = toolMessage("call-1", "工具结果");
List<Map<String, Object>> messages = new OpenAIChatMessageSerializer()
.serializeMessages(List.of(userMessage, assistantMessage, toolMessage), textPartsConfig());
List<?> userContent = (List<?>) messages.get(0).get("content");
Assert.assertEquals(2, userContent.size());
Assert.assertEquals("image_url", ((Map<?, ?>) userContent.get(1)).get("type"));
assertTextPart(messages.get(1), "");
Assert.assertEquals("先分析", messages.get(1).get("reasoning_content"));
Assert.assertTrue(messages.get(1).containsKey("tool_calls"));
assertTextPart(messages.get(2), "工具结果");
Assert.assertEquals("call-1", messages.get(2).get("tool_call_id"));
}
/**
* 创建内容块数组模式配置。
*
* @return 内容块数组模式配置
*/
private ChatConfig textPartsConfig() {
ChatConfig config = new ChatConfig();
config.setMessageContentFormat(ChatMessageContentFormat.TEXT_PARTS);
config.setNeedReasoningContentForToolMessage(Boolean.TRUE);
return config;
}
/**
* 创建工具结果消息。
*
* @param toolCallId 工具调用标识
* @param content 工具结果文本
* @return 工具结果消息
*/
private ToolMessage toolMessage(String toolCallId, String content) {
ToolMessage message = new ToolMessage();
message.setToolCallId(toolCallId);
message.setContent(content);
return message;
}
/**
* 断言消息 content 只包含指定文本内容块。
*
* @param message 已序列化消息
* @param expectedText 预期文本
*/
private void assertTextPart(Map<String, Object> message, String expectedText) {
List<?> content = (List<?>) message.get("content");
Assert.assertEquals(1, content.size());
Map<?, ?> textPart = (Map<?, ?>) content.get(0);
Assert.assertEquals("text", textPart.get("type"));
Assert.assertEquals(expectedText, textPart.get("text"));
}
}

View File

@@ -1732,7 +1732,47 @@ public class Chain {
}
/**
* 仅在工作流处于暂停状态时恢复执行。
*
* <p>状态判断与恢复动作在同一个实例锁内完成,避免并发恢复请求重复注入变量
* 或把终态实例重新改为运行中。</p>
*
* @param variables 恢复时注入的变量
* @return 本次是否完成了暂停态到运行态的转换
*/
public boolean resumeIfSuspended(Map<String, Object> variables) {
return executeWithLock(
stateInstanceId,
10L,
TimeUnit.SECONDS,
() -> {
ChainState current =
chainStateRepository.load(stateInstanceId);
if (current == null
|| current.getStatus() != ChainStatus.SUSPEND) {
return false;
}
resumeSuspended(variables);
return true;
});
}
/**
* 恢复暂停中的工作流。
*
* @param variables 恢复时注入的变量
*/
public void resume(Map<String, Object> variables) {
resumeIfSuspended(variables);
}
/**
* 在调用方持有实例锁且已确认暂停状态后执行恢复动作。
*
* @param variables 恢复时注入的变量
*/
private void resumeSuspended(Map<String, Object> variables) {
ChainState newState = updateStateSafely(state -> {
if (variables != null) {
state.getMemory().putAll(variables);

View File

@@ -194,18 +194,44 @@ public class ChainExecutor {
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) {
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);
String stateInstanceId = chain.getStateInstanceId();
try {
chain.start(variables);
Map<String, Object> result = awaitPersistentOutcome(
stateInstanceId, timeout, unit, null);
stateInstanceId, timeout, unit, null, rejectSuspension);
clearDefaultStates(result);
return result;
} catch (TimeoutException e) {
@@ -759,6 +785,17 @@ public class ChainExecutor {
TimeUnit unit,
Chain parentChain)
throws InterruptedException, TimeoutException {
return awaitPersistentOutcome(
stateInstanceId, timeout, unit, parentChain, false);
}
private Map<String, Object> awaitPersistentOutcome(
String stateInstanceId,
long timeout,
TimeUnit unit,
Chain parentChain,
boolean rejectSuspension)
throws InterruptedException, TimeoutException {
Objects.requireNonNull(unit, "time unit required");
long timeoutNanos = timeout == Long.MAX_VALUE
? Long.MAX_VALUE
@@ -789,6 +826,12 @@ public class ChainExecutor {
"Chain state not found: " + stateInstanceId);
}
ChainStatus status = state.getStatus();
if (rejectSuspension && status == ChainStatus.SUSPEND) {
cancel(stateInstanceId, "Suspended workflow is not supported by this caller");
throw new ChainException(
"Workflow suspended and requires external input: "
+ stateInstanceId);
}
if (status != null && status.isTerminal()) {
if (!status.isSuccess()) {
ExceptionSummary error = state.getError();
@@ -896,6 +939,82 @@ public class ChainExecutor {
chain.resume(variables);
}
/**
* 仅在工作流实例处于暂停状态时恢复执行。
*
* <p>状态判断和恢复由 {@link Chain} 在同一个实例锁内完成,可安全处理并发恢复请求。</p>
*
* @param stateInstanceId 工作流实例 ID
* @param variables 恢复时注入的变量
* @return 本次是否完成了暂停态到运行态的转换
*/
public boolean resumeAsyncIfSuspended(
String stateInstanceId,
Map<String, Object> variables) {
ChainState state = chainStateRepository.load(stateInstanceId);
if (state == null) {
return false;
}
ChainDefinition definition = getDefinitionForInstance(state);
if (definition == null) {
return false;
}
Chain chain = configureChain(
definition,
state.getInstanceId(),
state);
return chain.resumeIfSuspended(variables);
}
/**
* 获取工作流实例启动时定义快照中的节点名称。
*
* @param stateInstanceId 工作流实例 ID
* @return 按定义顺序排列的节点 ID 与名称;实例或定义不存在时返回空映射
*/
public Map<String, String> getInstanceNodeNames(
String stateInstanceId) {
ChainState state = chainStateRepository.load(stateInstanceId);
if (state == null) {
return Collections.emptyMap();
}
return getInstanceNodeNames(state);
}
/**
* 使用调用方已经加载的状态获取实例定义快照中的节点名称。
*
* @param state 已加载的工作流状态
* @return 按定义顺序排列的节点 ID 与名称;状态或定义不存在时返回空映射
*/
public Map<String, String> getInstanceNodeNames(
ChainState state) {
if (state == null) {
return Collections.emptyMap();
}
ChainDefinition definition = getDefinitionForInstance(state);
if (definition == null
|| definition.getNodes() == null
|| definition.getNodes().isEmpty()) {
return Collections.emptyMap();
}
Map<String, String> nodeNames = new LinkedHashMap<>();
for (Node node : definition.getNodes()) {
if (node == null
|| node.getId() == null
|| node.getId().isBlank()) {
continue;
}
String nodeName = node.getName();
nodeNames.put(
node.getId(),
nodeName == null || nodeName.isBlank()
? node.getId()
: nodeName);
}
return Collections.unmodifiableMap(nodeNames);
}
private Chain createChain(String definitionId) {
ChainDefinition definition = definitionRepository.getChainDefinitionById(definitionId);
@@ -1011,6 +1130,10 @@ public class ChainExecutor {
// 状态已过期或被清理时,该触发器已经失去业务目标,直接确认避免无限热重放。
return;
}
if (state.getStatus() != null && state.getStatus().isTerminal()) {
// 终态不可再次执行;直接确认迟到或重复触发器,避免重新加载已清理的定义快照。
return;
}
ChainDefinition definition = getDefinitionForInstance(state);

View File

@@ -34,6 +34,8 @@ public class TemplateNode extends BaseNode {
private String template;
static {
// Enjoy 默认仅识别 ASCII 变量名,工作流参数需要支持中文名称。
Engine.setChineseExpression(true);
engine = Engine.create("template", e -> {
e.addSharedStaticMethod(StringUtil.class);
});

View File

@@ -0,0 +1,125 @@
package com.easyagents.flow.core.node;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.chain.Parameter;
import com.easyagents.flow.core.chain.RefType;
import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository;
import com.easyagents.flow.core.chain.repository.InMemoryNodeStateRepository;
import org.junit.Assert;
import org.junit.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.Map;
import java.util.UUID;
/**
* 内容模板节点变量渲染回归测试。
*/
public class TemplateNodeTest {
/**
* 验证模板可以使用中文参数名称。
*/
@Test
public void shouldRenderChineseParameterNames() {
TemplateNode node = templateNode(
"#(申请人)\n\n#(被申请人)",
"申请人",
"被申请人");
Chain chain = chain(Map.of(
"申请人", "申请内容",
"被申请人", "答辩内容"));
Map<String, Object> result = node.execute(chain);
Assert.assertEquals(
"申请内容\n\n答辩内容",
result.get("finalContent"));
}
/**
* 验证开启中文表达式后继续兼容英文参数名称。
*/
@Test
public void shouldKeepRenderingEnglishParameterNames() {
TemplateNode node = templateNode(
"#(applicant)\n\n#(respondent)",
"applicant",
"respondent");
Chain chain = chain(Map.of(
"applicant", "申请内容",
"respondent", "答辩内容"));
Map<String, Object> result = node.execute(chain);
Assert.assertEquals(
"申请内容\n\n答辩内容",
result.get("finalContent"));
}
/**
* 验证自动模板变量可以通过引用读取上游节点输出。
*/
@Test
public void shouldRenderManagedUpstreamReference() {
String parameterName = "ref_node__llm_2e_output";
TemplateNode node = templateNode(
"模型输出:#(" + parameterName + ")");
Parameter parameter = new Parameter(parameterName);
parameter.setRefType(RefType.REF);
parameter.setRef("node_llm.output");
node.setParameters(Collections.singletonList(parameter));
Chain chain = chain(Map.of(
"node_llm.output", "回答内容"));
Map<String, Object> result = node.execute(chain);
Assert.assertEquals(
"模型输出:回答内容",
result.get("finalContent"));
}
/**
* 创建指定输入参数的内容模板节点。
*
* @param template 模板内容
* @param parameterNames 输入参数名称
* @return 内容模板节点
*/
private TemplateNode templateNode(
String template,
String... parameterNames) {
TemplateNode node = new TemplateNode();
node.setId("template-node");
node.setName("内容模板");
node.setTemplate(template);
node.setParameters(Arrays.stream(parameterNames)
.map(Parameter::new)
.toList());
node.setOutputDefs(Collections.singletonList(
new Parameter("finalContent")));
return node;
}
/**
* 创建带初始化状态和输入变量的工作流。
*
* @param inputs 工作流输入
* @return 工作流
*/
private Chain chain(Map<String, Object> inputs) {
Chain chain = new Chain(
new ChainDefinition(),
"template-node-" + UUID.randomUUID());
chain.setChainStateRepository(
new InMemoryChainStateRepository());
chain.setNodeStateRepository(
new InMemoryNodeStateRepository());
ChainState state = chain.initializeState();
state.getMemory().putAll(inputs);
return chain;
}
}

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.node.EndNode;
import com.easyagents.flow.core.node.BaseNode;
import com.easyagents.flow.core.node.ConfirmNode;
import com.easyagents.flow.core.node.StartNode;
import org.junit.Assert;
import org.junit.Test;
@@ -46,6 +47,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
@@ -59,6 +61,43 @@ import java.util.concurrent.atomic.AtomicReference;
*/
public class ChainExecutorConcurrencyTest {
/**
* 验证同步 Tool 入口遇到人工挂起会快速失败,不会无限占用调用线程。
*
* @throws Exception 异步测试执行失败时抛出
*/
@Test
public void shouldFailFastWhenNonSuspendingExecutionIsSuspended()
throws Exception {
ScheduledExecutorService schedulerPool = Executors.newSingleThreadScheduledExecutor();
ExecutorService workerPool = Executors.newFixedThreadPool(2);
TriggerScheduler triggerScheduler = new TriggerScheduler(
new InMemoryTriggerStore(), schedulerPool, workerPool, 10L);
ChainDefinition definition = createConfirmDefinition();
ChainExecutor executor = new ChainExecutor(
ignored -> definition,
new InMemoryChainStateRepository(),
new InMemoryNodeStateRepository(),
triggerScheduler);
ExecutorService caller = Executors.newSingleThreadExecutor();
try {
Future<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。
*
@@ -512,6 +551,36 @@ public class ChainExecutorConcurrencyTest {
return definition;
}
/**
* 创建包含内部确认节点的测试 Workflow。
*
* @return 会进入挂起状态的 Workflow 定义
*/
private ChainDefinition createConfirmDefinition() {
ChainDefinition definition = new ChainDefinition();
definition.setId("non-suspending-confirm-test");
StartNode start = new StartNode();
start.setId("start");
ConfirmNode confirm = new ConfirmNode();
confirm.setId("confirm");
EndNode end = new EndNode();
end.setId("end");
Edge first = new Edge();
first.setId("start-to-confirm");
first.setSource("start");
first.setTarget("confirm");
Edge second = new Edge();
second.setId("confirm-to-end");
second.setSource("confirm");
second.setTarget("end");
definition.addNode(start);
definition.addNode(confirm);
definition.addNode(end);
definition.addEdge(first);
definition.addEdge(second);
return definition;
}
/**
* 创建用于取消传播验证的工作流。
*

View File

@@ -0,0 +1,68 @@
package com.easyagents.flow.core.test;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository;
import com.easyagents.flow.core.chain.repository.InMemoryNodeStateRepository;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.easyagents.flow.core.chain.runtime.InMemoryTriggerStore;
import com.easyagents.flow.core.chain.runtime.TriggerScheduler;
import com.easyagents.flow.core.node.StartNode;
import org.junit.Assert;
import org.junit.Test;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
/**
* {@link ChainExecutor} 实例定义元数据查询测试。
*/
public class ChainExecutorInstanceMetadataTest {
/**
* 验证节点名称来自实例启动时可恢复的定义快照。
*/
@Test
public void shouldResolveNodeNamesFromInstanceDefinition() {
ChainDefinition definition = new ChainDefinition();
definition.setId("metadata-definition");
StartNode start = new StartNode();
start.setId("start");
start.setName("开始节点");
definition.setNodes(Collections.singletonList(start));
definition.setEdges(Collections.emptyList());
InMemoryChainStateRepository stateRepository =
new InMemoryChainStateRepository();
stateRepository.create("metadata-instance")
.setChainDefinitionId(definition.getId());
ScheduledExecutorService schedulerPool =
Executors.newSingleThreadScheduledExecutor();
ExecutorService workerPool =
Executors.newSingleThreadExecutor();
TriggerScheduler scheduler = new TriggerScheduler(
new InMemoryTriggerStore(),
schedulerPool,
workerPool,
1_000L);
ChainExecutor executor = new ChainExecutor(
ignored -> definition,
stateRepository,
new InMemoryNodeStateRepository(),
scheduler);
try {
Map<String, String> nodeNames =
executor.getInstanceNodeNames(
"metadata-instance");
Assert.assertEquals(
Map.of("start", "开始节点"),
nodeNames);
} finally {
scheduler.shutdown();
}
}
}

View File

@@ -0,0 +1,96 @@
package com.easyagents.flow.core.test;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.EventManager;
import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository;
import com.easyagents.flow.core.chain.repository.InMemoryNodeStateRepository;
import org.junit.Assert;
import org.junit.Test;
import java.util.Collections;
import java.util.Map;
/**
* {@link Chain} 暂停恢复状态守卫测试。
*/
public class ChainResumeGuardTest {
/**
* 验证只有暂停中的实例可以恢复,重复恢复不会再次注入变量。
*/
@Test
public void shouldResumeOnlyOnceFromSuspendedState() {
InMemoryChainStateRepository stateRepository =
new InMemoryChainStateRepository();
Chain chain = createChain(stateRepository, "resume-once");
chain.suspend();
boolean resumed = chain.resumeIfSuspended(
Map.of("approved", true));
boolean resumedAgain = chain.resumeIfSuspended(
Map.of("unexpected", true));
Assert.assertTrue(resumed);
Assert.assertFalse(resumedAgain);
Assert.assertEquals(
ChainStatus.RUNNING,
stateRepository.load("resume-once").getStatus());
Assert.assertEquals(
Boolean.TRUE,
stateRepository.load("resume-once")
.getMemory()
.get("approved"));
Assert.assertFalse(
stateRepository.load("resume-once")
.getMemory()
.containsKey("unexpected"));
}
/**
* 验证成功终态不会被恢复操作改回运行中。
*/
@Test
public void shouldKeepTerminalStateUnchanged() {
InMemoryChainStateRepository stateRepository =
new InMemoryChainStateRepository();
Chain chain = createChain(stateRepository, "resume-terminal");
stateRepository.load("resume-terminal")
.setStatus(ChainStatus.SUCCEEDED);
boolean resumed = chain.resumeIfSuspended(
Map.of("unexpected", true));
Assert.assertFalse(resumed);
Assert.assertEquals(
ChainStatus.SUCCEEDED,
stateRepository.load("resume-terminal").getStatus());
Assert.assertFalse(
stateRepository.load("resume-terminal")
.getMemory()
.containsKey("unexpected"));
}
/**
* 创建使用进程内状态仓储的最小工作流。
*
* @param stateRepository 状态仓储
* @param instanceId 工作流实例 ID
* @return 已配置工作流
*/
private Chain createChain(
InMemoryChainStateRepository stateRepository,
String instanceId) {
ChainDefinition definition = new ChainDefinition();
definition.setId("resume-definition");
definition.setNodes(Collections.emptyList());
definition.setEdges(Collections.emptyList());
Chain chain = new Chain(definition, instanceId);
chain.setChainStateRepository(stateRepository);
chain.setNodeStateRepository(
new InMemoryNodeStateRepository());
chain.setEventManager(new EventManager());
return chain;
}
}

View File

@@ -14,6 +14,13 @@ Codec 支持根目录单 Skill、单目录 Skill 和多目录 Skill 三种输入
`SKILL.md` 使用 YAML frontmatter 与 Markdown 正文。未知字段、嵌套 Map/List、布尔值和数字会保留语义校验通过结构化 issue 返回路径、行列、错误码与修复建议。
模块内的可移植模型只有两层:
- `SkillDocument``SKILL.md` 原文、frontmatter 和 Markdown 正文
- `SkillResource`:除 `SKILL.md` 外的任意安全相对路径文件
Skill 的数据库主键、分类、权限、发布和审批属于上层技能库,不进入标准包模型。
## 推荐调用方式
无参 `ZipSkillPackageCodec` 使用实例级临时文件存储,适合一次性导入导出。它拥有临时目录,必须关闭:
@@ -41,10 +48,10 @@ codec.encode(result.getSkillPackage(), outputStream, writeOptions);
校验通过 `SkillValidationMode` 区分两个明确上下文:
- `DRAFT_IMPORT`ZIP 导入和兼容预检使用;历史下划线名称保留为 warning允许先进入草稿修复
- `DRAFT_IMPORT`ZIP 导入和草稿编辑使用;可修复的命名问题返回 warning
- `STANDARD`:正式新建、发布校验和标准 ZIP 导出使用;下划线名称等互操作问题作为 error。
`SkillFactory.createStrict``SkillFactory.createWithResourcesStrict``DefaultSkillValidator.validate``ZipSkillPackageCodec.encode` 执行 `STANDARD` 校验。为保持旧调用兼容,`SkillFactory.create` 仍可构建导入草稿,原有 `validateReport(skill)``validateReport(skill, limits)` 继续使用 `DRAFT_IMPORT`;新调用方需要显式上下文时使用三参数 `validateReport`
`DefaultSkillValidator.validate``ZipSkillPackageCodec.encode` 执行 `STANDARD` 校验。`SkillFactory.create` 构建草稿;需要指定校验上下文时用三参数 `validateReport`
## 安全边界
@@ -59,14 +66,14 @@ codec.encode(result.getSkillPackage(), outputStream, writeOptions);
限额通过 `SkillPackageLimits` 配置,并由 `SkillPackageReadOptions``SkillPackageWriteOptions` 传入单次操作。
## 从旧接口迁移
## 编解码结果
`importZip(InputStream)` 为兼容入口,现已废弃。新调用方应使用 `decode`,以获得
`decode` 返回
- 包布局 `SkillPackageLayout`
- 标准化 `SkillPackage`
- 包哈希
- 聚合校验报告
- `STRICT``REPORT_ONLY` 读取模式
- `COMMIT_ON_VALID``REPORT_ONLY` 读取模式
写出统一使用 `encode`自定义校验器是附加业务校验,不能替代 Codec 内置标准安全校验。
写出统一使用 `encode`。Codec 始终执行内置标准安全校验。

View File

@@ -1,59 +0,0 @@
package com.easyagents.skill.codec;
import com.easyagents.skill.exception.SkillPackageException;
import com.easyagents.skill.model.Skill;
import com.easyagents.skill.model.SkillPackage;
import com.easyagents.skill.model.SkillPackageLayout;
import com.easyagents.skill.validation.SkillValidationReport;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.List;
/**
* Skill 包双向流式编解码接口。
*/
public interface SkillPackageCodec {
/**
* 从 ZIP 输入流导入 Skill。
*
* @param inputStream ZIP 输入流
* @return Skill 列表
* @throws SkillPackageException ZIP 结构、内容或安全校验失败
* @deprecated 请使用 {@link #decode(InputStream, SkillPackageReadOptions)} 获取包形态、hash 和诊断。
*/
@Deprecated
List<Skill> importZip(InputStream inputStream);
/**
* 解码 Skill ZIP。
*
* <p>兼容默认实现委托旧导入接口;正式 Codec 应覆盖。</p>
*
* @param inputStream ZIP 输入流
* @param options 读取选项
* @return 解码结果
* @throws SkillPackageException ZIP 结构、内容、安全校验或资源存储失败
*/
default SkillPackageReadResult decode(InputStream inputStream, SkillPackageReadOptions options) {
List<Skill> skills = importZip(inputStream);
SkillPackageLayout layout = skills.size() > 1
? SkillPackageLayout.MULTI_DIRECTORY : SkillPackageLayout.SINGLE_DIRECTORY;
return new SkillPackageReadResult(new SkillPackage(layout, skills), new SkillValidationReport(), null);
}
/**
* 将 Skill 包编码为标准 ZIP。
*
* @param skillPackage Skill 包
* @param outputStream 输出流,不由本方法关闭
* @param options 写出选项
* @return 编码结果
* @throws SkillPackageException Skill 包不合法、资源不可读或 ZIP 写出失败
*/
default SkillPackageWriteResult encode(SkillPackage skillPackage, OutputStream outputStream,
SkillPackageWriteOptions options) {
throw new SkillPackageException("This SkillPackageCodec does not support encoding.");
}
}

View File

@@ -12,17 +12,6 @@ public final class SkillPackageWriteResult {
private final int entryCount;
private final SkillPackageLayout layout;
/**
* 创建编码结果。
*
* @param packageHash 输出 ZIP SHA-256
* @param size 输出字节数
* @param entryCount 输出 entry 数
*/
public SkillPackageWriteResult(String packageHash, long size, int entryCount) {
this(packageHash, size, entryCount, SkillPackageLayout.SINGLE_DIRECTORY);
}
/**
* 创建带包形态的编码结果。
*

View File

@@ -4,7 +4,6 @@ import com.easyagents.skill.exception.SkillPackageException;
import com.easyagents.skill.exception.SkillValidationException;
import com.easyagents.skill.model.Skill;
import com.easyagents.skill.model.SkillDocument;
import com.easyagents.skill.model.SkillMetadata;
import com.easyagents.skill.model.SkillPackage;
import com.easyagents.skill.model.SkillPackageLayout;
import com.easyagents.skill.model.SkillPackageLimits;
@@ -22,7 +21,6 @@ import com.easyagents.skill.validation.SkillValidationIssue;
import com.easyagents.skill.validation.SkillValidationMode;
import com.easyagents.skill.validation.SkillValidationReport;
import com.easyagents.skill.validation.SkillValidationSeverity;
import com.easyagents.skill.validation.SkillValidator;
import com.easyagents.skill.validation.defaults.DefaultSkillValidator;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipFile;
@@ -62,7 +60,7 @@ import java.util.zip.ZipOutputStream;
/**
* 标准 Skill ZIP 的安全双向流式 Codec。
*/
public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
public class ZipSkillPackageCodec implements AutoCloseable {
private static final String DEFAULT_MEDIA_TYPE = "application/octet-stream";
private static final int CENTRAL_DIRECTORY_HEADER_SIZE = 46;
@@ -79,7 +77,6 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
private static final int UINT16_MAX = 0xFFFF;
private final SkillContentStore contentStore;
private final SkillValidator additionalValidator;
private final boolean ownsContentStore;
/**
@@ -88,7 +85,7 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
* <p>该实例拥有默认内容存储,使用完毕后应调用 {@link #close()} 清理临时内容。</p>
*/
public ZipSkillPackageCodec() {
this(new TemporaryFileSkillContentStore(), null, true);
this(new TemporaryFileSkillContentStore(), true);
}
/**
@@ -97,36 +94,17 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
* @param contentStore 二进制内容存储
*/
public ZipSkillPackageCodec(SkillContentStore contentStore) {
this(contentStore, null, false);
this(contentStore, false);
}
/**
* 创建 ZIP Codec。
*
* @param contentStore 二进制内容存储
* @param validator Skill 聚合校验器
*/
public ZipSkillPackageCodec(SkillContentStore contentStore, SkillValidator validator) {
this(contentStore, requireValidator(validator), false);
}
private ZipSkillPackageCodec(SkillContentStore contentStore, SkillValidator validator,
boolean ownsContentStore) {
private ZipSkillPackageCodec(SkillContentStore contentStore, boolean ownsContentStore) {
if (contentStore == null) {
throw new SkillPackageException("Skill content store is required.");
}
this.contentStore = contentStore;
this.additionalValidator = validator;
this.ownsContentStore = ownsContentStore;
}
private static SkillValidator requireValidator(SkillValidator validator) {
if (validator == null) {
throw new SkillPackageException("Skill validator is required.");
}
return validator;
}
/**
* 关闭 Codec 自有的默认临时内容存储。
*
@@ -139,19 +117,6 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
}
}
/**
* 使用兼容入口导入 ZIP。
*
* @param inputStream ZIP 输入流
* @return Skill 列表
* @deprecated 请使用 {@link #decode(InputStream, SkillPackageReadOptions)}。
*/
@Deprecated
@Override
public List<Skill> importZip(InputStream inputStream) {
return decode(inputStream, SkillPackageReadOptions.defaults()).getSkillPackage().getSkills();
}
/**
* 安全解码 Skill ZIP。
*
@@ -159,7 +124,6 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
* @param options 读取选项
* @return 解码结果
*/
@Override
public SkillPackageReadResult decode(InputStream inputStream, SkillPackageReadOptions options) {
if (inputStream == null) {
throw packageError("ZIP_INPUT_REQUIRED", null, "ZIP input stream is required.");
@@ -221,7 +185,6 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
* @param options 写出选项
* @return 编码结果
*/
@Override
public SkillPackageWriteResult encode(SkillPackage skillPackage, OutputStream outputStream,
SkillPackageWriteOptions options) {
if (skillPackage == null || skillPackage.getSkills() == null || skillPackage.getSkills().isEmpty()) {
@@ -288,9 +251,10 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
throw packageError("UNSUPPORTED_ZIP_ENTRY", rawPath,
"Encrypted or unsupported ZIP entries are not allowed.");
}
boolean ignoredSystemPath = SkillPaths.isIgnoredSystemPath(rawPath);
String pathForValidation = entry.isDirectory() ? stripDirectorySuffix(rawPath) : rawPath;
String path = normalizeArchivePath(pathForValidation, limits);
if (entry.isDirectory() || SkillPaths.isIgnoredSystemPath(rawPath)) {
String path = normalizeArchivePath(pathForValidation, limits, ignoredSystemPath);
if (entry.isDirectory() || ignoredSystemPath) {
continue;
}
if (!exactPaths.add(path)) {
@@ -359,7 +323,7 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
ArchiveFile skillFile = group.files.stream()
.filter(file -> SkillPaths.SKILL_FILE.equals(file.relativePath))
.findFirst()
.orElseThrow(() -> packageError("SKILL_FILE_REQUIRED", group.root,
.orElseThrow(() -> packageError("SKILL_FILE_REQUIRED", group.archiveRoot,
"Skill directory must contain exactly one SKILL.md."));
String skillContent = readStrictText(zipFile, skillFile, limits.getMaxTextFileBytes());
@@ -368,11 +332,9 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
document = SkillFrontmatter.parseDocument(skillContent, limits);
} catch (SkillValidationException e) {
throw validationPackageError(e,
layout == SkillPackageLayout.ROOT_SKILL ? null : group.root);
layout == SkillPackageLayout.ROOT_SKILL ? null : group.packageRoot);
}
Map<String, Object> frontmatter = document.getFrontmatter().getValues();
String name = scalar(frontmatter.get("name"));
String description = scalar(frontmatter.get("description"));
String name = scalar(document.getFrontmatter().get("name"));
List<SkillResource> resources = new ArrayList<>();
for (ArchiveFile file : group.files) {
@@ -385,14 +347,9 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
resources.sort(Comparator.comparing(SkillResource::getPath));
Skill skill = new Skill();
skill.setId(null);
skill.setPackageRoot(layout == SkillPackageLayout.ROOT_SKILL ? name : group.root);
skill.setName(name);
skill.setDescription(description);
skill.setMetadata(new SkillMetadata(frontmatter));
skill.setPackageRoot(layout == SkillPackageLayout.ROOT_SKILL ? name : group.packageRoot);
skill.setDocument(document);
skill.setResources(resources);
SkillResources.refreshLegacyViews(skill);
return skill;
}
@@ -400,7 +357,7 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
StageTracker stages) throws IOException {
SkillResourceKind kind = SkillResources.classify(file.relativePath);
String mediaType = detectMediaType(file.relativePath);
boolean text = SkillResources.isText(file.relativePath, kind, mediaType);
boolean text = SkillResources.isText(file.relativePath, mediaType);
long singleLimit = text ? limits.getMaxTextFileBytes() : limits.getMaxBinaryFileBytes();
if (file.entry.getSize() > singleLimit) {
throw packageError(text ? "TEXT_FILE_SIZE_LIMIT" : "BINARY_FILE_SIZE_LIMIT", file.fullPath,
@@ -456,19 +413,44 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
.map(file -> file.withRelativePath(file.fullPath))
.toList();
return new ArchiveLayout(SkillPackageLayout.ROOT_SKILL,
List.of(new ArchiveGroup(null, relativeFiles)));
List.of(new ArchiveGroup(null, null, relativeFiles)));
}
List<String> skillRoots = files.stream()
.map(file -> file.fullPath)
.filter(path -> path.endsWith("/" + SkillPaths.SKILL_FILE))
.map(path -> path.substring(0, path.length() - SkillPaths.SKILL_FILE.length() - 1))
.distinct()
.sorted()
.toList();
if (skillRoots.isEmpty()) {
throw packageError("SKILL_FILE_REQUIRED", null,
"Wrapped Skill ZIP must contain at least one Skill directory.");
}
String commonParent = parentPath(skillRoots.get(0));
boolean supportedParent = commonParent.isEmpty() || commonParent.indexOf('/') < 0;
boolean sameParent = skillRoots.stream().allMatch(root -> parentPath(root).equals(commonParent));
if (!supportedParent || !sameParent) {
throw packageError("MIXED_PACKAGE_LAYOUT", null,
"Skill directories must be direct ZIP roots or share one optional parent directory.");
}
Map<String, List<ArchiveFile>> grouped = new LinkedHashMap<>();
for (String root : skillRoots) {
grouped.put(root, new ArrayList<>());
}
for (ArchiveFile file : files) {
int separator = file.fullPath.indexOf('/');
if (separator < 1 || separator == file.fullPath.length() - 1) {
String owner = skillRoots.stream()
.filter(root -> file.fullPath.startsWith(root + "/"))
.findFirst()
.orElse(null);
if (owner == null) {
throw packageError("UNOWNED_ROOT_FILE", file.fullPath,
"Wrapped Skill ZIP root can contain only Skill directories.");
"Wrapped Skill ZIP can contain files only inside Skill directories.");
}
String root = file.fullPath.substring(0, separator);
String relativePath = file.fullPath.substring(separator + 1);
grouped.computeIfAbsent(root, ignored -> new ArrayList<>())
String relativePath = file.fullPath.substring(owner.length() + 1);
grouped.get(owner)
.add(file.withRelativePath(relativePath));
}
List<ArchiveGroup> groups = new ArrayList<>();
@@ -478,35 +460,49 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
.count();
if (skillFileCount != 1) {
throw packageError("SKILL_FILE_REQUIRED", entry.getKey(),
"Each top-level Skill directory must contain exactly one SKILL.md.");
"Each Skill directory must contain exactly one SKILL.md.");
}
groups.add(new ArchiveGroup(entry.getKey(), entry.getValue()));
groups.add(new ArchiveGroup(entry.getKey(), fileName(entry.getKey()), entry.getValue()));
}
groups.sort(Comparator.comparing(group -> group.root));
groups.sort(Comparator.comparing(group -> group.archiveRoot));
SkillPackageLayout layout = groups.size() == 1
? SkillPackageLayout.SINGLE_DIRECTORY : SkillPackageLayout.MULTI_DIRECTORY;
return new ArchiveLayout(layout, groups);
}
/**
* 执行 Codec 不可绕过的标准安全校验,并追加调用方业务校验
* 获取归一化归档路径的父目录
*
* @param path 归一化归档路径
* @return 父目录,顶层路径返回空字符串
*/
private static String parentPath(String path) {
int separator = path.lastIndexOf('/');
return separator < 0 ? "" : path.substring(0, separator);
}
/**
* 获取归一化归档路径的末级目录名。
*
* @param path 归一化归档路径
* @return 末级目录名
*/
private static String fileName(String path) {
int separator = path.lastIndexOf('/');
return separator < 0 ? path : path.substring(separator + 1);
}
/**
* 执行 Codec 不可绕过的标准安全校验。
*
* @param skill Skill 聚合
* @param limits 当前读写操作限额
* @param mode 标准校验模式
* @return 合并后的结构化报告
* @return 结构化报告
*/
private SkillValidationReport validateForCodec(Skill skill, SkillPackageLimits limits,
SkillValidationMode mode) {
SkillValidationReport report = new DefaultSkillValidator(limits)
.validateReport(skill, limits, mode);
if (additionalValidator != null) {
SkillValidationReport additionalReport = additionalValidator.getClass() == DefaultSkillValidator.class
? additionalValidator.validateReport(skill, null, mode)
: additionalValidator.validateReport(skill, limits, mode);
report.merge(additionalReport);
}
return report;
return new DefaultSkillValidator(limits).validateReport(skill, limits, mode);
}
private List<OutputFile> prepareOutput(List<Skill> skills, SkillPackageLimits limits) {
@@ -557,7 +553,7 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
files.add(OutputFile.text(skillPath, skillContent));
totalSize = checkedOutputTotal(totalSize, skillSize, limits, skillPath);
List<SkillResource> resources = SkillResources.canonicalResources(skill);
List<SkillResource> resources = new ArrayList<>(skill.getResources());
resources.sort(Comparator.comparing(SkillResource::getPath));
for (SkillResource resource : resources) {
String path = skill.getName() + "/" + SkillPaths.normalize(resource.getPath());
@@ -1188,10 +1184,13 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
return total;
}
private static String normalizeArchivePath(String rawPath, SkillPackageLimits limits) {
private static String normalizeArchivePath(String rawPath, SkillPackageLimits limits,
boolean ignoredSystemPath) {
String normalized;
try {
normalized = SkillPaths.normalize(rawPath);
normalized = ignoredSystemPath
? SkillPaths.normalizeIgnoredSystemPath(rawPath)
: SkillPaths.normalize(rawPath);
} catch (SkillValidationException e) {
throw packageError("UNSAFE_ENTRY_PATH", rawPath, e.getMessage());
}
@@ -1248,7 +1247,7 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
throw packageError("PATH_LENGTH_LIMIT", path,
"Skill path exceeds " + limits.getMaxPathLength() + " characters.");
}
if (SkillPaths.depth(path) > limits.getMaxPathDepth()) {
if (path.split("/", -1).length > limits.getMaxPathDepth()) {
throw packageError("PATH_DEPTH_LIMIT", path,
"Skill path exceeds depth " + limits.getMaxPathDepth() + ".");
}
@@ -1443,7 +1442,7 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
}
}
private record ArchiveGroup(String root, List<ArchiveFile> files) {
private record ArchiveGroup(String archiveRoot, String packageRoot, List<ArchiveFile> files) {
}
private record ArchiveLayout(SkillPackageLayout layout, List<ArchiveGroup> groups) {
@@ -1533,22 +1532,27 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
}
SkillPackageException rollbackFailure = null;
for (int index = records.size() - 1; index >= 0; index--) {
StageRecord record = records.get(index);
if (record.cleaned) {
continue;
}
try {
contentStore.rollback(records.get(index).stage);
contentStore.rollback(record.stage);
record.cleaned = true;
} catch (RuntimeException cleanupError) {
if (rollbackFailure == null) {
rollbackFailure = new SkillPackageException(
"SKILL_CONTENT_ROLLBACK_ERROR", records.get(index).path,
"SKILL_CONTENT_ROLLBACK_ERROR", record.path,
"Failed to rollback staged Skill package content.", cleanupError);
} else {
rollbackFailure.addSuppressed(cleanupError);
}
}
}
finalized = true;
if (rollbackFailure != null) {
throw rollbackFailure;
}
finalized = true;
}
private void cleanupAfterFailure(Throwable primary) {
@@ -1557,17 +1561,21 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
}
for (int index = records.size() - 1; index >= 0; index--) {
StageRecord record = records.get(index);
if (record.cleaned) {
continue;
}
try {
if (record.committedRef == null) {
contentStore.rollback(record.stage);
} else {
contentStore.release(record.committedRef);
}
record.cleaned = true;
} catch (RuntimeException cleanupError) {
primary.addSuppressed(cleanupError);
}
}
finalized = true;
finalized = records.stream().allMatch(record -> record.cleaned);
}
}
@@ -1577,6 +1585,7 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
private final SkillResource resource;
private final String path;
private String committedRef;
private boolean cleaned;
private StageRecord(SkillContentStage stage, SkillResource resource, String path) {
this.stage = stage;

View File

@@ -2,8 +2,6 @@ package com.easyagents.skill.factory;
import com.easyagents.skill.model.*;
import com.easyagents.skill.util.SkillFrontmatter;
import com.easyagents.skill.util.SkillResources;
import com.easyagents.skill.validation.defaults.DefaultSkillValidator;
import java.util.List;
import java.util.Map;
@@ -19,92 +17,37 @@ public final class SkillFactory {
/**
* 基于 SKILL.md 内容创建 Skill。
*
* @param id Skill ID
* @param skillContent SKILL.md 原始内容
* @return Skill 聚合
*/
public static Skill create(String id, String skillContent) {
return create(id, skillContent, null, null, null);
}
/**
* 基于 SKILL.md 内容创建并严格校验正式标准 Skill。
*
* @param id Skill ID
* @param skillContent SKILL.md 原始内容
* @return 通过正式标准校验的 Skill 聚合
*/
public static Skill createStrict(String id, String skillContent) {
Skill skill = create(id, skillContent);
new DefaultSkillValidator().validate(skill);
public static Skill create(String skillContent) {
SkillDocument document = SkillFrontmatter.parseDocument(skillContent);
Map<String, Object> values = document.getFrontmatter().getValues();
requiredScalar(values, "name");
requiredScalar(values, "description");
Skill skill = new Skill();
skill.setDocument(document);
return skill;
}
/**
* 基于 SKILL.md 内容和通用资源创建 Skill。
*
* @param id 仓储 ID可为空
* @param skillContent SKILL.md 原始内容
* @param resources 通用资源列表
* @return Skill 聚合
*/
public static Skill createWithResources(String id, String skillContent, List<SkillResource> resources) {
Skill skill = create(id, skillContent);
public static Skill createWithResources(String skillContent, List<SkillResource> resources) {
Skill skill = create(skillContent);
skill.setResources(resources);
SkillResources.refreshLegacyViews(skill);
return skill;
}
/**
* 基于 SKILL.md 和通用资源创建并严格校验正式标准 Skill。
*
* @param id 仓储 ID可为空
* @param skillContent SKILL.md 原始内容
* @param resources 通用资源列表
* @return 通过正式标准校验的 Skill 聚合
*/
public static Skill createWithResourcesStrict(String id, String skillContent,
List<SkillResource> resources) {
Skill skill = createWithResources(id, skillContent, resources);
new DefaultSkillValidator().validate(skill);
return skill;
}
/**
* 基于 SKILL.md 内容和资源列表创建 Skill。
*
* @param id Skill ID
* @param skillContent SKILL.md 原始内容
* @param references reference 文档列表
* @param scripts script 脚本列表
* @param assets asset 资产列表
* @return Skill 聚合
*/
public static Skill create(String id, String skillContent, List<SkillReference> references,
List<SkillScript> scripts, List<SkillAsset> assets) {
SkillDocument document = SkillFrontmatter.parseDocument(skillContent);
Map<String, Object> values = document.getFrontmatter().getValues();
String name = requiredScalar(values, "name");
String description = requiredScalar(values, "description");
Skill skill = new Skill();
skill.setId(id);
skill.setName(name);
skill.setDescription(description);
skill.setMetadata(new SkillMetadata(values));
skill.setDocument(document);
skill.setReferences(references);
skill.setScripts(scripts);
skill.setAssets(assets);
skill.setResources(SkillResources.canonicalResources(skill));
return skill;
}
private static String requiredScalar(Map<String, Object> values, String key) {
private static void requiredScalar(Map<String, Object> values, String key) {
Object value = values.get(key);
if (!(value instanceof String text) || text.isBlank()) {
throw new com.easyagents.skill.exception.SkillValidationException(
"SKILL.md frontmatter " + key + " must be a non-blank string.");
}
return text;
}
}

View File

@@ -1,7 +1,5 @@
package com.easyagents.skill.model;
import com.easyagents.skill.util.SkillResources;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
@@ -13,39 +11,13 @@ public class Skill implements Serializable {
private static final long serialVersionUID = 1L;
private String id;
private String packageRoot;
private String name;
private String description;
private SkillMetadata metadata = new SkillMetadata();
private String skillContent;
private SkillDocument document;
private List<SkillResource> resources = new ArrayList<>();
private boolean resourcesInitialized;
private List<SkillReference> references = new ArrayList<>();
private List<SkillScript> scripts = new ArrayList<>();
private List<SkillAsset> assets = new ArrayList<>();
/**
* 获取 Skill ID
*
* @return Skill ID
*/
public String getId() {
return id;
}
/**
* 设置 Skill ID。
*
* @param id Skill ID
*/
public void setId(String id) {
this.id = id;
}
/**
* 获取导入包中的顶层目录名;该值不是仓储 ID。
* 获取 Skill 自身的逻辑目录名;导入包允许在其外再包装一层父目录
*
* @return 包目录名
*/
@@ -54,7 +26,7 @@ public class Skill implements Serializable {
}
/**
* 设置导入包中的顶层目录名。
* 设置 Skill 自身的逻辑目录名。
*
* @param packageRoot 包目录名
*/
@@ -68,16 +40,7 @@ public class Skill implements Serializable {
* @return 名称
*/
public String getName() {
return name;
}
/**
* 设置名称。
*
* @param name 名称
*/
public void setName(String name) {
this.name = name;
return frontmatterString("name");
}
/**
@@ -86,34 +49,7 @@ public class Skill implements Serializable {
* @return 描述
*/
public String getDescription() {
return description;
}
/**
* 设置描述。
*
* @param description 描述
*/
public void setDescription(String description) {
this.description = description;
}
/**
* 获取元数据。
*
* @return 元数据
*/
public SkillMetadata getMetadata() {
return metadata;
}
/**
* 设置元数据。
*
* @param metadata 元数据
*/
public void setMetadata(SkillMetadata metadata) {
this.metadata = metadata == null ? new SkillMetadata() : metadata;
return frontmatterString("description");
}
/**
@@ -152,12 +88,6 @@ public class Skill implements Serializable {
public void setDocument(SkillDocument document) {
this.document = document;
this.skillContent = document == null ? null : document.render();
if (document != null) {
java.util.Map<String, Object> values = document.getFrontmatter().getValues();
this.metadata = new SkillMetadata(values);
this.name = values.get("name") instanceof String text ? text : null;
this.description = values.get("description") instanceof String text ? text : null;
}
}
/**
@@ -166,12 +96,6 @@ public class Skill implements Serializable {
* @return 通用资源列表
*/
public List<SkillResource> getResources() {
if (!resourcesInitialized) {
resources = resources == null || resources.isEmpty()
? new ArrayList<>(SkillResources.fromLegacyViews(this))
: new ArrayList<>(resources);
resourcesInitialized = true;
}
return resources;
}
@@ -182,81 +106,13 @@ public class Skill implements Serializable {
*/
public void setResources(List<SkillResource> resources) {
this.resources = resources == null ? new ArrayList<>() : new ArrayList<>(resources);
this.resourcesInitialized = true;
}
/**
* 判断正式通用资源列表是否已被显式初始化。
*
* <p>该标记用于区分“尚未迁移的旧资源视图”和“调用方明确设置的空资源列表”,
* 避免删除最后一个正式资源后又从旧兼容视图恢复该资源。</p>
*
* @return 已显式设置通用资源列表时为 true
*/
public boolean isResourcesInitialized() {
return resourcesInitialized;
}
/**
* 获取参考文档列表。
*
* @return 参考文档列表
*/
public List<SkillReference> getReferences() {
return references;
}
/**
* 设置参考文档列表。
*
* @param references 参考文档列表
*/
public void setReferences(List<SkillReference> references) {
this.references = references == null ? new ArrayList<>() : new ArrayList<>(references);
}
/**
* 获取脚本列表。
*
* @return 脚本列表
*/
public List<SkillScript> getScripts() {
return scripts;
}
/**
* 设置脚本列表。
*
* @param scripts 脚本列表
*/
public void setScripts(List<SkillScript> scripts) {
this.scripts = scripts == null ? new ArrayList<>() : new ArrayList<>(scripts);
}
/**
* 获取资产列表。
*
* @return 资产列表
*/
public List<SkillAsset> getAssets() {
return assets;
}
/**
* 设置资产列表。
*
* @param assets 资产列表
*/
public void setAssets(List<SkillAsset> assets) {
this.assets = assets == null ? new ArrayList<>() : new ArrayList<>(assets);
}
/**
* 转换为轻量描述。
*
* @return Skill 描述
*/
public SkillDescriptor toDescriptor() {
return new SkillDescriptor(id, name, description, metadata);
private String frontmatterString(String key) {
if (document == null) {
return null;
}
Object value = document.getFrontmatter().get(key);
return value instanceof String text ? text : null;
}
}

View File

@@ -1,148 +0,0 @@
package com.easyagents.skill.model;
import java.io.Serializable;
/**
* Skill 静态资产兼容视图。
*
* @deprecated 请使用 {@link SkillResource}。
*/
@Deprecated
public class SkillAsset implements Serializable {
private static final long serialVersionUID = 1L;
private String path;
private String name;
private String mediaType;
private String contentRef;
private String contentHash;
private long size;
private SkillMetadata metadata = new SkillMetadata();
/**
* 获取逻辑路径。
*
* @return 逻辑路径
*/
public String getPath() {
return path;
}
/**
* 设置逻辑路径。
*
* @param path 逻辑路径
*/
public void setPath(String path) {
this.path = path;
}
/**
* 获取名称。
*
* @return 名称
*/
public String getName() {
return name;
}
/**
* 设置名称。
*
* @param name 名称
*/
public void setName(String name) {
this.name = name;
}
/**
* 获取媒体类型。
*
* @return 媒体类型
*/
public String getMediaType() {
return mediaType;
}
/**
* 设置媒体类型。
*
* @param mediaType 媒体类型
*/
public void setMediaType(String mediaType) {
this.mediaType = mediaType;
}
/**
* 获取内容引用。
*
* @return 内容引用
*/
public String getContentRef() {
return contentRef;
}
/**
* 设置内容引用。
*
* @param contentRef 内容引用
*/
public void setContentRef(String contentRef) {
this.contentRef = contentRef;
}
/**
* 获取内容 hash。
*
* @return 内容 hash
*/
public String getContentHash() {
return contentHash;
}
/**
* 设置内容 hash。
*
* @param contentHash 内容 hash
*/
public void setContentHash(String contentHash) {
this.contentHash = contentHash;
}
/**
* 获取文件大小。
*
* @return 文件大小
*/
public long getSize() {
return size;
}
/**
* 设置文件大小。
*
* @param size 文件大小
*/
public void setSize(long size) {
this.size = size;
}
/**
* 获取元数据。
*
* @return 元数据
*/
public SkillMetadata getMetadata() {
return metadata;
}
/**
* 设置元数据。
*
* @param metadata 元数据
*/
public void setMetadata(SkillMetadata metadata) {
this.metadata = metadata == null ? new SkillMetadata() : metadata;
}
}

View File

@@ -1,111 +0,0 @@
package com.easyagents.skill.model;
import java.io.Serial;
import java.io.Serializable;
/**
* Skill 轻量描述。
*/
public class SkillDescriptor implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private String id;
private String name;
private String description;
private SkillMetadata metadata = new SkillMetadata();
/**
* 创建空 Skill 描述。
*/
public SkillDescriptor() {
}
/**
* 创建 Skill 描述。
*
* @param id Skill ID
* @param name Skill 名称
* @param description Skill 描述
* @param metadata 元数据
*/
public SkillDescriptor(String id, String name, String description, SkillMetadata metadata) {
this.id = id;
this.name = name;
this.description = description;
setMetadata(metadata);
}
/**
* 获取 Skill ID。
*
* @return Skill ID
*/
public String getId() {
return id;
}
/**
* 设置 Skill ID。
*
* @param id Skill ID
*/
public void setId(String id) {
this.id = id;
}
/**
* 获取名称。
*
* @return 名称
*/
public String getName() {
return name;
}
/**
* 设置名称。
*
* @param name 名称
*/
public void setName(String name) {
this.name = name;
}
/**
* 获取描述。
*
* @return 描述
*/
public String getDescription() {
return description;
}
/**
* 设置描述。
*
* @param description 描述
*/
public void setDescription(String description) {
this.description = description;
}
/**
* 获取元数据。
*
* @return 元数据
*/
public SkillMetadata getMetadata() {
return metadata;
}
/**
* 设置元数据。
*
* @param metadata 元数据
*/
public void setMetadata(SkillMetadata metadata) {
this.metadata = metadata == null ? new SkillMetadata() : metadata;
}
}

View File

@@ -8,9 +8,9 @@ public enum SkillPackageLayout {
/** 根目录直接包含 SKILL.md 的单 Skill 包。 */
ROOT_SKILL,
/** 一个顶层目录包装的单 Skill 包。 */
/** 一个 Skill 目录组成的单 Skill 包,可再由单层父目录包装。 */
SINGLE_DIRECTORY,
/** 多个顶层 Skill 目录组成的批量包。 */
/** 多个 Skill 目录组成的批量包,可共享一个单层父目录。 */
MULTI_DIRECTORY
}

View File

@@ -1,129 +0,0 @@
package com.easyagents.skill.model;
import java.io.Serializable;
/**
* Skill Markdown 参考文档兼容视图。
*
* @deprecated 请使用 {@link SkillResource}。
*/
@Deprecated
public class SkillReference implements Serializable {
private static final long serialVersionUID = 1L;
private String path;
private String name;
private String content;
private String contentHash;
private long size;
private SkillMetadata metadata = new SkillMetadata();
/**
* 获取逻辑路径。
*
* @return 逻辑路径
*/
public String getPath() {
return path;
}
/**
* 设置逻辑路径。
*
* @param path 逻辑路径
*/
public void setPath(String path) {
this.path = path;
}
/**
* 获取名称。
*
* @return 名称
*/
public String getName() {
return name;
}
/**
* 设置名称。
*
* @param name 名称
*/
public void setName(String name) {
this.name = name;
}
/**
* 获取 Markdown 内容。
*
* @return Markdown 内容
*/
public String getContent() {
return content;
}
/**
* 设置 Markdown 内容。
*
* @param content Markdown 内容
*/
public void setContent(String content) {
this.content = content;
}
/**
* 获取内容 hash。
*
* @return 内容 hash
*/
public String getContentHash() {
return contentHash;
}
/**
* 设置内容 hash。
*
* @param contentHash 内容 hash
*/
public void setContentHash(String contentHash) {
this.contentHash = contentHash;
}
/**
* 获取文件大小。
*
* @return 文件大小
*/
public long getSize() {
return size;
}
/**
* 设置文件大小。
*
* @param size 文件大小
*/
public void setSize(long size) {
this.size = size;
}
/**
* 获取元数据。
*
* @return 元数据
*/
public SkillMetadata getMetadata() {
return metadata;
}
/**
* 设置元数据。
*
* @param metadata 元数据
*/
public void setMetadata(SkillMetadata metadata) {
this.metadata = metadata == null ? new SkillMetadata() : metadata;
}
}

View File

@@ -18,7 +18,6 @@ public class SkillResource implements Serializable {
private String contentRef;
private String contentHash;
private long size;
private SkillMetadata metadata = new SkillMetadata();
/**
* 获取 Skill 根目录相对路径。
@@ -146,24 +145,6 @@ public class SkillResource implements Serializable {
this.size = size;
}
/**
* 获取资源扩展元数据。
*
* @return 扩展元数据
*/
public SkillMetadata getMetadata() {
return metadata;
}
/**
* 设置资源扩展元数据。
*
* @param metadata 扩展元数据
*/
public void setMetadata(SkillMetadata metadata) {
this.metadata = metadata == null ? new SkillMetadata() : metadata;
}
/**
* 判断资源是否以内联文本保存。
*

View File

@@ -1,129 +0,0 @@
package com.easyagents.skill.model;
import java.io.Serializable;
/**
* Skill 脚本源码兼容视图。
*
* @deprecated 请使用 {@link SkillResource}。
*/
@Deprecated
public class SkillScript implements Serializable {
private static final long serialVersionUID = 1L;
private String path;
private SkillScriptLanguage language = SkillScriptLanguage.UNKNOWN;
private String content;
private String contentHash;
private long size;
private SkillMetadata metadata = new SkillMetadata();
/**
* 获取逻辑路径。
*
* @return 逻辑路径
*/
public String getPath() {
return path;
}
/**
* 设置逻辑路径。
*
* @param path 逻辑路径
*/
public void setPath(String path) {
this.path = path;
}
/**
* 获取脚本语言。
*
* @return 脚本语言
*/
public SkillScriptLanguage getLanguage() {
return language;
}
/**
* 设置脚本语言。
*
* @param language 脚本语言
*/
public void setLanguage(SkillScriptLanguage language) {
this.language = language == null ? SkillScriptLanguage.UNKNOWN : language;
}
/**
* 获取脚本源码。
*
* @return 脚本源码
*/
public String getContent() {
return content;
}
/**
* 设置脚本源码。
*
* @param content 脚本源码
*/
public void setContent(String content) {
this.content = content;
}
/**
* 获取内容 hash。
*
* @return 内容 hash
*/
public String getContentHash() {
return contentHash;
}
/**
* 设置内容 hash。
*
* @param contentHash 内容 hash
*/
public void setContentHash(String contentHash) {
this.contentHash = contentHash;
}
/**
* 获取文件大小。
*
* @return 文件大小
*/
public long getSize() {
return size;
}
/**
* 设置文件大小。
*
* @param size 文件大小
*/
public void setSize(long size) {
this.size = size;
}
/**
* 获取元数据。
*
* @return 元数据
*/
public SkillMetadata getMetadata() {
return metadata;
}
/**
* 设置元数据。
*
* @param metadata 元数据
*/
public void setMetadata(SkillMetadata metadata) {
this.metadata = metadata == null ? new SkillMetadata() : metadata;
}
}

View File

@@ -1,50 +0,0 @@
package com.easyagents.skill.model;
/**
* Skill 脚本语言。
*/
public enum SkillScriptLanguage {
/**
* Python 脚本。
*/
PYTHON,
/**
* JavaScript 脚本。
*/
JAVASCRIPT,
/**
* Shell 脚本。
*/
SHELL,
/**
* 未知脚本语言。
*/
UNKNOWN;
/**
* 按脚本路径识别语言。
*
* @param path 脚本逻辑路径
* @return 脚本语言
*/
public static SkillScriptLanguage fromPath(String path) {
if (path == null) {
return UNKNOWN;
}
String lower = path.toLowerCase();
if (lower.endsWith(".py")) {
return PYTHON;
}
if (lower.endsWith(".js")) {
return JAVASCRIPT;
}
if (lower.endsWith(".sh")) {
return SHELL;
}
return UNKNOWN;
}
}

View File

@@ -1,58 +0,0 @@
package com.easyagents.skill.repository;
import com.easyagents.skill.model.Skill;
import com.easyagents.skill.model.SkillDescriptor;
import java.util.List;
import java.util.Optional;
/**
* Skill 聚合存储接口。
*/
public interface SkillRepository {
/**
* 保存 Skill。
*
* @param skill Skill 聚合
*/
void save(Skill skill);
/**
* 获取完整 Skill。
*
* @param skillId Skill ID
* @return Skill 聚合
*/
Optional<Skill> get(String skillId);
/**
* 获取 Skill 描述。
*
* @param skillId Skill ID
* @return Skill 描述
*/
Optional<SkillDescriptor> getDescriptor(String skillId);
/**
* 列出 Skill 描述。
*
* @return Skill 描述列表
*/
List<SkillDescriptor> listDescriptors();
/**
* 删除 Skill。
*
* @param skillId Skill ID
*/
void delete(String skillId);
/**
* 判断 Skill 是否存在。
*
* @param skillId Skill ID
* @return 存在时为 true
*/
boolean exists(String skillId);
}

View File

@@ -1,214 +0,0 @@
package com.easyagents.skill.repository.memory;
import com.easyagents.skill.exception.SkillValidationException;
import com.easyagents.skill.model.*;
import com.easyagents.skill.repository.SkillRepository;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* 基于内存的 Skill 聚合仓储实现。
*/
public class InMemorySkillRepository implements SkillRepository {
private final ConcurrentMap<String, Skill> skills = new ConcurrentHashMap<>();
/**
* 保存 Skill 聚合。
*
* @param skill Skill 聚合
*/
@Override
public void save(Skill skill) {
if (skill == null || isBlank(skill.getId())) {
throw new SkillValidationException("Skill id is required.");
}
skills.put(skill.getId(), copySkill(skill));
}
/**
* 获取完整 Skill。
*
* @param skillId Skill ID
* @return Skill 聚合
*/
@Override
public Optional<Skill> get(String skillId) {
Skill skill = skills.get(skillId);
return skill == null ? Optional.empty() : Optional.of(copySkill(skill));
}
/**
* 获取 Skill 描述。
*
* @param skillId Skill ID
* @return Skill 描述
*/
@Override
public Optional<SkillDescriptor> getDescriptor(String skillId) {
Skill skill = skills.get(skillId);
return skill == null ? Optional.empty() : Optional.of(copyDescriptor(skill.toDescriptor()));
}
/**
* 列出 Skill 描述。
*
* @return Skill 描述列表
*/
@Override
public List<SkillDescriptor> listDescriptors() {
List<SkillDescriptor> descriptors = new ArrayList<>();
for (Skill skill : skills.values()) {
descriptors.add(copyDescriptor(skill.toDescriptor()));
}
return descriptors;
}
/**
* 删除 Skill。
*
* @param skillId Skill ID
*/
@Override
public void delete(String skillId) {
skills.remove(skillId);
}
/**
* 判断 Skill 是否存在。
*
* @param skillId Skill ID
* @return 存在时为 true
*/
@Override
public boolean exists(String skillId) {
return skills.containsKey(skillId);
}
private static Skill copySkill(Skill source) {
Skill target = new Skill();
target.setId(source.getId());
target.setPackageRoot(source.getPackageRoot());
target.setName(source.getName());
target.setDescription(source.getDescription());
target.setMetadata(copyMetadata(source.getMetadata()));
SkillDocument copiedDocument = copyDocument(source.getDocument());
if (copiedDocument == null) {
target.setSkillContent(source.getSkillContent());
} else {
target.setDocument(copiedDocument);
}
target.setResources(copyResources(source.getResources()));
target.setReferences(copyReferences(source.getReferences()));
target.setScripts(copyScripts(source.getScripts()));
target.setAssets(copyAssets(source.getAssets()));
return target;
}
private static SkillDocument copyDocument(SkillDocument source) {
if (source == null) {
return null;
}
SkillDocument target = new SkillDocument(source.getRawContent(),
source.getFrontmatter().getValues(), source.getMarkdownBody(),
source.getFrontmatterLocations(), source.getDiagnostics());
target.setModified(source.isModified());
return target;
}
private static List<SkillResource> copyResources(List<SkillResource> sources) {
List<SkillResource> targets = new ArrayList<>();
if (sources == null) {
return targets;
}
for (SkillResource source : sources) {
SkillResource target = new SkillResource();
target.setPath(source.getPath());
target.setKind(source.getKind());
target.setMediaType(source.getMediaType());
target.setTextContent(source.getTextContent());
target.setContentRef(source.getContentRef());
target.setContentHash(source.getContentHash());
target.setSize(source.getSize());
target.setMetadata(copyMetadata(source.getMetadata()));
targets.add(target);
}
return targets;
}
private static List<SkillReference> copyReferences(List<SkillReference> sources) {
List<SkillReference> targets = new ArrayList<>();
if (sources == null) {
return targets;
}
for (SkillReference source : sources) {
SkillReference target = new SkillReference();
target.setPath(source.getPath());
target.setName(source.getName());
target.setContent(source.getContent());
target.setContentHash(source.getContentHash());
target.setSize(source.getSize());
target.setMetadata(copyMetadata(source.getMetadata()));
targets.add(target);
}
return targets;
}
private static List<SkillScript> copyScripts(List<SkillScript> sources) {
List<SkillScript> targets = new ArrayList<>();
if (sources == null) {
return targets;
}
for (SkillScript source : sources) {
SkillScript target = new SkillScript();
target.setPath(source.getPath());
target.setLanguage(source.getLanguage());
target.setContent(source.getContent());
target.setContentHash(source.getContentHash());
target.setSize(source.getSize());
target.setMetadata(copyMetadata(source.getMetadata()));
targets.add(target);
}
return targets;
}
private static List<SkillAsset> copyAssets(List<SkillAsset> sources) {
List<SkillAsset> targets = new ArrayList<>();
if (sources == null) {
return targets;
}
for (SkillAsset source : sources) {
SkillAsset target = new SkillAsset();
target.setPath(source.getPath());
target.setName(source.getName());
target.setMediaType(source.getMediaType());
target.setContentRef(source.getContentRef());
target.setContentHash(source.getContentHash());
target.setSize(source.getSize());
target.setMetadata(copyMetadata(source.getMetadata()));
targets.add(target);
}
return targets;
}
private static SkillDescriptor copyDescriptor(SkillDescriptor source) {
return new SkillDescriptor(
source.getId(),
source.getName(),
source.getDescription(),
copyMetadata(source.getMetadata())
);
}
private static SkillMetadata copyMetadata(SkillMetadata source) {
return source == null ? new SkillMetadata() : new SkillMetadata(source.getValues());
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
}

View File

@@ -1,6 +1,5 @@
package com.easyagents.skill.store;
import java.nio.file.Path;
import java.util.Objects;
/**
@@ -12,8 +11,6 @@ public final class SkillContentStage {
private final String contentRef;
private final String contentHash;
private final long size;
private final boolean alreadyCommitted;
private final Path compatibilityPath;
/**
* 创建内容阶段结果。
@@ -22,21 +19,12 @@ public final class SkillContentStage {
* @param contentRef 最终内容引用
* @param contentHash SHA-256
* @param size 字节大小
* @param alreadyCommitted 是否由兼容实现提前写入正式存储
*/
public SkillContentStage(String stageId, String contentRef, String contentHash,
long size, boolean alreadyCommitted) {
this(stageId, contentRef, contentHash, size, alreadyCommitted, null);
}
private SkillContentStage(String stageId, String contentRef, String contentHash,
long size, boolean alreadyCommitted, Path compatibilityPath) {
public SkillContentStage(String stageId, String contentRef, String contentHash, long size) {
this.stageId = Objects.requireNonNull(stageId, "stageId");
this.contentRef = Objects.requireNonNull(contentRef, "contentRef");
this.contentHash = Objects.requireNonNull(contentHash, "contentHash");
this.size = size;
this.alreadyCommitted = alreadyCommitted;
this.compatibilityPath = compatibilityPath;
}
/** @return 暂存标识 */
@@ -59,18 +47,4 @@ public final class SkillContentStage {
return size;
}
/** @return 已提前提交时为 true */
public boolean isAlreadyCommitted() {
return alreadyCommitted;
}
static SkillContentStage compatibility(Path path, String contentHash, long size) {
String contentRef = "sha256:" + contentHash;
return new SkillContentStage(contentRef, contentRef, contentHash,
size, false, path);
}
Path compatibilityPath() {
return compatibilityPath;
}
}

View File

@@ -1,16 +1,6 @@
package com.easyagents.skill.store;
import com.easyagents.skill.exception.SkillException;
import com.easyagents.skill.util.SkillHashes;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
/**
* Skill 二进制内容流式存储与引用生命周期接口。
@@ -25,62 +15,14 @@ public interface SkillContentStore {
*/
String put(byte[] bytes);
/**
* 流式保存内容并返回内容引用。
*
* <p>兼容默认实现仅缓存单个文件;正式持久化实现应覆盖该方法以直接流式写入。</p>
*
* @param inputStream 内容流,不由本方法关闭
* @param maxBytes 最大允许字节数
* @return 内容引用
*/
default String put(InputStream inputStream, long maxBytes) {
return put(readBounded(inputStream, maxBytes));
}
/**
* 暂存内容,供完成全包校验后统一提交。
*
* <p>为兼容旧实现,默认实现会立即写入;正式实现应覆盖并提供真实暂存区。</p>
*
* @param inputStream 内容流,不由本方法关闭
* @param maxBytes 最大允许字节数
* @return 暂存结果
*/
default SkillContentStage stage(InputStream inputStream, long maxBytes) {
if (inputStream == null || maxBytes < 0) {
throw new SkillException("Valid Skill content stream and limit are required.");
}
Path temporaryFile = null;
try {
temporaryFile = Files.createTempFile("easy-agents-skill-content-", ".stage");
MessageDigest digest = SkillHashes.newSha256Digest();
long total = 0;
try (DigestOutputStream output = new DigestOutputStream(
Files.newOutputStream(temporaryFile, StandardOpenOption.TRUNCATE_EXISTING), digest)) {
byte[] buffer = new byte[16 * 1024];
int read;
while ((read = inputStream.read(buffer)) >= 0) {
if (read == 0) {
continue;
}
if (total > maxBytes - read) {
throw new SkillException("Skill content exceeds " + maxBytes + " bytes.");
}
output.write(buffer, 0, read);
total += read;
}
}
return SkillContentStage.compatibility(
temporaryFile, SkillHashes.toHex(digest.digest()), total);
} catch (IOException | RuntimeException e) {
deleteTemporaryFile(temporaryFile);
if (e instanceof SkillException skillException) {
throw skillException;
}
throw new SkillException("Failed to stage Skill content stream.", e);
}
}
SkillContentStage stage(InputStream inputStream, long maxBytes);
/**
* 提交暂存内容。
@@ -88,59 +30,28 @@ public interface SkillContentStore {
* @param stage 暂存结果
* @return 最终内容引用
*/
default String commit(SkillContentStage stage) {
if (stage == null) {
throw new SkillException("Skill content stage is required.");
}
Path compatibilityPath = stage.compatibilityPath();
if (compatibilityPath != null) {
try (InputStream input = Files.newInputStream(compatibilityPath)) {
String contentRef = put(input, stage.getSize());
if (!stage.getContentRef().equals(contentRef)) {
release(contentRef);
throw new SkillException("Skill content store returned a non-hash content reference.");
}
return contentRef;
} catch (IOException e) {
throw new SkillException("Failed to commit staged Skill content.", e);
} finally {
deleteTemporaryFile(compatibilityPath);
}
}
return stage.getContentRef();
}
String commit(SkillContentStage stage);
/**
* 回滚尚未提交的内容。
*
* @param stage 暂存结果
*/
default void rollback(SkillContentStage stage) {
if (stage != null) {
deleteTemporaryFile(stage.compatibilityPath());
if (stage.isAlreadyCommitted()) {
release(stage.getContentRef());
}
}
}
void rollback(SkillContentStage stage);
/**
* 增加正式内容引用计数。
*
* @param contentRef 内容引用
*/
default void retain(String contentRef) {
// 旧实现没有引用计数,保留兼容空操作。
}
void retain(String contentRef);
/**
* 释放正式内容引用;引用归零后实现可以删除物理内容。
*
* @param contentRef 内容引用
*/
default void release(String contentRef) {
// 旧实现没有引用计数,保留兼容空操作。
}
void release(String contentRef);
/**
* 打开内容流。
@@ -150,14 +61,6 @@ public interface SkillContentStore {
*/
InputStream open(String contentRef);
/**
* 读取全部内容。
*
* @param contentRef 内容引用
* @return 内容字节
*/
byte[] readAllBytes(String contentRef);
/**
* 判断内容是否存在。
*
@@ -165,43 +68,4 @@ public interface SkillContentStore {
* @return 存在时为 true
*/
boolean exists(String contentRef);
private static byte[] readBounded(InputStream inputStream, long maxBytes) {
if (inputStream == null) {
throw new SkillException("Skill content input stream is required.");
}
if (maxBytes < 0) {
throw new SkillException("Skill content max bytes cannot be negative.");
}
try {
ByteArrayOutputStream output = new ByteArrayOutputStream((int) Math.min(maxBytes, 8_192));
byte[] buffer = new byte[8_192];
long total = 0;
int read;
while ((read = inputStream.read(buffer)) >= 0) {
if (read == 0) {
continue;
}
total += read;
if (total > maxBytes) {
throw new SkillException("Skill content exceeds " + maxBytes + " bytes.");
}
output.write(buffer, 0, read);
}
return output.toByteArray();
} catch (IOException e) {
throw new SkillException("Failed to read Skill content stream.", e);
}
}
private static void deleteTemporaryFile(Path path) {
if (path == null) {
return;
}
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
// 临时文件清理由操作系统兜底,调用方异常语义保持不变。
}
}
}

View File

@@ -86,19 +86,7 @@ public final class TemporaryFileSkillContentStore implements SkillContentStore,
@Override
public String put(byte[] bytes) {
byte[] safeBytes = bytes == null ? new byte[0] : bytes;
return put(new ByteArrayInputStream(safeBytes), safeBytes.length);
}
/**
* 流式保存内容并持有一个正式引用。
*
* @param inputStream 内容流,不由本方法关闭
* @param maxBytes 最大允许字节数
* @return SHA-256 内容引用
*/
@Override
public String put(InputStream inputStream, long maxBytes) {
SkillContentStage stage = stage(inputStream, maxBytes);
SkillContentStage stage = stage(new ByteArrayInputStream(safeBytes), safeBytes.length);
try {
return commit(stage);
} catch (RuntimeException exception) {
@@ -153,7 +141,7 @@ public final class TemporaryFileSkillContentStore implements SkillContentStore,
ensureOpenLocked();
stagedContents.put(stageId, stagedContent);
}
return new SkillContentStage(stageId, contentRef, contentHash, size, false);
return new SkillContentStage(stageId, contentRef, contentHash, size);
} catch (IOException | RuntimeException exception) {
deleteFileQuietly(stagedPath);
if (exception instanceof SkillException skillException) {
@@ -309,24 +297,6 @@ public final class TemporaryFileSkillContentStore implements SkillContentStore,
}
}
/**
* 读取正式内容的全部字节。
*
* <p>该兼容方法会按接口约定返回一个字节数组;流式调用方应优先使用 {@link #open(String)}。</p>
*
* @param contentRef 内容引用
* @return 内容字节
*/
@Override
public byte[] readAllBytes(String contentRef) {
try (InputStream input = open(contentRef)) {
return input.readAllBytes();
} catch (IOException exception) {
throw new SkillException("Failed to read temporary Skill content: " + contentRef,
exception);
}
}
/**
* 判断正式内容是否仍有有效引用。
*

View File

@@ -1,210 +0,0 @@
package com.easyagents.skill.store.memory;
import com.easyagents.skill.exception.SkillException;
import com.easyagents.skill.store.SkillContentStage;
import com.easyagents.skill.store.SkillContentStore;
import com.easyagents.skill.util.SkillHashes;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 基于内存的 Skill 内容存储,支持真实暂存与引用计数,适用于测试和轻量场景。
*/
public class InMemorySkillContentStore implements SkillContentStore {
private final ConcurrentMap<String, StoredContent> contents = new ConcurrentHashMap<>();
private final ConcurrentMap<String, byte[]> stagedContents = new ConcurrentHashMap<>();
/**
* 保存内容并持有一个引用。
*
* @param bytes 内容字节
* @return 内容引用
*/
@Override
public String put(byte[] bytes) {
byte[] safeBytes = bytes == null ? new byte[0] : Arrays.copyOf(bytes, bytes.length);
String contentRef = SkillHashes.sha256Ref(safeBytes);
contents.compute(contentRef, (key, current) -> {
if (current == null) {
return new StoredContent(safeBytes);
}
if (!Arrays.equals(current.bytes, safeBytes)) {
throw new SkillException("SHA-256 collision detected for Skill content: " + contentRef);
}
current.references.incrementAndGet();
return current;
});
return contentRef;
}
/**
* 流式保存内容并持有一个引用。
*
* @param inputStream 内容流
* @param maxBytes 最大允许字节数
* @return 内容引用
*/
@Override
public String put(InputStream inputStream, long maxBytes) {
return put(readBounded(inputStream, maxBytes));
}
/**
* 将内容写入独立暂存区。
*
* @param inputStream 内容流
* @param maxBytes 最大允许字节数
* @return 暂存结果
*/
@Override
public SkillContentStage stage(InputStream inputStream, long maxBytes) {
byte[] bytes = readBounded(inputStream, maxBytes);
String hash = SkillHashes.sha256Hex(bytes);
String stageId = UUID.randomUUID().toString();
stagedContents.put(stageId, bytes);
return new SkillContentStage(stageId, "sha256:" + hash, hash, bytes.length, false);
}
/**
* 原子地将暂存内容转为正式引用。
*
* @param stage 暂存结果
* @return 正式内容引用
*/
@Override
public String commit(SkillContentStage stage) {
if (stage == null) {
throw new SkillException("Skill content stage is required.");
}
byte[] bytes = stagedContents.remove(stage.getStageId());
if (bytes == null) {
throw new SkillException("Skill content stage does not exist: " + stage.getStageId());
}
String contentRef = put(bytes);
if (!contentRef.equals(stage.getContentRef())) {
release(contentRef);
throw new SkillException("Skill staged content hash changed before commit.");
}
return contentRef;
}
/**
* 删除暂存内容。
*
* @param stage 暂存结果
*/
@Override
public void rollback(SkillContentStage stage) {
if (stage != null) {
stagedContents.remove(stage.getStageId());
}
}
/**
* 增加正式内容引用计数。
*
* @param contentRef 内容引用
*/
@Override
public void retain(String contentRef) {
contents.compute(contentRef, (key, content) -> {
if (content == null) {
throw new SkillException("Skill content does not exist: " + contentRef);
}
content.references.incrementAndGet();
return content;
});
}
/**
* 释放正式内容引用并在归零时删除内容。
*
* @param contentRef 内容引用
*/
@Override
public void release(String contentRef) {
contents.computeIfPresent(contentRef, (key, content) ->
content.references.decrementAndGet() <= 0 ? null : content);
}
/**
* 打开内容流。
*
* @param contentRef 内容引用
* @return 内容流
*/
@Override
public InputStream open(String contentRef) {
return new ByteArrayInputStream(readAllBytes(contentRef));
}
/**
* 读取全部内容。
*
* @param contentRef 内容引用
* @return 内容副本
*/
@Override
public byte[] readAllBytes(String contentRef) {
StoredContent content = contents.get(contentRef);
if (content == null) {
throw new SkillException("Skill content does not exist: " + contentRef);
}
return Arrays.copyOf(content.bytes, content.bytes.length);
}
/**
* 判断内容是否存在。
*
* @param contentRef 内容引用
* @return 存在时为 true
*/
@Override
public boolean exists(String contentRef) {
return contents.containsKey(contentRef);
}
private static byte[] readBounded(InputStream inputStream, long maxBytes) {
if (inputStream == null || maxBytes < 0) {
throw new SkillException("Valid Skill content stream and limit are required.");
}
try {
ByteArrayOutputStream output = new ByteArrayOutputStream((int) Math.min(maxBytes, 8_192));
byte[] buffer = new byte[8_192];
long total = 0;
int read;
while ((read = inputStream.read(buffer)) >= 0) {
if (read == 0) {
continue;
}
total += read;
if (total > maxBytes) {
throw new SkillException("Skill content exceeds " + maxBytes + " bytes.");
}
output.write(buffer, 0, read);
}
return output.toByteArray();
} catch (IOException e) {
throw new SkillException("Failed to read Skill content stream.", e);
}
}
private static final class StoredContent {
private final byte[] bytes;
private final AtomicInteger references = new AtomicInteger(1);
private StoredContent(byte[] bytes) {
this.bytes = Arrays.copyOf(bytes, bytes.length);
}
}
}

View File

@@ -45,6 +45,32 @@ public final class SkillPaths {
* @return 规范化后的路径
*/
public static String normalize(String path) {
return normalize(path, false);
}
/**
* 规范化已识别的系统元数据路径,允许其中保留隐藏路径段供导入器安全忽略。
*
* @param path 原始系统元数据路径
* @return 规范化后的系统元数据路径
* @throws SkillValidationException 路径不属于可忽略系统元数据或包含不安全路径段时抛出
*/
public static String normalizeIgnoredSystemPath(String path) {
if (!isIgnoredSystemPath(path)) {
throw new SkillValidationException("Skill path is not ignored system metadata: " + path);
}
return normalize(path, true);
}
/**
* 规范化逻辑路径并按用途控制隐藏路径段。
*
* @param path 原始路径
* @param allowHiddenSegments 是否允许隐藏路径段
* @return 规范化后的路径
* @throws SkillValidationException 路径非法时抛出
*/
private static String normalize(String path, boolean allowHiddenSegments) {
if (path == null) {
throw new SkillValidationException("Skill path is required.");
}
@@ -63,7 +89,7 @@ public final class SkillPaths {
if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) {
throw new SkillValidationException("Unsafe skill path is not allowed: " + path);
}
if (segment.startsWith(".")) {
if (!allowHiddenSegments && segment.startsWith(".")) {
throw new SkillValidationException("Hidden skill path is not allowed: " + path);
}
if (!segment.equals(segment.strip())) {

View File

@@ -1,29 +1,25 @@
package com.easyagents.skill.util;
import com.easyagents.skill.model.Skill;
import com.easyagents.skill.model.SkillAsset;
import com.easyagents.skill.model.SkillMetadata;
import com.easyagents.skill.model.SkillReference;
import com.easyagents.skill.model.SkillResource;
import com.easyagents.skill.model.SkillResourceKind;
import com.easyagents.skill.model.SkillScript;
import com.easyagents.skill.model.SkillScriptLanguage;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* 通用 Skill 资源与旧资源视图之间的兼容适配工具。
* 通用 Skill 资源识别工具。
*/
public final class SkillResources {
private static final Set<String> TEXT_EXTENSIONS = Set.of(
".md", ".markdown", ".txt", ".json", ".yaml", ".yml", ".xml", ".csv", ".tsv",
".htm", ".html", ".css", ".properties", ".toml", ".ini", ".sql", ".java", ".kt", ".kts",
".htm", ".html", ".svg", ".css", ".scss", ".less", ".properties", ".toml", ".ini",
".conf", ".config", ".env", ".sql", ".graphql", ".gql", ".proto", ".java", ".kt", ".kts",
".py", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".sh", ".bash", ".zsh",
".rb", ".go", ".rs", ".c", ".h", ".cpp", ".hpp", ".gradle", ".groovy"
".rb", ".go", ".rs", ".c", ".h", ".cpp", ".hpp", ".gradle", ".groovy", ".vue", ".svelte"
);
private static final Set<String> TEXT_FILE_NAMES = Set.of(
"dockerfile", "makefile", "gradlew", ".gitignore", ".gitattributes", ".editorconfig"
);
private SkillResources() {
@@ -50,138 +46,22 @@ public final class SkillResources {
* 判断资源是否应按严格 UTF-8 文本处理。
*
* @param path 资源路径
* @param kind 资源语义类型
* @param mediaType 媒体类型
* @return 文本资源时为 true
*/
public static boolean isText(String path, SkillResourceKind kind, String mediaType) {
if (kind == SkillResourceKind.SCRIPT) {
return true;
}
if (kind == SkillResourceKind.ASSET) {
return false;
}
if (mediaType != null && (mediaType.startsWith("text/")
|| mediaType.contains("json") || mediaType.contains("yaml")
|| mediaType.contains("xml") || mediaType.contains("javascript"))) {
public static boolean isText(String path, String mediaType) {
String normalizedMediaType = mediaType == null ? "" : mediaType.toLowerCase(Locale.ROOT);
if (normalizedMediaType.startsWith("text/")
|| normalizedMediaType.contains("json") || normalizedMediaType.contains("yaml")
|| normalizedMediaType.contains("xml") || normalizedMediaType.contains("javascript")
|| normalizedMediaType.contains("typescript") || normalizedMediaType.contains("toml")
|| normalizedMediaType.contains("sql")) {
return true;
}
String lowerPath = path.toLowerCase(Locale.ROOT);
return TEXT_EXTENSIONS.stream().anyMatch(lowerPath::endsWith);
String fileName = SkillPaths.fileName(lowerPath);
return TEXT_FILE_NAMES.contains(fileName)
|| TEXT_EXTENSIONS.stream().anyMatch(lowerPath::endsWith);
}
/**
* 获取 Skill 的正式通用资源;旧模型会被按需转换。
*
* @param skill Skill 聚合
* @return 通用资源副本
*/
public static List<SkillResource> canonicalResources(Skill skill) {
if (skill == null) {
return new ArrayList<>();
}
return new ArrayList<>(skill.getResources());
}
/**
* 将尚未迁移的旧 references、scripts、assets 视图转换为正式通用资源。
*
* <p>该方法只读取旧视图,不读取或修改正式资源列表,由 {@link Skill#getResources()}
* 在第一次正式访问时完成一次性迁移。</p>
*
* @param skill Skill 聚合
* @return 从旧视图转换得到的通用资源副本
*/
public static List<SkillResource> fromLegacyViews(Skill skill) {
List<SkillResource> resources = new ArrayList<>();
if (skill == null) {
return resources;
}
if (skill.getReferences() != null) {
for (SkillReference reference : skill.getReferences()) {
SkillResource resource = base(reference.getPath(), SkillResourceKind.REFERENCE,
"text/markdown", reference.getContentHash(), reference.getSize(), reference.getMetadata());
resource.setTextContent(reference.getContent());
resources.add(resource);
}
}
if (skill.getScripts() != null) {
for (SkillScript script : skill.getScripts()) {
SkillResource resource = base(script.getPath(), SkillResourceKind.SCRIPT,
"text/plain", script.getContentHash(), script.getSize(), script.getMetadata());
resource.setTextContent(script.getContent());
resources.add(resource);
}
}
if (skill.getAssets() != null) {
for (SkillAsset asset : skill.getAssets()) {
SkillResource resource = base(asset.getPath(), SkillResourceKind.ASSET,
asset.getMediaType(), asset.getContentHash(), asset.getSize(), asset.getMetadata());
resource.setContentRef(asset.getContentRef());
resources.add(resource);
}
}
return resources;
}
/**
* 依据通用资源刷新旧 references、scripts、assets 兼容视图。
*
* @param skill Skill 聚合
*/
public static void refreshLegacyViews(Skill skill) {
List<SkillReference> references = new ArrayList<>();
List<SkillScript> scripts = new ArrayList<>();
List<SkillAsset> assets = new ArrayList<>();
for (SkillResource resource : skill.getResources()) {
if (resource.getKind() == SkillResourceKind.REFERENCE && resource.isText()) {
SkillReference reference = new SkillReference();
reference.setPath(resource.getPath());
reference.setName(SkillPaths.fileName(resource.getPath()));
reference.setContent(resource.getTextContent());
reference.setContentHash(resource.getContentHash());
reference.setSize(resource.getSize());
reference.setMetadata(copy(resource.getMetadata()));
references.add(reference);
} else if (resource.getKind() == SkillResourceKind.SCRIPT && resource.isText()) {
SkillScript script = new SkillScript();
script.setPath(resource.getPath());
script.setLanguage(SkillScriptLanguage.fromPath(resource.getPath()));
script.setContent(resource.getTextContent());
script.setContentHash(resource.getContentHash());
script.setSize(resource.getSize());
script.setMetadata(copy(resource.getMetadata()));
scripts.add(script);
} else if (resource.getKind() == SkillResourceKind.ASSET) {
SkillAsset asset = new SkillAsset();
asset.setPath(resource.getPath());
asset.setName(SkillPaths.fileName(resource.getPath()));
asset.setMediaType(resource.getMediaType());
asset.setContentRef(resource.getContentRef());
asset.setContentHash(resource.getContentHash());
asset.setSize(resource.getSize());
asset.setMetadata(copy(resource.getMetadata()));
assets.add(asset);
}
}
skill.setReferences(references);
skill.setScripts(scripts);
skill.setAssets(assets);
}
private static SkillResource base(String path, SkillResourceKind kind, String mediaType,
String hash, long size, SkillMetadata metadata) {
SkillResource resource = new SkillResource();
resource.setPath(path);
resource.setKind(kind);
resource.setMediaType(mediaType);
resource.setContentHash(hash);
resource.setSize(size);
resource.setMetadata(copy(metadata));
return resource;
}
private static SkillMetadata copy(SkillMetadata metadata) {
return metadata == null ? new SkillMetadata() : new SkillMetadata(metadata.getValues());
}
}

View File

@@ -1,65 +0,0 @@
package com.easyagents.skill.validation;
import com.easyagents.skill.exception.SkillValidationException;
import com.easyagents.skill.model.Skill;
import com.easyagents.skill.model.SkillPackageLimits;
/**
* Skill 校验接口。
*/
public interface SkillValidator {
/**
* 校验 Skill 聚合。
*
* @param skill Skill 聚合
*/
void validate(Skill skill);
/**
* 聚合校验 Skill 并返回结构化报告。
*
* <p>兼容默认实现会把旧命令式异常转换为单个错误;新实现应覆盖以返回全部问题。</p>
*
* @param skill Skill 聚合
* @return 结构化校验报告
*/
default SkillValidationReport validateReport(Skill skill) {
SkillValidationReport report = new SkillValidationReport();
try {
validate(skill);
} catch (SkillValidationException e) {
report.add(new SkillValidationIssue(e.getCode(), SkillValidationSeverity.ERROR, e.getPath(),
e.getLine(), e.getColumn(), e.getMessage(), null));
}
return report;
}
/**
* 使用当前 Codec 操作的安全限额聚合校验 Skill。
*
* <p>兼容实现默认委托给原有校验入口;需要检查包限额的实现应覆盖本方法。</p>
*
* @param skill Skill 聚合
* @param limits 当前读写操作的安全限额
* @return 结构化校验报告
*/
default SkillValidationReport validateReport(Skill skill, SkillPackageLimits limits) {
return validateReport(skill);
}
/**
* 使用指定标准模式和当前 Codec 安全限额聚合校验 Skill。
*
* <p>兼容实现默认忽略模式;需要区分草稿导入与正式标准约束的实现应覆盖本方法。</p>
*
* @param skill Skill 聚合
* @param limits 当前读写操作的安全限额
* @param mode 标准校验模式
* @return 结构化校验报告
*/
default SkillValidationReport validateReport(Skill skill, SkillPackageLimits limits,
SkillValidationMode mode) {
return validateReport(skill, limits);
}
}

View File

@@ -6,7 +6,6 @@ import com.easyagents.skill.model.SkillDocument;
import com.easyagents.skill.model.SkillPackageLimits;
import com.easyagents.skill.model.SkillResource;
import com.easyagents.skill.model.SkillResourceKind;
import com.easyagents.skill.model.SkillScriptLanguage;
import com.easyagents.skill.model.SkillSourceLocation;
import com.easyagents.skill.util.SkillFrontmatter;
import com.easyagents.skill.util.SkillHashes;
@@ -17,7 +16,6 @@ import com.easyagents.skill.validation.SkillValidationIssue;
import com.easyagents.skill.validation.SkillValidationMode;
import com.easyagents.skill.validation.SkillValidationReport;
import com.easyagents.skill.validation.SkillValidationSeverity;
import com.easyagents.skill.validation.SkillValidator;
import java.nio.charset.CharacterCodingException;
import java.util.HashMap;
@@ -31,7 +29,7 @@ import java.util.regex.Pattern;
/**
* 默认 Skill 聚合结构化校验器。
*/
public class DefaultSkillValidator implements SkillValidator {
public class DefaultSkillValidator {
private static final Pattern CANONICAL_NAME = Pattern.compile("[a-z0-9]+(?:-[a-z0-9]+)*");
private static final Pattern LEGACY_UNDERSCORE_NAME = Pattern.compile("[a-z0-9]+(?:_[a-z0-9]+)+");
@@ -61,7 +59,6 @@ public class DefaultSkillValidator implements SkillValidator {
* @param skill Skill 聚合
* @throws SkillValidationException 校验失败
*/
@Override
public void validate(Skill skill) {
validateReport(skill, limits, SkillValidationMode.STANDARD).throwIfInvalid();
}
@@ -72,7 +69,6 @@ public class DefaultSkillValidator implements SkillValidator {
* @param skill Skill 聚合
* @return 结构化校验报告
*/
@Override
public SkillValidationReport validateReport(Skill skill) {
return validateReport(skill, limits);
}
@@ -84,7 +80,6 @@ public class DefaultSkillValidator implements SkillValidator {
* @param operationLimits 当前读写操作的安全限额
* @return 结构化校验报告
*/
@Override
public SkillValidationReport validateReport(Skill skill, SkillPackageLimits operationLimits) {
return validateReport(skill, operationLimits, SkillValidationMode.DRAFT_IMPORT);
}
@@ -97,7 +92,6 @@ public class DefaultSkillValidator implements SkillValidator {
* @param mode 标准校验模式
* @return 结构化校验报告
*/
@Override
public SkillValidationReport validateReport(Skill skill, SkillPackageLimits operationLimits,
SkillValidationMode mode) {
SkillPackageLimits effectiveLimits = operationLimits == null ? limits : operationLimits;
@@ -110,11 +104,11 @@ public class DefaultSkillValidator implements SkillValidator {
SkillDocument document = parseDocument(skill, report, effectiveLimits);
validateName(skill, document, report, effectiveMode);
validateDescription(skill, document, report);
validateDescription(document, report);
if (document != null) {
validateDocument(skill, document, report);
validateDocument(document, report);
}
List<SkillResource> resources = SkillResources.canonicalResources(skill);
List<SkillResource> resources = skill.getResources();
validateAggregateLimits(skill, resources, report, effectiveLimits);
validateResources(skill.getName(), resources, report, effectiveLimits);
return report;
@@ -123,7 +117,7 @@ public class DefaultSkillValidator implements SkillValidator {
private static void validateName(Skill skill, SkillDocument document,
SkillValidationReport report, SkillValidationMode mode) {
SkillSourceLocation location = sourceLocation(document, "name");
String name = skill.getName();
String name = frontmatterString(document, "name");
if (isBlank(name)) {
report.add(errorAt("NAME_REQUIRED", SkillPaths.SKILL_FILE, location, "Skill name is required.",
"Set frontmatter name to a portable Skill name."));
@@ -153,13 +147,13 @@ public class DefaultSkillValidator implements SkillValidator {
}
}
private static void validateDescription(Skill skill, SkillDocument document,
SkillValidationReport report) {
private static void validateDescription(SkillDocument document, SkillValidationReport report) {
SkillSourceLocation location = sourceLocation(document, "description");
if (isBlank(skill.getDescription())) {
String description = frontmatterString(document, "description");
if (isBlank(description)) {
report.add(errorAt("DESCRIPTION_REQUIRED", SkillPaths.SKILL_FILE, location,
"Skill description is required.", "Describe both the capability and when to use it."));
} else if (skill.getDescription().length() > 1_024) {
} else if (description.length() > 1_024) {
report.add(errorAt("DESCRIPTION_TOO_LONG", SkillPaths.SKILL_FILE, location,
"Skill description cannot exceed 1024 characters.", "Shorten the description."));
}
@@ -181,10 +175,8 @@ public class DefaultSkillValidator implements SkillValidator {
}
}
private static void validateDocument(Skill skill, SkillDocument document, SkillValidationReport report) {
private static void validateDocument(SkillDocument document, SkillValidationReport report) {
Map<String, Object> values = document.getFrontmatter().getValues();
validateCoreString(document, values, "name", skill.getName(), report);
validateCoreString(document, values, "description", skill.getDescription(), report);
validateOptionalString(document, values, "license", null, report);
Object compatibility = values.get("compatibility");
if (compatibility != null && (!(compatibility instanceof String text)
@@ -195,12 +187,6 @@ public class DefaultSkillValidator implements SkillValidator {
}
validateOptionalString(document, values, "allowed-tools", "INVALID_ALLOWED_TOOLS", report);
validateMetadataField(document, values.get("metadata"), report);
if (skill.getMetadata() == null || !skill.getMetadata().getValues().equals(values)) {
report.add(errorAt("METADATA_MISMATCH", SkillPaths.SKILL_FILE,
sourceLocation(document, "name"),
"Skill metadata must match SKILL.md frontmatter.",
"Reparse SKILL.md before saving the aggregate."));
}
if (document.getMarkdownBody().length() > 30_000) {
report.add(new SkillValidationIssue("LONG_SKILL_BODY", SkillValidationSeverity.WARNING,
SkillPaths.SKILL_FILE, null, null,
@@ -209,21 +195,6 @@ public class DefaultSkillValidator implements SkillValidator {
}
}
private static void validateCoreString(SkillDocument document, Map<String, Object> values,
String key, String expected,
SkillValidationReport report) {
Object value = values.get(key);
if (!(value instanceof String text) || text.isBlank()) {
report.add(errorAt(key.toUpperCase(Locale.ROOT) + "_REQUIRED", SkillPaths.SKILL_FILE,
sourceLocation(document, key),
"SKILL.md frontmatter " + key + " must be a non-blank string.", null));
} else if (!text.equals(expected)) {
report.add(errorAt(key.toUpperCase(Locale.ROOT) + "_MISMATCH", SkillPaths.SKILL_FILE,
sourceLocation(document, key),
"Skill " + key + " must match SKILL.md frontmatter.", null));
}
}
private static void validateOptionalString(SkillDocument document, Map<String, Object> values,
String key, String code,
SkillValidationReport report) {
@@ -335,11 +306,9 @@ public class DefaultSkillValidator implements SkillValidator {
private static void validateResourceContent(SkillResource resource, String path,
SkillValidationReport report, SkillPackageLimits limits) {
boolean expectedText = SkillResources.isText(path, resource.getKind(), resource.getMediaType());
boolean expectedText = SkillResources.isText(path, resource.getMediaType());
if (expectedText != resource.isText()) {
String code = resource.getKind() == SkillResourceKind.SCRIPT
? "SCRIPT_TEXT_REQUIRED" : "RESOURCE_CONTENT_MODE_MISMATCH";
report.add(error(code, path,
report.add(error("RESOURCE_CONTENT_MODE_MISMATCH", path,
expectedText
? "Resource path and media type require strict UTF-8 text content."
: "Resource path and media type require a binary content reference.",
@@ -350,13 +319,6 @@ public class DefaultSkillValidator implements SkillValidator {
path, null, null, "Script resource is empty.",
"Add script source or remove the unused file."));
}
if (resource.getKind() == SkillResourceKind.SCRIPT
&& SkillScriptLanguage.fromPath(path) == SkillScriptLanguage.UNKNOWN) {
report.add(new SkillValidationIssue("SCRIPT_LANGUAGE_UNRECOGNIZED",
SkillValidationSeverity.WARNING, path, null, null,
"Script language is not recognized from its extension.",
"Use .py, .js, or .sh for first-class editing and syntax highlighting."));
}
if (isBlank(resource.getMediaType())) {
report.add(error("MEDIA_TYPE_REQUIRED", path, "Skill resource media type is required.", null));
}
@@ -525,4 +487,12 @@ public class DefaultSkillValidator implements SkillValidator {
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
private static String frontmatterString(SkillDocument document, String key) {
if (document == null) {
return null;
}
Object value = document.getFrontmatter().get(key);
return value instanceof String text ? text : null;
}
}

View File

@@ -1,7 +1,6 @@
package com.easyagents.skill.codec;
import com.easyagents.skill.exception.SkillPackageException;
import com.easyagents.skill.exception.SkillValidationException;
import com.easyagents.skill.factory.SkillFactory;
import com.easyagents.skill.model.Skill;
import com.easyagents.skill.model.SkillPackage;
@@ -14,7 +13,6 @@ import com.easyagents.skill.store.SkillContentStore;
import com.easyagents.skill.store.memory.InMemorySkillContentStore;
import com.easyagents.skill.util.SkillHashes;
import com.easyagents.skill.util.SkillPaths;
import com.easyagents.skill.validation.SkillValidator;
import org.apache.commons.compress.archivers.zip.UnixStat;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
@@ -69,7 +67,7 @@ public class ZipSkillPackageCodecTest {
}
/**
* 单目录包导入通用资源并保持旧资源视图
* 单目录包导入通用资源。
*/
@Test
public void decodeWrappedSingleSkillWithGenericResources() {
@@ -86,17 +84,16 @@ public class ZipSkillPackageCodecTest {
Assert.assertEquals(SkillPackageLayout.SINGLE_DIRECTORY,
result.getSkillPackage().getLayout());
Skill skill = result.getSkillPackage().getSkills().get(0);
Assert.assertNull(skill.getId());
Assert.assertEquals("skill-a", skill.getPackageRoot());
Assert.assertEquals(5, skill.getResources().size());
Assert.assertTrue(skill.getResources().stream().anyMatch(resource ->
resource.getKind() == SkillResourceKind.EXAMPLE));
Assert.assertTrue(skill.getResources().stream().anyMatch(resource ->
resource.getKind() == SkillResourceKind.OTHER));
Assert.assertEquals(1, skill.getReferences().size());
Assert.assertEquals(1, skill.getScripts().size());
Assert.assertEquals(1, skill.getAssets().size());
Assert.assertTrue(store.exists(skill.getAssets().get(0).getContentRef()));
SkillResource binary = skill.getResources().stream()
.filter(resource -> resource.getKind() == SkillResourceKind.ASSET)
.findFirst().orElseThrow();
Assert.assertTrue(store.exists(binary.getContentRef()));
}
/**
@@ -113,6 +110,33 @@ public class ZipSkillPackageCodecTest {
Assert.assertEquals("root-skill", result.getSkillPackage().getSkills().get(0).getPackageRoot());
}
/**
* macOS Finder 生成的 AppleDouble 与目录元数据不会影响根目录或单目录 Skill 导入。
*/
@Test
public void ignoreMacOsMetadataForSingleSkillPackages() {
SkillPackageReadResult rootResult = decode(new ZipSkillPackageCodec(), zip(files(
"SKILL.md", utf8(skillMd("root-skill")),
"references/a.md", utf8("# A"),
"__MACOSX/._SKILL.md", utf8("metadata"),
"__MACOSX/references/._a.md", utf8("metadata"),
".DS_Store", utf8("metadata")
)));
Assert.assertEquals(SkillPackageLayout.ROOT_SKILL, rootResult.getSkillPackage().getLayout());
Assert.assertEquals(1, rootResult.getSkillPackage().getSkills().get(0).getResources().size());
SkillPackageReadResult wrappedResult = decode(new ZipSkillPackageCodec(), zip(files(
"docx/SKILL.md", utf8(skillMd("docx")),
"docx/references/a.md", utf8("# A"),
"__MACOSX/docx/._SKILL.md", utf8("metadata"),
"__MACOSX/docx/references/._a.md", utf8("metadata"),
"docx/.DS_Store", utf8("metadata")
)));
Assert.assertEquals(SkillPackageLayout.SINGLE_DIRECTORY,
wrappedResult.getSkillPackage().getLayout());
Assert.assertEquals(1, wrappedResult.getSkillPackage().getSkills().get(0).getResources().size());
}
/**
* 多目录包按稳定目录顺序导入。
*/
@@ -136,6 +160,50 @@ public class ZipSkillPackageCodecTest {
.getSkillPackage().getSkills().stream().map(Skill::getName).toList());
}
/**
* 多个标准 Skill 目录被同一个父目录包装后仍按独立 Skill 解码。
*/
@Test
public void decodeMultipleSkillsWithParentDirectory() {
SkillPackageReadResult result = decode(new ZipSkillPackageCodec(), zip(files(
"skill-bundle/skill-b/SKILL.md", utf8(skillMd("skill-b")),
"skill-bundle/skill-a/SKILL.md", utf8(skillMd("skill-a")),
"skill-bundle/skill-a/references/a.md", utf8("# A"),
"__MACOSX/skill-bundle/skill-a/._SKILL.md", utf8("metadata"),
"skill-bundle/.DS_Store", utf8("metadata")
)));
Assert.assertEquals(SkillPackageLayout.MULTI_DIRECTORY, result.getSkillPackage().getLayout());
Assert.assertEquals(List.of("skill-a", "skill-b"), result.getSkillPackage().getSkills().stream()
.map(Skill::getName).toList());
Assert.assertEquals(List.of("skill-a", "skill-b"), result.getSkillPackage().getSkills().stream()
.map(Skill::getPackageRoot).toList());
Assert.assertEquals(1, result.getSkillPackage().getSkills().get(0).getResources().size());
}
/**
* 父目录包装布局中的散落文件不归属于任何 Skill 时拒绝。
*/
@Test
public void rejectUnownedFileInParentDirectory() {
assertPackageCode("UNOWNED_ROOT_FILE", zip(files(
"skill-bundle/skill-a/SKILL.md", utf8(skillMd("skill-a")),
"skill-bundle/skill-b/SKILL.md", utf8(skillMd("skill-b")),
"skill-bundle/README.md", utf8("orphan")
)), SkillPackageReadOptions.defaults());
}
/**
* Skill 目录来自不同层级或不同父目录时拒绝,避免产生含糊归属。
*/
@Test
public void rejectMixedSkillDirectoryParents() {
assertPackageCode("MIXED_PACKAGE_LAYOUT", zip(files(
"skill-a/SKILL.md", utf8(skillMd("skill-a")),
"skill-bundle/skill-b/SKILL.md", utf8(skillMd("skill-b"))
)), SkillPackageReadOptions.defaults());
}
/**
* 嵌套 frontmatter 与未知字段在 Codec 中保留。
*/
@@ -148,7 +216,7 @@ public class ZipSkillPackageCodecTest {
"nested-skill/SKILL.md", utf8(markdown)
))).getSkillPackage().getSkills().get(0);
Assert.assertTrue(skill.getMetadata().get("metadata") instanceof Map);
Assert.assertTrue(skill.getDocument().getFrontmatter().get("metadata") instanceof Map);
Assert.assertEquals(markdown, skill.getSkillContent());
}
@@ -271,7 +339,10 @@ public class ZipSkillPackageCodecTest {
*/
@Test
public void rejectUnixSymlink() {
assertPackageCode("SYMLINK_ENTRY", symlinkZip(), SkillPackageReadOptions.defaults());
assertPackageCode("SYMLINK_ENTRY", symlinkZip("skill-a/assets/link"),
SkillPackageReadOptions.defaults());
assertPackageCode("SYMLINK_ENTRY", symlinkZip("__MACOSX/._link"),
SkillPackageReadOptions.defaults());
}
/**
@@ -469,6 +540,56 @@ public class ZipSkillPackageCodecTest {
Assert.assertEquals(0, store.committedCount);
}
/**
* 资源的文本或二进制表示由文件类型决定,不受顶层语义目录约束。
*/
@Test
public void decodeContentModeIndependentlyFromSemanticDirectory() throws Exception {
InMemorySkillContentStore store = new InMemorySkillContentStore();
byte[] opaqueScript = new byte[]{0, (byte) 0xFF, 1};
SkillPackageReadResult result = decode(new ZipSkillPackageCodec(store), zip(files(
"skill-a/SKILL.md", utf8(skillMd("skill-a")),
"skill-a/assets/template.md", utf8("# Template"),
"skill-a/scripts/helper.bin", opaqueScript
)));
List<SkillResource> resources = result.getSkillPackage().getSkills().get(0).getResources();
SkillResource template = resources.stream()
.filter(resource -> "assets/template.md".equals(resource.getPath()))
.findFirst().orElseThrow();
SkillResource helper = resources.stream()
.filter(resource -> "scripts/helper.bin".equals(resource.getPath()))
.findFirst().orElseThrow();
Assert.assertTrue(template.isText());
Assert.assertEquals("# Template", template.getTextContent());
Assert.assertFalse(helper.isText());
try (InputStream input = store.open(helper.getContentRef())) {
Assert.assertArrayEquals(opaqueScript, input.readAllBytes());
}
}
/**
* 预检回滚首次失败时,异常清理路径会重试尚未释放的 stage。
*/
@Test
public void retryTransientReportOnlyRollbackFailure() {
FlakyRollbackContentStore store = new FlakyRollbackContentStore();
try {
new ZipSkillPackageCodec(store).decode(
new ByteArrayInputStream(zip(files(
"skill-a/SKILL.md", utf8(skillMd("skill-a")),
"skill-a/assets/data.bin", new byte[]{1, 2, 3}
))), SkillPackageReadOptions.reportOnly());
Assert.fail("Transient rollback failure should remain visible to the caller.");
} catch (SkillPackageException expected) {
Assert.assertEquals("SKILL_CONTENT_ROLLBACK_ERROR", expected.getCode());
Assert.assertEquals(2, store.rollbackAttempts);
Assert.assertTrue(store.rollbackCompleted);
}
}
/**
* 多 Skill 预检报告为每个相对路径补齐各自根目录。
*/
@@ -493,12 +614,14 @@ public class ZipSkillPackageCodecTest {
*/
@Test
public void exportPrefixesValidationPathsForMultipleSkills() {
Skill first = SkillFactory.create("first", skillMd("skill-a"));
Skill first = SkillFactory.create(skillMd("skill-a"));
first.setPackageRoot("skill-a");
first.setDescription(null);
Skill second = SkillFactory.create("second", skillMd("skill-b"));
first.getDocument().removeFrontmatter("description");
first.setDocument(first.getDocument());
Skill second = SkillFactory.create(skillMd("skill-b"));
second.setPackageRoot("skill-b");
second.setDescription(null);
second.getDocument().removeFrontmatter("description");
second.setDocument(second.getDocument());
ByteArrayOutputStream output = new ByteArrayOutputStream();
try {
@@ -596,8 +719,6 @@ public class ZipSkillPackageCodecTest {
"skill-a/references/a.md", utf8("# A"),
"skill-a/assets/data.bin", new byte[]{1, 2, 3}
)));
first.getSkillPackage().getSkills().get(0).setId("internal-repository-id");
ByteArrayOutputStream firstZip = new ByteArrayOutputStream();
SkillPackageWriteResult firstWrite = codec.encode(first.getSkillPackage(), firstZip,
SkillPackageWriteOptions.defaults());
@@ -609,13 +730,11 @@ public class ZipSkillPackageCodecTest {
Assert.assertEquals(firstWrite.getPackageHash(), secondWrite.getPackageHash());
Assert.assertEquals(SkillPackageLayout.SINGLE_DIRECTORY, firstWrite.getLayout());
Assert.assertEquals(SkillHashes.sha256Hex(firstZip.toByteArray()), firstWrite.getPackageHash());
Assert.assertFalse(zipContains(firstZip.toByteArray(), "internal-repository-id"));
SkillPackageReadResult roundTrip = decode(codec, firstZip.toByteArray());
Skill original = first.getSkillPackage().getSkills().get(0);
Skill decoded = roundTrip.getSkillPackage().getSkills().get(0);
Assert.assertNull(decoded.getId());
Assert.assertEquals(original.getMetadata().getValues(), decoded.getMetadata().getValues());
Assert.assertEquals(original.getDocument().getFrontmatter().getValues(),
decoded.getDocument().getFrontmatter().getValues());
Assert.assertEquals(original.getSkillContent(), decoded.getSkillContent());
Assert.assertEquals(original.getResources().stream().map(resource -> resource.getPath()).toList(),
decoded.getResources().stream().map(resource -> resource.getPath()).toList());
@@ -630,7 +749,7 @@ public class ZipSkillPackageCodecTest {
@Test
public void highCompressionTextRoundTripsWithSameLimits() {
String content = skillMd("skill-a") + "a".repeat(20_000);
Skill skill = SkillFactory.create("id", content);
Skill skill = SkillFactory.create(content);
SkillPackage skillPackage = new SkillPackage(
SkillPackageLayout.SINGLE_DIRECTORY, List.of(skill));
SkillPackageLimits limits = SkillPackageLimits.builder()
@@ -678,7 +797,7 @@ public class ZipSkillPackageCodecTest {
@Test
public void enforceOutputLimitsOnGeneratedSkillEntryPath() {
ZipSkillPackageCodec codec = new ZipSkillPackageCodec();
Skill skill = SkillFactory.create("id", skillMd("skill-a"));
Skill skill = SkillFactory.create(skillMd("skill-a"));
String skillPath = "skill-a/SKILL.md";
assertExportIssue(codec, skill, new SkillPackageWriteOptions(
@@ -690,95 +809,17 @@ public class ZipSkillPackageCodecTest {
}
/**
* 删除最后一个正式资源后不得从滞留的旧兼容视图恢复并再次导出
* Codec 内建的 YAML 与包大小安全校验不可绕过
*/
@Test
public void emptyCanonicalResourcesDoNotFallBackToLegacyViews() {
String content = "# Rules";
SkillResource reference = new SkillResource();
reference.setPath("references/rules.md");
reference.setKind(SkillResourceKind.REFERENCE);
reference.setMediaType("text/markdown");
reference.setTextContent(content);
reference.setContentHash(SkillHashes.sha256Hex(utf8(content)));
reference.setSize(utf8(content).length);
Skill skill = SkillFactory.createWithResources(
"id", skillMd("skill-a"), List.of(reference));
Assert.assertEquals(1, skill.getReferences().size());
skill.getResources().clear();
public void enforceMandatoryYamlAndPackageLimits() {
ZipSkillPackageCodec codec = new ZipSkillPackageCodec();
ByteArrayOutputStream output = new ByteArrayOutputStream();
codec.encode(new SkillPackage(SkillPackageLayout.SINGLE_DIRECTORY, List.of(skill)),
output, SkillPackageWriteOptions.defaults());
Skill decoded = codec.decode(new ByteArrayInputStream(output.toByteArray()),
SkillPackageReadOptions.defaults()).getSkillPackage().getSkills().get(0);
Assert.assertTrue(decoded.getResources().isEmpty());
Assert.assertTrue(decoded.getReferences().isEmpty());
}
/**
* 注入的默认校验器仍属于附加校验,其更严格限额不得被类型判断或操作选项绕过。
*/
@Test
public void injectedDefaultValidatorAppliesItsStricterLimits() {
ZipSkillPackageCodec codec = new ZipSkillPackageCodec(
new InMemorySkillContentStore(),
new com.easyagents.skill.validation.defaults.DefaultSkillValidator(
SkillPackageLimits.builder().maxTextFileBytes(128).build()));
Skill skill = SkillFactory.create("id", skillMd("skill-a") + "x".repeat(300));
assertExportIssue(codec, skill, SkillPackageWriteOptions.defaults(),
"TEXT_FILE_SIZE_LIMIT", SkillPaths.SKILL_FILE);
}
/**
* 自定义限额感知校验器必须收到当前 Codec 操作选项。
*/
@Test
public void customValidatorReceivesOperationLimits() {
SkillPackageLimits limits = SkillPackageLimits.builder().maxPathLength(64).build();
SkillPackageLimits[] observed = new SkillPackageLimits[1];
SkillValidator validator = new SkillValidator() {
@Override
public void validate(Skill skill) {
// 限额感知实现通过下方结构化入口完成业务校验。
}
@Override
public com.easyagents.skill.validation.SkillValidationReport validateReport(
Skill skill, SkillPackageLimits operationLimits) {
observed[0] = operationLimits;
return new com.easyagents.skill.validation.SkillValidationReport();
}
};
ZipSkillPackageCodec codec = new ZipSkillPackageCodec(
new InMemorySkillContentStore(), validator);
codec.encode(new SkillPackage(SkillPackageLayout.SINGLE_DIRECTORY,
List.of(SkillFactory.create("id", skillMd("skill-a")))),
new ByteArrayOutputStream(), new SkillPackageWriteOptions(limits));
Assert.assertSame(limits, observed[0]);
}
/**
* noop 业务校验器不能绕过 Codec 内建的 YAML 与包大小安全校验。
*/
@Test
public void noopValidatorCannotBypassMandatoryYamlAndPackageLimits() {
SkillValidator noopValidator = skill -> {
// 业务层不增加规则Codec 仍必须独立完成标准安全校验。
};
ZipSkillPackageCodec codec = new ZipSkillPackageCodec(
new InMemorySkillContentStore(), noopValidator);
Skill malformedYaml = SkillFactory.create("id", skillMd("skill-a"));
Skill malformedYaml = SkillFactory.create(skillMd("skill-a"));
malformedYaml.setSkillContent("---\nname: skill-a\ndescription: [\n---\n# Invalid\n");
assertExportIssue(codec, malformedYaml, SkillPackageWriteOptions.defaults(),
"INVALID_FRONTMATTER_YAML", SkillPaths.SKILL_FILE);
Skill oversized = SkillFactory.create("id", skillMd("skill-a") + "x".repeat(300));
Skill oversized = SkillFactory.create(skillMd("skill-a") + "x".repeat(300));
SkillPackageLimits limits = SkillPackageLimits.builder()
.maxTextFileBytes(128)
.build();
@@ -786,22 +827,6 @@ public class ZipSkillPackageCodecTest {
"TEXT_FILE_SIZE_LIMIT", SkillPaths.SKILL_FILE);
}
/**
* Codec 强制标准校验后仍执行调用方注入的业务校验器。
*/
@Test
public void preserveAdditionalBusinessValidator() {
SkillValidator businessValidator = skill -> {
throw new SkillValidationException("CUSTOM_POLICY", SkillPaths.SKILL_FILE,
null, null, "Custom Skill policy rejected the package.", null);
};
ZipSkillPackageCodec codec = new ZipSkillPackageCodec(
new InMemorySkillContentStore(), businessValidator);
assertExportIssue(codec, SkillFactory.create("id", skillMd("skill-a")),
SkillPackageWriteOptions.defaults(), "CUSTOM_POLICY", SkillPaths.SKILL_FILE);
}
/**
* SKILL.md 与普通文本资源中的孤立 UTF-16 surrogate 均以结构化错误拒绝。
*/
@@ -809,12 +834,12 @@ public class ZipSkillPackageCodecTest {
public void rejectMalformedUtf16SurrogateDuringExport() {
String isolatedSurrogate = String.valueOf((char) 0xD800);
ZipSkillPackageCodec codec = new ZipSkillPackageCodec();
Skill invalidDocument = SkillFactory.create("id", skillMd("skill-a"));
Skill invalidDocument = SkillFactory.create(skillMd("skill-a"));
invalidDocument.setSkillContent(skillMd("skill-a") + isolatedSurrogate);
assertExportIssue(codec, invalidDocument, SkillPackageWriteOptions.defaults(),
"INVALID_UTF8", SkillPaths.SKILL_FILE);
Skill invalidResource = SkillFactory.create("id", skillMd("skill-a"));
Skill invalidResource = SkillFactory.create(skillMd("skill-a"));
SkillResource resource = new SkillResource();
resource.setPath("references/invalid.md");
resource.setKind(SkillResourceKind.REFERENCE);
@@ -826,7 +851,7 @@ public class ZipSkillPackageCodecTest {
assertExportIssue(codec, invalidResource, SkillPackageWriteOptions.defaults(),
"INVALID_UTF8", "references/invalid.md");
Skill invalidPath = SkillFactory.create("id", skillMd("skill-a"));
Skill invalidPath = SkillFactory.create(skillMd("skill-a"));
SkillResource pathResource = new SkillResource();
String malformedPath = "references/" + isolatedSurrogate + ".md";
pathResource.setPath(malformedPath);
@@ -845,7 +870,7 @@ public class ZipSkillPackageCodecTest {
*/
@Test
public void rejectMissingResourcePathBeforeExportWritesBytes() {
Skill skill = SkillFactory.create("id", skillMd("skill-a"));
Skill skill = SkillFactory.create(skillMd("skill-a"));
SkillResource resource = new SkillResource();
resource.setKind(SkillResourceKind.REFERENCE);
resource.setMediaType("text/markdown");
@@ -902,7 +927,7 @@ public class ZipSkillPackageCodecTest {
*/
@Test
public void aggregateInvalidSkillBeforePreparingOutput() {
Skill skill = com.easyagents.skill.factory.SkillFactory.create("id", skillMd("skill-a"));
Skill skill = SkillFactory.create(skillMd("skill-a"));
skill.setSkillContent(null);
ByteArrayOutputStream output = new ByteArrayOutputStream();
@@ -975,7 +1000,7 @@ public class ZipSkillPackageCodecTest {
resource.setContentRef("sha256:" + hash);
resource.setContentHash(hash);
resource.setSize(expected.length);
Skill skill = com.easyagents.skill.factory.SkillFactory.create("id", skillMd("skill-a"));
Skill skill = SkillFactory.create(skillMd("skill-a"));
skill.setResources(List.of(resource));
ByteArrayOutputStream output = new ByteArrayOutputStream();
@@ -1170,7 +1195,7 @@ public class ZipSkillPackageCodecTest {
return truncated;
}
private static byte[] symlinkZip() {
private static byte[] symlinkZip(String path) {
try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ZipArchiveOutputStream output = new ZipArchiveOutputStream(bytes)) {
@@ -1180,7 +1205,7 @@ public class ZipSkillPackageCodecTest {
output.write(utf8(skillMd("skill-a")));
output.closeArchiveEntry();
ZipArchiveEntry symlink = new ZipArchiveEntry("skill-a/assets/link");
ZipArchiveEntry symlink = new ZipArchiveEntry(path);
symlink.setUnixMode(UnixStat.LINK_FLAG | 0777);
output.putArchiveEntry(symlink);
output.write(utf8("target"));
@@ -1258,21 +1283,6 @@ public class ZipSkillPackageCodecTest {
}
}
private static boolean zipContains(byte[] zip, String value) {
try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(zip), StandardCharsets.UTF_8)) {
ZipEntry entry;
while ((entry = input.getNextEntry()) != null) {
if (entry.getName().contains(value)
|| new String(input.readAllBytes(), StandardCharsets.UTF_8).contains(value)) {
return true;
}
}
return false;
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
private static byte[] utf8(String value) {
return value.getBytes(StandardCharsets.UTF_8);
}
@@ -1285,7 +1295,7 @@ public class ZipSkillPackageCodecTest {
return "---\nname: " + name + "\ndescription: ''\n---\n# " + name + "\n";
}
private static final class CountingContentStore implements SkillContentStore {
private static final class CountingContentStore extends InMemorySkillContentStore {
private int putCount;
@@ -1300,11 +1310,6 @@ public class ZipSkillPackageCodecTest {
throw new UnsupportedOperationException();
}
@Override
public byte[] readAllBytes(String contentRef) {
throw new UnsupportedOperationException();
}
@Override
public boolean exists(String contentRef) {
return false;
@@ -1356,6 +1361,12 @@ public class ZipSkillPackageCodecTest {
stagedCount--;
}
@Override
public void retain(String contentRef) {
delegate.retain(contentRef);
committedCount++;
}
@Override
public void release(String contentRef) {
delegate.release(contentRef);
@@ -1367,18 +1378,13 @@ public class ZipSkillPackageCodecTest {
return delegate.open(contentRef);
}
@Override
public byte[] readAllBytes(String contentRef) {
return delegate.readAllBytes(contentRef);
}
@Override
public boolean exists(String contentRef) {
return delegate.exists(contentRef);
}
}
private static final class CorruptReadContentStore implements SkillContentStore {
private static final class CorruptReadContentStore extends InMemorySkillContentStore {
@Override
public String put(byte[] bytes) {
@@ -1390,14 +1396,25 @@ public class ZipSkillPackageCodecTest {
return new ByteArrayInputStream(utf8("xyz"));
}
@Override
public byte[] readAllBytes(String contentRef) {
return utf8("xyz");
}
@Override
public boolean exists(String contentRef) {
return true;
}
}
private static final class FlakyRollbackContentStore extends InMemorySkillContentStore {
private int rollbackAttempts;
private boolean rollbackCompleted;
@Override
public void rollback(SkillContentStage stage) {
rollbackAttempts++;
if (rollbackAttempts == 1) {
throw new IllegalStateException("simulated transient rollback failure");
}
super.rollback(stage);
rollbackCompleted = true;
}
}
}

View File

@@ -1,118 +0,0 @@
package com.easyagents.skill.repository.memory;
import com.easyagents.skill.model.Skill;
import com.easyagents.skill.model.SkillDescriptor;
import com.easyagents.skill.model.SkillReference;
import com.easyagents.skill.util.SkillResources;
import org.junit.Assert;
import org.junit.Test;
import java.util.Optional;
/**
* InMemorySkillRepository 单元测试。
*/
public class InMemorySkillRepositoryTest {
/**
* 覆盖保存、读取、描述、列表、删除和存在性判断。
*/
@Test
public void crudSkill() {
InMemorySkillRepository repository = new InMemorySkillRepository();
Skill skill = skill();
repository.save(skill);
Assert.assertTrue(repository.exists("skill-a"));
Assert.assertTrue(repository.get("skill-a").isPresent());
Assert.assertTrue(repository.getDescriptor("skill-a").isPresent());
Assert.assertEquals(1, repository.listDescriptors().size());
repository.delete("skill-a");
Assert.assertFalse(repository.exists("skill-a"));
Assert.assertFalse(repository.get("skill-a").isPresent());
}
/**
* descriptor 不携带 references/scripts/assets 内容。
*/
@Test
public void descriptorDoesNotExposeResourceContent() {
InMemorySkillRepository repository = new InMemorySkillRepository();
repository.save(skill());
Optional<SkillDescriptor> descriptor = repository.getDescriptor("skill-a");
Assert.assertTrue(descriptor.isPresent());
Assert.assertEquals("skill-a", descriptor.get().getId());
Assert.assertEquals("Skill A", descriptor.get().getName());
}
/**
* 读取返回副本,避免外部修改仓储内部状态。
*/
@Test
public void getReturnsCopy() {
InMemorySkillRepository repository = new InMemorySkillRepository();
repository.save(skill());
Skill loaded = repository.get("skill-a").get();
loaded.setName("Changed");
loaded.getReferences().get(0).setContent("changed");
Skill reloaded = repository.get("skill-a").get();
Assert.assertEquals("Skill A", reloaded.getName());
Assert.assertEquals("# A", reloaded.getReferences().get(0).getContent());
}
/**
* 仓储复制旧资源对象时应先迁移正式资源,不能因空 canonical 列表丢失 reference。
*/
@Test
public void repositoryCopyMigratesLegacyOnlyResources() {
InMemorySkillRepository repository = new InMemorySkillRepository();
Skill legacy = skill();
Assert.assertFalse(legacy.isResourcesInitialized());
repository.save(legacy);
Skill loaded = repository.get("skill-a").orElseThrow();
Assert.assertTrue(loaded.isResourcesInitialized());
Assert.assertEquals(1, SkillResources.canonicalResources(loaded).size());
Assert.assertEquals("references/a.md", loaded.getResources().get(0).getPath());
}
/**
* 显式清空正式资源列表后,仓储往返不得从旧兼容视图恢复已删除资源。
*/
@Test
public void repositoryCopyPreservesExplicitlyEmptyCanonicalResources() {
InMemorySkillRepository repository = new InMemorySkillRepository();
Skill skill = skill();
Assert.assertEquals(1, skill.getResources().size());
skill.getResources().clear();
repository.save(skill);
Skill loaded = repository.get("skill-a").orElseThrow();
Assert.assertTrue(loaded.isResourcesInitialized());
Assert.assertTrue(SkillResources.canonicalResources(loaded).isEmpty());
}
private static Skill skill() {
Skill skill = new Skill();
skill.setId("skill-a");
skill.setName("Skill A");
skill.setDescription("Desc A");
skill.setSkillContent("---\nname: Skill A\ndescription: Desc A\n---\n# Skill A\n");
SkillReference reference = new SkillReference();
reference.setPath("references/a.md");
reference.setName("a.md");
reference.setContent("# A");
skill.getReferences().add(reference);
return skill;
}
}

View File

@@ -4,7 +4,6 @@ import com.easyagents.skill.codec.ZipSkillPackageCodec;
import com.easyagents.skill.exception.SkillException;
import com.easyagents.skill.store.SkillContentStage;
import com.easyagents.skill.store.SkillContentStore;
import com.easyagents.skill.store.memory.InMemorySkillContentStore;
import com.easyagents.skill.util.SkillHashes;
import org.junit.Assert;
import org.junit.Test;
@@ -167,7 +166,6 @@ public class TemporaryFileSkillContentStoreTest {
ZipSkillPackageCodec defaultCodec = new ZipSkillPackageCodec();
SkillContentStore defaultStore = (SkillContentStore) contentStoreField.get(defaultCodec);
Assert.assertTrue(defaultStore instanceof TemporaryFileSkillContentStore);
Assert.assertFalse(defaultStore instanceof InMemorySkillContentStore);
Path defaultDirectory = ((TemporaryFileSkillContentStore) defaultStore).storageDirectory();
defaultCodec.close();
Assert.assertFalse(Files.exists(defaultDirectory));

View File

@@ -0,0 +1,124 @@
package com.easyagents.skill.store.memory;
import com.easyagents.skill.exception.SkillException;
import com.easyagents.skill.store.SkillContentStage;
import com.easyagents.skill.store.SkillContentStore;
import com.easyagents.skill.util.SkillHashes;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* Codec 测试使用的内存内容存储。
*/
public class InMemorySkillContentStore implements SkillContentStore {
private final Map<String, StoredContent> contents = new HashMap<>();
private final Map<String, byte[]> stages = new HashMap<>();
@Override
public String put(byte[] bytes) {
byte[] copy = bytes == null ? new byte[0] : Arrays.copyOf(bytes, bytes.length);
String ref = SkillHashes.sha256Ref(copy);
StoredContent stored = contents.get(ref);
if (stored == null) {
contents.put(ref, new StoredContent(copy));
} else {
stored.references++;
}
return ref;
}
@Override
public SkillContentStage stage(InputStream inputStream, long maxBytes) {
byte[] bytes = readBounded(inputStream, maxBytes);
String stageId = UUID.randomUUID().toString();
String hash = SkillHashes.sha256Hex(bytes);
stages.put(stageId, bytes);
return new SkillContentStage(stageId, "sha256:" + hash, hash, bytes.length);
}
@Override
public String commit(SkillContentStage stage) {
byte[] bytes = stage == null ? null : stages.remove(stage.getStageId());
if (bytes == null) {
throw new SkillException("Skill content stage does not exist.");
}
return put(bytes);
}
@Override
public void rollback(SkillContentStage stage) {
if (stage != null) {
stages.remove(stage.getStageId());
}
}
@Override
public void retain(String contentRef) {
StoredContent stored = require(contentRef);
stored.references++;
}
@Override
public void release(String contentRef) {
StoredContent stored = contents.get(contentRef);
if (stored != null && --stored.references <= 0) {
contents.remove(contentRef);
}
}
@Override
public InputStream open(String contentRef) {
StoredContent stored = require(contentRef);
return new ByteArrayInputStream(Arrays.copyOf(stored.bytes, stored.bytes.length));
}
@Override
public boolean exists(String contentRef) {
return contents.containsKey(contentRef);
}
private StoredContent require(String contentRef) {
StoredContent stored = contents.get(contentRef);
if (stored == null) {
throw new SkillException("Skill content does not exist: " + contentRef);
}
return stored;
}
private static byte[] readBounded(InputStream inputStream, long maxBytes) {
try {
ByteArrayOutputStream output = new ByteArrayOutputStream();
byte[] buffer = new byte[8_192];
long total = 0;
int read;
while ((read = inputStream.read(buffer)) >= 0) {
total += read;
if (total > maxBytes) {
throw new SkillException("Skill content exceeds limit.");
}
output.write(buffer, 0, read);
}
return output.toByteArray();
} catch (IOException exception) {
throw new SkillException("Failed to read Skill content.", exception);
}
}
private static final class StoredContent {
private final byte[] bytes;
private int references = 1;
private StoredContent(byte[] bytes) {
this.bytes = bytes;
}
}
}

View File

@@ -1,83 +0,0 @@
package com.easyagents.skill.store.memory;
import com.easyagents.skill.store.SkillContentStage;
import org.junit.Assert;
import org.junit.Test;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
/**
* InMemorySkillContentStore 单元测试。
*/
public class InMemorySkillContentStoreTest {
/**
* put 返回 sha256 内容引用,且相同内容引用相同。
*/
@Test
public void putReturnsStableSha256Ref() {
InMemorySkillContentStore store = new InMemorySkillContentStore();
String firstRef = store.put("abc".getBytes(StandardCharsets.UTF_8));
String secondRef = store.put("abc".getBytes(StandardCharsets.UTF_8));
Assert.assertTrue(firstRef.startsWith("sha256:"));
Assert.assertEquals(firstRef, secondRef);
}
/**
* open、readAllBytes 和 exists 正常工作。
*
* @throws Exception 读取流失败时抛出
*/
@Test
public void openReadAndExists() throws Exception {
InMemorySkillContentStore store = new InMemorySkillContentStore();
byte[] bytes = "abc".getBytes(StandardCharsets.UTF_8);
String contentRef = store.put(bytes);
Assert.assertTrue(store.exists(contentRef));
Assert.assertArrayEquals(bytes, store.readAllBytes(contentRef));
try (InputStream inputStream = store.open(contentRef)) {
Assert.assertArrayEquals(bytes, inputStream.readAllBytes());
}
}
/**
* 暂存内容在 commit 前不可见rollback 后不残留。
*/
@Test
public void stageCommitAndRollback() {
InMemorySkillContentStore store = new InMemorySkillContentStore();
byte[] bytes = "abc".getBytes(StandardCharsets.UTF_8);
SkillContentStage rolledBack = store.stage(new java.io.ByteArrayInputStream(bytes), bytes.length);
Assert.assertFalse(store.exists(rolledBack.getContentRef()));
store.rollback(rolledBack);
Assert.assertFalse(store.exists(rolledBack.getContentRef()));
SkillContentStage committed = store.stage(new java.io.ByteArrayInputStream(bytes), bytes.length);
String contentRef = store.commit(committed);
Assert.assertTrue(store.exists(contentRef));
Assert.assertArrayEquals(bytes, store.readAllBytes(contentRef));
}
/**
* 相同内容按引用计数释放,归零后删除。
*/
@Test
public void releaseDeletesOnlyAfterReferenceCountReachesZero() {
InMemorySkillContentStore store = new InMemorySkillContentStore();
byte[] bytes = "abc".getBytes(StandardCharsets.UTF_8);
String first = store.put(bytes);
String second = store.put(bytes);
store.release(first);
Assert.assertTrue(store.exists(second));
store.release(second);
Assert.assertFalse(store.exists(second));
}
}

View File

@@ -32,4 +32,23 @@ public class SkillPathsTest {
public void normalizeUnicodeToNfc() {
Assert.assertEquals("references/\u00e9.md", SkillPaths.normalize("references/e\u0301.md"));
}
/**
* 已识别的 macOS 系统元数据允许隐藏文件名,但仍执行路径规范化。
*/
@Test
public void normalizeIgnoredMacOsMetadata() {
Assert.assertEquals("__MACOSX/._docx-js.md",
SkillPaths.normalizeIgnoredSystemPath("__MACOSX/._docx-js.md"));
Assert.assertEquals("docx/.DS_Store",
SkillPaths.normalizeIgnoredSystemPath("docx/.DS_Store"));
}
/**
* 普通隐藏文件不能借用系统元数据规范化入口。
*/
@Test(expected = SkillValidationException.class)
public void rejectNonSystemHiddenPathThroughIgnoredNormalizer() {
SkillPaths.normalizeIgnoredSystemPath("docx/.env");
}
}

View File

@@ -1,6 +1,5 @@
package com.easyagents.skill.util;
import com.easyagents.skill.model.SkillResourceKind;
import org.junit.Assert;
import org.junit.Test;
@@ -15,8 +14,18 @@ public class SkillResourcesTest {
@Test
public void recognizeBothHtmlExtensionsAsText() {
Assert.assertTrue(SkillResources.isText(
"references/page.htm", SkillResourceKind.REFERENCE, "application/octet-stream"));
"references/page.htm", "application/octet-stream"));
Assert.assertTrue(SkillResources.isText(
"references/page.html", SkillResourceKind.REFERENCE, "application/octet-stream"));
"references/page.html", "application/octet-stream"));
}
/**
* 资源目录只表达语义分类,不决定文本或二进制存储。
*/
@Test
public void determineContentModeIndependentlyFromTopLevelDirectory() {
Assert.assertTrue(SkillResources.isText("assets/template.md", "application/octet-stream"));
Assert.assertTrue(SkillResources.isText("custom/data.csv", "application/octet-stream"));
Assert.assertFalse(SkillResources.isText("scripts/helper.bin", "application/octet-stream"));
}
}

View File

@@ -45,15 +45,15 @@ public class DefaultSkillValidatorTest {
String content = "---\nname: nested-skill\ndescription: Handles nested metadata\n"
+ "metadata:\n enabled: true\n retries: 3\n tags:\n - alpha\n - beta\n"
+ "---\n# Nested\n";
Skill skill = SkillFactory.create("repository-id", content);
Skill skill = SkillFactory.create(content);
validator.validate(skill);
Assert.assertTrue(skill.getMetadata().get("metadata") instanceof java.util.Map);
Assert.assertTrue(skill.getDocument().getFrontmatter().get("metadata") instanceof java.util.Map);
}
/**
* 结构化文档重新应用到聚合时同步 name、description 和 metadata
* 结构化文档更新后名称与描述从 frontmatter 派生
*/
@Test
public void applyEditedDocumentToAggregate() {
@@ -64,7 +64,8 @@ public class DefaultSkillValidatorTest {
skill.setDocument(document);
Assert.assertEquals("Updated description for the Skill", skill.getDescription());
Assert.assertEquals("Updated description for the Skill", skill.getMetadata().get("description"));
Assert.assertEquals("Updated description for the Skill",
skill.getDocument().getFrontmatter().get("description"));
validator.validate(skill);
}
@@ -98,20 +99,12 @@ public class DefaultSkillValidatorTest {
&& issue.getSeverity() == SkillValidationSeverity.ERROR));
}
/**
* 严格工厂入口执行正式标准名称校验。
*/
@Test(expected = SkillValidationException.class)
public void strictFactoryRejectsLegacyUnderscoreName() {
SkillFactory.createStrict("repository-id", skillMd("legacy_skill"));
}
/**
* AgentScope 嵌套 metadata 保真并标记标准兼容 warning。
*/
@Test
public void nestedMetadataProducesCompatibilityWarning() {
Skill skill = SkillFactory.create("id", "---\nname: metadata-skill\n"
Skill skill = SkillFactory.create("---\nname: metadata-skill\n"
+ "description: Nested metadata compatibility\nmetadata:\n provider:\n enabled: true\n"
+ "---\n# Metadata\n");
@@ -127,7 +120,7 @@ public class DefaultSkillValidatorTest {
*/
@Test
public void rejectInvalidOptionalStandardFields() {
Skill skill = SkillFactory.create("id", "---\nname: optional-skill\n"
Skill skill = SkillFactory.create("---\nname: optional-skill\n"
+ "description: Invalid optional fields\nlicense: []\ncompatibility: ''\nallowed-tools:\n - Read\n"
+ "---\n# Optional\n");
@@ -261,10 +254,10 @@ public class DefaultSkillValidatorTest {
}
/**
* scripts 目录只接受严格 UTF-8 文本表示,二进制引用必须被拒绝
* 文本扩展名必须使用严格 UTF-8 文本表示,与所在目录无关
*/
@Test
public void rejectBinaryScriptResource() {
public void rejectBinaryRepresentationForTextExtension() {
Skill skill = validSkill("skill-a");
SkillResource script = binaryResource("scripts/run.sh", "echo unsafe");
script.setKind(SkillResourceKind.SCRIPT);
@@ -273,23 +266,37 @@ public class DefaultSkillValidatorTest {
SkillValidationReport report = validator.validateReport(skill);
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
"SCRIPT_TEXT_REQUIRED".equals(issue.getCode())
"RESOURCE_CONTENT_MODE_MISMATCH".equals(issue.getCode())
&& issue.getSeverity() == SkillValidationSeverity.ERROR));
}
/**
* 资源的文本或二进制表示必须与统一路径和媒体类型判定一致
* assets 目录中的文本资源使用规范文本表示
*/
@Test
public void rejectNonCanonicalResourceContentMode() {
public void acceptTextResourceInAssetsDirectory() {
Skill skill = validSkill("skill-a");
SkillResource textAsset = textResource("assets/readme.txt", SkillResourceKind.ASSET, "text asset");
skill.setResources(java.util.List.of(textAsset));
SkillValidationReport report = validator.validateReport(skill);
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
"RESOURCE_CONTENT_MODE_MISMATCH".equals(issue.getCode())));
Assert.assertFalse(report.hasErrors());
}
/**
* scripts 目录允许保存不透明二进制辅助文件。
*/
@Test
public void acceptBinaryResourceInScriptsDirectory() {
Skill skill = validSkill("skill-a");
SkillResource binaryScript = binaryResource("scripts/helper.bin", "opaque");
binaryScript.setKind(SkillResourceKind.SCRIPT);
skill.setResources(java.util.List.of(binaryScript));
SkillValidationReport report = validator.validateReport(skill);
Assert.assertFalse(report.hasErrors());
}
/**
@@ -309,23 +316,6 @@ public class DefaultSkillValidatorTest {
&& issue.getSeverity() == SkillValidationSeverity.WARNING));
}
/**
* 未识别脚本语言需要提示,但不能破坏外部标准 Skill 包的资源保真。
*/
@Test
public void warnForUnrecognizedScriptLanguageExtension() {
Skill skill = validSkill("skill-a");
skill.setResources(java.util.List.of(
textResource("scripts/run.txt", SkillResourceKind.SCRIPT, "echo ok")));
SkillValidationReport report = validator.validateReport(skill);
Assert.assertFalse(report.hasErrors());
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
"SCRIPT_LANGUAGE_UNRECOGNIZED".equals(issue.getCode())
&& issue.getSeverity() == SkillValidationSeverity.WARNING));
}
/**
* 结构化校验器执行资源单文件安全限额。
*/
@@ -361,19 +351,8 @@ public class DefaultSkillValidatorTest {
Assert.assertFalse(report.hasErrors());
}
/**
* 元数据与 SKILL.md 不一致时失败。
*/
@Test(expected = SkillValidationException.class)
public void rejectMetadataMismatch() {
Skill skill = validSkill("skill-a");
skill.getMetadata().put("extra", "value");
validator.validate(skill);
}
private static Skill validSkill(String name) {
return SkillFactory.create("repository-id", skillMd(name));
return SkillFactory.create(skillMd(name));
}
private static String skillMd(String name) {

15
pom.xml
View File

@@ -29,12 +29,13 @@
<module>easy-agents-mcp</module>
<module>easy-agents-skill</module>
<module>easy-agents-agent-runtime</module>
<module>easy-agents-agui</module>
<module>easy-agents-flow</module>
<module>easy-agents-support</module>
</modules>
<properties>
<revision>1.1.0-RC</revision>
<revision>1.1.0</revision>
<maven.compiler.release>17</maven.compiler.release>
<maven-flatten.version>1.3.0</maven-flatten.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -456,12 +457,24 @@
<version>${revision}</version>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-agui</artifactId>
<version>${revision}</version>
</dependency>
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope</artifactId>
<version>${agentscope.version}</version>
</dependency>
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-extensions-agui</artifactId>
<version>${agentscope.version}</version>
</dependency>
</dependencies>
</dependencyManagement>