Compare commits

22 Commits

Author SHA1 Message Date
45c708a212 feat: 支持工作流多知识库向量检索 2026-09-04 17:41:15 +08:00
e959a772b5 feat: 重构用户确认节点选择与恢复契约
- 统一单选多选输出与结构化暂停参数

- 增加严格恢复校验及并发状态保护

- 补充确认节点契约与恢复测试
2026-09-04 14:57:03 +08:00
f311822a3d perf: 复用 Milvus 客户端连接池 2026-09-04 11:34:52 +08:00
2cdbc57174 fix: 暴露本地文档解析任务丢失状态 2026-09-04 11:34:21 +08:00
4bc68ec7f7 fix: 拦截非标准 XLSX 文件
- 在 POI 解析前识别旧版 XLS 与异常容器

- 返回可操作提示并补充回归测试
2026-09-02 19:15:34 +08:00
93e0ff2204 fix: 修复 Lucene 特殊字符查询失败
- 将用户关键词按普通文本转义后再解析

- 避免空查询和解析失败触发二次空指针

- 补充特殊字符与空查询回归测试
2026-09-01 15:12:15 +08:00
ea67d0519f feat: M28 支持工作流多入边汇聚模式 2026-08-31 15:56:05 +08:00
2d26494775 feat: 增强 Agentic RAG 主动检索引导
- 统一组合用户、知识库与异步工具系统提示词

- 补充知识库调用策略与运行时回归测试
2026-08-29 17:01:44 +08:00
651b292d83 feat: 支持一库一工具 Agentic RAG
- 将知识库声明与 Retriever 绑定为独立 Registration 并注册到 AgentScope Toolkit

- 统一检索分数、最终文档事件与长答案引用语义

- 补齐历史会话、工具类加载器和知识库调用状态回归测试
2026-08-29 15:44:43 +08:00
67b69669c4 fix: 保留模型异常后的会话上下文
- 保存模型失败与无正文取消路径中的 AgentScope 会话

- 补充失败和推理中断场景的上下文恢复测试
2026-08-26 22:56:29 +08:00
6192df4bd1 Merge pull request '发布 v1.1.0' (#2) from develop into main
Reviewed-on: #2
2026-08-20 11:35:40 +08:00
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
bb37d9d708 Merge pull request 'fix: 修复增加 FAQ 报错的 bug' (#1) from hotfix/embedding_error into main
Reviewed-on: #1
2026-06-14 16:19:44 +08:00
149 changed files with 16847 additions and 3225 deletions

View File

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

View File

@@ -1,14 +1,17 @@
package com.easyagents.agent.runtime; package com.easyagents.agent.runtime;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever; import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
import com.easyagents.agent.runtime.media.AgentMediaResolver; import com.easyagents.agent.runtime.media.AgentMediaResolver;
import com.easyagents.agent.runtime.memory.AgentMemorySnapshot;
import com.easyagents.agent.runtime.persistence.conversation.AgentConversationRecorder; import com.easyagents.agent.runtime.persistence.conversation.AgentConversationRecorder;
import com.easyagents.agent.runtime.persistence.conversation.noop.NoopAgentConversationRecorder; import com.easyagents.agent.runtime.persistence.conversation.noop.NoopAgentConversationRecorder;
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore; import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore; import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore;
import com.easyagents.agent.runtime.tool.AgentToolInvoker; import com.easyagents.agent.runtime.tool.AgentToolInvoker;
import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@@ -44,7 +47,12 @@ public class AgentInitRequest {
/** /**
* 知识库集合实现AgentKnowledgeRetriever接口以进行知识检索动作。 * 知识库集合实现AgentKnowledgeRetriever接口以进行知识检索动作。
*/ */
private Map<String, AgentKnowledgeRetriever> knowledgeRetrievers = new LinkedHashMap<>(); private List<AgentKnowledgeRegistration> knowledgeRegistrations = new ArrayList<>();
/**
* 首次构建 Agent 时装载的对话历史快照。
*/
private AgentMemorySnapshot memorySnapshot = new AgentMemorySnapshot();
/** /**
* 对话事件记录器,用于记录运行时事件流。 * 对话事件记录器,用于记录运行时事件流。
@@ -156,17 +164,37 @@ public class AgentInitRequest {
* *
* @return 知识库检索器 * @return 知识库检索器
*/ */
public Map<String, AgentKnowledgeRetriever> getKnowledgeRetrievers() { public List<AgentKnowledgeRegistration> getKnowledgeRegistrations() {
return knowledgeRetrievers; return knowledgeRegistrations;
} }
/** /**
* 设置知识库检索器。 * 设置知识库检索器。
* *
* @param knowledgeRetrievers 知识库检索器 * @param knowledgeRegistrations 知识库运行时绑定
*/ */
public void setKnowledgeRetrievers(Map<String, AgentKnowledgeRetriever> knowledgeRetrievers) { public void setKnowledgeRegistrations(List<AgentKnowledgeRegistration> knowledgeRegistrations) {
this.knowledgeRetrievers = knowledgeRetrievers == null ? new LinkedHashMap<>() : knowledgeRetrievers; this.knowledgeRegistrations = knowledgeRegistrations == null
? new ArrayList<>()
: new ArrayList<>(knowledgeRegistrations);
}
/**
* 获取首次构建 Agent 时的对话历史快照。
*
* @return 对话历史快照
*/
public AgentMemorySnapshot getMemorySnapshot() {
return memorySnapshot;
}
/**
* 设置首次构建 Agent 时的对话历史快照。
*
* @param memorySnapshot 对话历史快照
*/
public void setMemorySnapshot(AgentMemorySnapshot memorySnapshot) {
this.memorySnapshot = memorySnapshot == null ? new AgentMemorySnapshot() : memorySnapshot;
} }
/** /**

View File

@@ -1,6 +1,6 @@
package com.easyagents.agent.runtime; package com.easyagents.agent.runtime;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever; import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
import com.easyagents.agent.runtime.memory.AgentMemorySnapshot; import com.easyagents.agent.runtime.memory.AgentMemorySnapshot;
import com.easyagents.agent.runtime.message.AgentMessage; import com.easyagents.agent.runtime.message.AgentMessage;
import com.easyagents.agent.runtime.persistence.conversation.AgentConversationRecorder; import com.easyagents.agent.runtime.persistence.conversation.AgentConversationRecorder;
@@ -9,7 +9,9 @@ import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore; import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore;
import com.easyagents.agent.runtime.tool.AgentToolInvoker; import com.easyagents.agent.runtime.tool.AgentToolInvoker;
import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@@ -60,7 +62,7 @@ public class AgentRuntimeExecutionContext {
/** /**
* 按知识库ID索引的检索器。 * 按知识库ID索引的检索器。
*/ */
private Map<String, AgentKnowledgeRetriever> knowledgeRetrievers = new LinkedHashMap<>(); private List<AgentKnowledgeRegistration> knowledgeRegistrations = new ArrayList<>();
/** /**
* 会话状态存储。 * 会话状态存储。
@@ -231,17 +233,19 @@ public class AgentRuntimeExecutionContext {
* *
* @return 知识库检索器 * @return 知识库检索器
*/ */
public Map<String, AgentKnowledgeRetriever> getKnowledgeRetrievers() { public List<AgentKnowledgeRegistration> getKnowledgeRegistrations() {
return knowledgeRetrievers; return knowledgeRegistrations;
} }
/** /**
* 设置知识库检索器。 * 设置知识库检索器。
* *
* @param knowledgeRetrievers 知识库检索器 * @param knowledgeRegistrations 知识库运行时绑定
*/ */
public void setKnowledgeRetrievers(Map<String, AgentKnowledgeRetriever> knowledgeRetrievers) { public void setKnowledgeRegistrations(List<AgentKnowledgeRegistration> knowledgeRegistrations) {
this.knowledgeRetrievers = knowledgeRetrievers == null ? new LinkedHashMap<>() : knowledgeRetrievers; this.knowledgeRegistrations = knowledgeRegistrations == null
? new ArrayList<>()
: new ArrayList<>(knowledgeRegistrations);
} }
/** /**

View File

@@ -2,309 +2,452 @@ package com.easyagents.agent.runtime.agentscope;
import com.easyagents.agent.runtime.AgentRuntimeException; import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.AgentRuntimeExecutionContext; import com.easyagents.agent.runtime.AgentRuntimeExecutionContext;
import com.easyagents.agent.runtime.event.*; import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.knowledge.*; import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import io.agentscope.core.message.TextBlock; import com.easyagents.agent.runtime.event.AgentRuntimeTurnContextHolder;
import io.agentscope.core.rag.Knowledge; import com.easyagents.agent.runtime.hitl.AgentToolApprovalCoordinator;
import io.agentscope.core.rag.model.Document; import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
import io.agentscope.core.rag.model.DocumentMetadata; import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
import io.agentscope.core.rag.model.RetrieveConfig; import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalRequest;
import reactor.core.publisher.Mono; import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
import reactor.core.publisher.Sinks; import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeToolNames;
import com.easyagents.agent.runtime.tool.AgentToolCategory;
import com.easyagents.agent.runtime.tool.AgentToolContext;
import com.easyagents.agent.runtime.tool.AgentToolResult;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import io.agentscope.core.tool.Toolkit;
import java.util.*; import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/** /**
* 将运行时知识库检索器适配为一个聚合 AgentScope Knowledge * 将中立知识库绑定适配为 AgentScope 一库一工具
*/ */
public class AgentScopeKnowledgeAdapter { public class AgentScopeKnowledgeAdapter {
/** /**
* 创建聚合 Knowledge * 根据 Agent 知识库声明创建模型可见工具定义
* *
* @param request 运行请求 * @param context 运行时上下文
* @return 聚合 Knowledge未配置知识库时返回 null * @return 知识库工具定义
* @throws AgentRuntimeException 声明、运行名或 Retriever 绑定不合法时抛出
*/ */
public Knowledge createAggregateKnowledge(AgentRuntimeExecutionContext request) { public List<AgentToolSpec> createToolSpecs(AgentRuntimeExecutionContext context) {
return createAggregateKnowledge(request, (Sinks.Many<AgentRuntimeEvent>) null); if (context == null || context.getAgentDefinition() == null) {
} throw new AgentRuntimeException("Agent runtime context and definition are required for knowledge tools.");
/**
* 创建带事件 sink 的聚合 Knowledge。
*
* @param request 运行请求
* @param eventSink 事件 sink
* @return 聚合 Knowledge未配置知识库时返回 null
*/
public Knowledge createAggregateKnowledge(AgentRuntimeExecutionContext request, Sinks.Many<AgentRuntimeEvent> eventSink) {
return createAggregateKnowledge(request, fixedHolder(request, eventSink));
}
/**
* 创建可读取当前运行轮次事件出口的聚合 Knowledge。
*
* @param request 运行时级上下文
* @param turnContextHolder 当前运行轮次上下文持有器
* @return 聚合 Knowledge未配置知识库时返回 null
*/
public Knowledge createAggregateKnowledge(AgentRuntimeExecutionContext request,
AgentRuntimeTurnContextHolder turnContextHolder) {
if (request.getAgentDefinition().getKnowledgeSpecs().isEmpty()) {
return null;
} }
return new AggregateKnowledge(request, turnContextHolder); List<AgentKnowledgeSpec> knowledgeSpecs = context.getAgentDefinition().getKnowledgeSpecs();
} List<AgentKnowledgeRegistration> registrations = context.getKnowledgeRegistrations();
if (knowledgeSpecs == null || knowledgeSpecs.isEmpty()) {
private AgentRuntimeTurnContextHolder fixedHolder(AgentRuntimeExecutionContext request, if (registrations != null && !registrations.isEmpty()) {
Sinks.Many<AgentRuntimeEvent> eventSink) { throw new AgentRuntimeException("Knowledge registrations require matching knowledge specs.");
AgentRuntimeTurnContextHolder holder = new AgentRuntimeTurnContextHolder(); }
AgentRuntimeEventBridge bridge = new AgentRuntimeEventBridge(request, holder); return List.of();
holder.set(new AgentRuntimeTurnContext(null, eventSink, bridge)); }
return holder; Map<String, AgentKnowledgeRegistration> registrationIndex = registrationIndex(registrations);
if (registrationIndex.size() != knowledgeSpecs.size()) {
throw new AgentRuntimeException("Knowledge specs and registrations must match one-to-one.");
}
List<AgentToolSpec> toolSpecs = new ArrayList<>(knowledgeSpecs.size());
Set<String> toolNames = new LinkedHashSet<>();
for (AgentKnowledgeSpec knowledgeSpec : knowledgeSpecs) {
validateKnowledgeSpec(knowledgeSpec);
AgentKnowledgeRegistration registration = registrationIndex.get(knowledgeSpec.getKnowledgeId());
if (registration == null) {
throw new AgentRuntimeException(
"Knowledge retriever is required: " + knowledgeSpec.getKnowledgeId());
}
String toolName = AgentKnowledgeToolNames.build(knowledgeSpec.getRuntimeName());
if (!toolNames.add(toolName)) {
throw new AgentRuntimeException("Duplicate knowledge tool name: " + toolName);
}
toolSpecs.add(toolSpec(knowledgeSpec, toolName));
}
return toolSpecs;
} }
/** /**
* 将运行时文档转换为 AgentScope 文档 * 将知识库工具注册到现有 AgentScope Toolkit
* *
* @param documents 运行时文 * @param context 运行时上下
* @return AgentScope 文档 * @param toolSpecs 知识库工具定义
* @param toolkit AgentScope Toolkit
* @param toolAdapter 中立工具适配器
* @param approvalCoordinator 工具审批协调器
* @param turnContextHolder 当前运行轮次上下文持有器
*/ */
public List<Document> toDocuments(List<AgentKnowledgeDocument> documents) { public void registerTools(AgentRuntimeExecutionContext context,
List<Document> converted = new ArrayList<>(); List<AgentToolSpec> toolSpecs,
Toolkit toolkit,
AgentScopeToolAdapter toolAdapter,
AgentToolApprovalCoordinator approvalCoordinator,
AgentRuntimeTurnContextHolder turnContextHolder) {
Objects.requireNonNull(toolkit, "toolkit");
Objects.requireNonNull(toolAdapter, "toolAdapter");
if (toolSpecs == null || toolSpecs.isEmpty()) {
return;
}
Map<String, AgentKnowledgeRegistration> registrationIndex =
registrationIndex(context.getKnowledgeRegistrations());
for (AgentToolSpec toolSpec : toolSpecs) {
String knowledgeId = stringValue(toolSpec.getMetadata().get("knowledgeId"));
AgentKnowledgeRegistration registration = registrationIndex.get(knowledgeId);
if (registration == null) {
throw new AgentRuntimeException("Knowledge registration is required: " + knowledgeId);
}
toolkit.registerAgentTool(toolAdapter.adapt(
toolSpec,
(input, toolContext) -> retrieve(registration, input, toolContext),
context,
approvalCoordinator,
turnContextHolder,
null,
null,
false,
false,
false));
}
}
/**
* 按知识库 ID 建立运行时绑定索引并拒绝重复绑定。
*
* @param registrations 知识库运行时绑定
* @return 以知识库 ID 为键的绑定索引
*/
private Map<String, AgentKnowledgeRegistration> registrationIndex(
List<AgentKnowledgeRegistration> registrations) {
Map<String, AgentKnowledgeRegistration> index = new LinkedHashMap<>();
if (registrations == null) {
return index;
}
for (AgentKnowledgeRegistration registration : registrations) {
if (registration == null || registration.getKnowledgeSpec() == null) {
throw new AgentRuntimeException("Knowledge registration and spec are required.");
}
String knowledgeId = registration.getKnowledgeSpec().getKnowledgeId();
if (knowledgeId == null || knowledgeId.isBlank()) {
throw new AgentRuntimeException("Knowledge id is required.");
}
if (index.putIfAbsent(knowledgeId, registration) != null) {
throw new AgentRuntimeException("Duplicate knowledge registration: " + knowledgeId);
}
}
return index;
}
/**
* 校验知识库声明的标识和英文运行名。
*
* @param knowledgeSpec 知识库声明
* @throws AgentRuntimeException 声明不合法时抛出
*/
private void validateKnowledgeSpec(AgentKnowledgeSpec knowledgeSpec) {
if (knowledgeSpec == null) {
throw new AgentRuntimeException("Knowledge spec is required.");
}
if (knowledgeSpec.getKnowledgeId() == null || knowledgeSpec.getKnowledgeId().isBlank()) {
throw new AgentRuntimeException("Knowledge id is required.");
}
AgentKnowledgeToolNames.build(knowledgeSpec.getRuntimeName());
}
/**
* 创建单个知识库对应的模型工具声明。
*
* @param knowledgeSpec 知识库声明
* @param toolName 已规范化的工具名
* @return 模型可见工具声明
*/
private AgentToolSpec toolSpec(AgentKnowledgeSpec knowledgeSpec, String toolName) {
AgentToolSpec toolSpec = new AgentToolSpec();
toolSpec.setName(toolName);
toolSpec.setDescription(toolDescription(knowledgeSpec));
toolSpec.setCategory(AgentToolCategory.KNOWLEDGE);
toolSpec.setParametersSchema(Map.of(
"type", "object",
"properties", Map.of(
"query", Map.of(
"type", "string",
"description", "A standalone search query containing all context needed to retrieve relevant knowledge."
)
),
"required", List.of("query"),
"additionalProperties", false
));
toolSpec.getMetadata().putAll(knowledgeSpec.getMetadata());
toolSpec.getMetadata().put("knowledgeId", knowledgeSpec.getKnowledgeId());
toolSpec.getMetadata().put("knowledgeName", knowledgeSpec.getName());
toolSpec.getMetadata().put("knowledgeRuntimeName", knowledgeSpec.getRuntimeName());
toolSpec.getMetadata().put("toolDisplayName", knowledgeSpec.getName());
return toolSpec;
}
/**
* 生成包含知识库名称、范围和查询要求的工具描述。
*
* @param knowledgeSpec 知识库声明
* @return 模型可见工具描述
*/
private String toolDescription(AgentKnowledgeSpec knowledgeSpec) {
String knowledgeName = hasText(knowledgeSpec.getName())
? knowledgeSpec.getName().trim()
: knowledgeSpec.getRuntimeName().trim();
StringBuilder description = new StringBuilder()
.append("Search the knowledge base \"")
.append(knowledgeName)
.append("\" for relevant and grounded information.");
if (hasText(knowledgeSpec.getDescription())) {
description.append(" Knowledge scope: ")
.append(knowledgeSpec.getDescription().trim())
.append('.');
}
description.append(" Build a standalone query from the current question and necessary conversation context.");
return description.toString();
}
/**
* 执行单库检索并生成模型证据及旁路检索事件。
*
* @param registration 知识库运行时绑定
* @param input Function Call 输入
* @param toolContext 工具执行上下文
* @return 模型可见工具结果
*/
private AgentToolResult retrieve(AgentKnowledgeRegistration registration,
Map<String, Object> input,
AgentToolContext toolContext) {
AgentKnowledgeSpec knowledgeSpec = registration.getKnowledgeSpec();
String query = input == null ? null : stringValue(input.get("query"));
if (query == null || query.isBlank()) {
throw new AgentRuntimeException("Knowledge tool query is required: "
+ AgentKnowledgeToolNames.build(knowledgeSpec.getRuntimeName()));
}
String normalizedQuery = query.trim();
AgentKnowledgeRetrievalRequest retrievalRequest = new AgentKnowledgeRetrievalRequest();
retrievalRequest.setQuery(normalizedQuery);
retrievalRequest.setLimit(knowledgeSpec.getLimit());
retrievalRequest.setScoreThreshold(knowledgeSpec.getScoreThreshold());
retrievalRequest.setKnowledgeSpec(knowledgeSpec);
retrievalRequest.setRuntimeContext(toolContext.getRuntimeContext());
retrievalRequest.getMetadata().put("requestId", toolContext.getRequestId());
retrievalRequest.getMetadata().put("traceId", toolContext.getTraceId());
retrievalRequest.getMetadata().put("sessionId", toolContext.getSessionId());
retrievalRequest.getMetadata().put("toolCallId", toolContext.getToolCallId());
AgentKnowledgeRetrievalResult retrievalResult = registration.getRetriever().retrieve(retrievalRequest);
if (retrievalResult == null) {
throw new AgentRuntimeException("Knowledge retriever returned null result: "
+ knowledgeSpec.getKnowledgeId());
}
List<AgentKnowledgeDocument> documents = finalDocuments(knowledgeSpec, retrievalResult.getDocuments());
toolContext.emitEvent(retrievalEvent(toolContext, knowledgeSpec, retrievalRequest, documents));
List<Map<String, Object>> summaries = documentSummaries(documents);
AgentToolResult toolResult = AgentToolResult.success(modelContent(knowledgeSpec, normalizedQuery, documents));
toolResult.setDisplayContent(summaries);
toolResult.getMetadata().putAll(retrievalResult.getMetadata());
toolResult.getMetadata().put("knowledgeId", knowledgeSpec.getKnowledgeId());
toolResult.getMetadata().put("knowledgeName", knowledgeSpec.getName());
toolResult.getMetadata().put("query", normalizedQuery);
toolResult.getMetadata().put("documentCount", documents.size());
toolResult.getMetadata().put("documents", summaries);
return toolResult;
}
/**
* 对检索结果执行统一阈值过滤、降序排序和绑定数量截断。
*
* @param knowledgeSpec 知识库声明
* @param documents 原始检索文档
* @return 最终进入模型上下文的文档
*/
private List<AgentKnowledgeDocument> finalDocuments(AgentKnowledgeSpec knowledgeSpec,
List<AgentKnowledgeDocument> documents) {
List<AgentKnowledgeDocument> finalDocuments = new ArrayList<>();
if (documents == null) { if (documents == null) {
return converted; return finalDocuments;
} }
for (AgentKnowledgeDocument document : documents) { for (AgentKnowledgeDocument document : documents) {
converted.add(toDocument(document)); if (document == null || !passesThreshold(document, knowledgeSpec.getScoreThreshold())) {
continue;
}
preserveKnowledgeMetadata(knowledgeSpec, document);
finalDocuments.add(document);
} }
return converted; finalDocuments.sort(Comparator.comparing(
} AgentKnowledgeDocument::getScore,
Comparator.nullsLast(Comparator.reverseOrder())));
private Document toDocument(AgentKnowledgeDocument document) { int limit = Math.max(knowledgeSpec.getLimit(), 1);
Map<String, Object> payload = new LinkedHashMap<>(); if (finalDocuments.size() > limit) {
payload.put("documentId", document.getDocumentId()); return new ArrayList<>(finalDocuments.subList(0, limit));
payload.put("documentName", document.getDocumentName()); }
payload.put("chunkId", document.getChunkId()); return finalDocuments;
payload.put("sourceUri", document.getSourceUri());
payload.put("knowledgeMetadata", document.getKnowledgeMetadata());
payload.put("documentMetadata", document.getMetadata());
payload.putAll(document.getMetadata());
DocumentMetadata metadata = DocumentMetadata.builder()
.content(TextBlock.builder().text(safeContent(document)).build())
.docId(safeDocumentId(document))
.chunkId(safeChunkId(document))
.payload(payload)
.build();
Document converted = new Document(metadata);
converted.setScore(document.getScore());
return converted;
} }
/** /**
* 获取 AgentScope 要求的非空文档 ID * 判断文档最终分数是否达到绑定阈值
* *
* @param document 知识文档 * @param document 检索文档
* @return 非空文档 ID * @param scoreThreshold 分数阈值
* @return 达到阈值时为 true
*/ */
private String safeDocumentId(AgentKnowledgeDocument document) { private boolean passesThreshold(AgentKnowledgeDocument document, double scoreThreshold) {
if (document.getDocumentId() != null && !document.getDocumentId().isBlank()) { if (scoreThreshold <= 0D) {
return document.getDocumentId(); return true;
} }
if (document.getChunkId() != null && !document.getChunkId().isBlank()) { return document.getScore() != null && document.getScore() >= scoreThreshold;
return document.getChunkId();
}
return "knowledge-document";
} }
/** /**
* 获取 AgentScope 要求的非空分片 ID * 将知识库归属信息合并到文档元数据中
* *
* @param document 知识文档 * @param knowledgeSpec 知识库声明
* @return 非空分片 ID * @param document 检索文档
*/ */
private String safeChunkId(AgentKnowledgeDocument document) { private void preserveKnowledgeMetadata(AgentKnowledgeSpec knowledgeSpec,
if (document.getChunkId() != null && !document.getChunkId().isBlank()) { AgentKnowledgeDocument document) {
return document.getChunkId(); Map<String, Object> knowledgeMetadata = new LinkedHashMap<>(knowledgeSpec.getMetadata());
} knowledgeMetadata.put("knowledgeId", knowledgeSpec.getKnowledgeId());
if (document.getDocumentId() != null && !document.getDocumentId().isBlank()) { knowledgeMetadata.put("knowledgeName", knowledgeSpec.getName());
return document.getDocumentId(); knowledgeMetadata.put("knowledgeRuntimeName", knowledgeSpec.getRuntimeName());
} knowledgeMetadata.putAll(document.getKnowledgeMetadata());
return "0"; document.setKnowledgeMetadata(knowledgeMetadata);
document.getMetadata().putIfAbsent("knowledgeId", knowledgeSpec.getKnowledgeId());
document.getMetadata().putIfAbsent("knowledgeName", knowledgeSpec.getName());
} }
/** /**
* 获取 AgentScope 要求的非空文档内容 * 创建与模型最终证据一致的知识库检索事件
* *
* @param document 知识文档 * @param toolContext 工具执行上下文
* @return 文档内容 * @param knowledgeSpec 知识库声明
* @param retrievalRequest 检索请求
* @param documents 最终文档
* @return 检索旁路事件
*/ */
private String safeContent(AgentKnowledgeDocument document) { private AgentRuntimeEvent retrievalEvent(AgentToolContext toolContext,
return document.getContent() == null ? "" : document.getContent(); AgentKnowledgeSpec knowledgeSpec,
AgentKnowledgeRetrievalRequest retrievalRequest,
List<AgentKnowledgeDocument> documents) {
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL);
event.setToolCallId(toolContext.getToolCallId());
event.getPayload().put("query", retrievalRequest.getQuery());
event.getPayload().put("knowledgeId", knowledgeSpec.getKnowledgeId());
event.getPayload().put("knowledgeName", knowledgeSpec.getName());
event.getPayload().put("knowledgeType", knowledgeSpec.getMetadata().get("knowledgeType"));
event.getPayload().put("faqCollection", knowledgeSpec.getMetadata().get("faqCollection"));
event.getPayload().put("limit", retrievalRequest.getLimit());
event.getPayload().put("scoreThreshold", retrievalRequest.getScoreThreshold());
event.getPayload().put("documentCount", documents.size());
event.getPayload().put("documents", documentSummaries(documents));
event.getMetadata().put("toolName", AgentKnowledgeToolNames.build(knowledgeSpec.getRuntimeName()));
event.getMetadata().put("knowledgeRuntimeName", knowledgeSpec.getRuntimeName());
return event;
} }
/** /**
* 将检索调用分发到多个知识源的聚合 Knowledge 实现 * 将最终文档转换为 UI 和完成消息引用可消费的稳定摘要
*
* @param documents 最终文档
* @return 文档摘要列表
*/ */
private class AggregateKnowledge implements Knowledge { private List<Map<String, Object>> documentSummaries(List<AgentKnowledgeDocument> documents) {
List<Map<String, Object>> summaries = new ArrayList<>();
private final AgentRuntimeExecutionContext request; if (documents == null) {
private final AgentRuntimeTurnContextHolder turnContextHolder;
private AggregateKnowledge(AgentRuntimeExecutionContext request, AgentRuntimeTurnContextHolder turnContextHolder) {
this.request = request;
this.turnContextHolder = turnContextHolder;
}
/**
* 忽略文档新增,因为知识库索引由 EasyFlow 负责。
*
* @param documents 文档列表
* @return 完成信号
*/
@Override
public Mono<Void> addDocuments(List<Document> documents) {
return Mono.error(new UnsupportedOperationException(
"Easy-Agents agent runtime knowledge does not support addDocuments. Use external knowledge service instead."));
}
/**
* 从已配置的知识源检索文档。
*
* @param query 查询
* @param config 检索配置
* @return 文档列表
*/
@Override
public Mono<List<Document>> retrieve(String query, RetrieveConfig config) {
return Mono.fromCallable(() -> retrieveAll(query, config));
}
/**
* 检索并合并所有已配置的知识源。
*
* @param query 查询
* @param config 检索配置
* @return 合并后的文档
*/
private List<Document> retrieveAll(String query, RetrieveConfig config) {
List<AgentKnowledgeDocument> allDocuments = new ArrayList<>();
int globalLimit = config == null || config.getLimit() <= 0 ? 5 : config.getLimit();
double globalThreshold = config == null ? 0D : config.getScoreThreshold();
for (AgentKnowledgeSpec spec : request.getAgentDefinition().getKnowledgeSpecs()) {
AgentKnowledgeRetriever retriever = request.getKnowledgeRetrievers().get(spec.getKnowledgeId());
if (retriever == null) {
throw new AgentRuntimeException("Knowledge retriever is required: " + spec.getKnowledgeId());
}
AgentKnowledgeRetrievalRequest retrievalRequest = new AgentKnowledgeRetrievalRequest();
retrievalRequest.setQuery(query);
retrievalRequest.setLimit(spec.getLimit());
retrievalRequest.setScoreThreshold(Math.max(spec.getScoreThreshold(), globalThreshold));
retrievalRequest.setKnowledgeSpec(spec);
AgentRuntimeExecutionContext currentRequest = currentRequest();
retrievalRequest.setRuntimeContext(currentRequest.getRuntimeContext());
retrievalRequest.getMetadata().put("traceId", currentRequest.getTraceId());
retrievalRequest.getMetadata().put("sessionId", currentRequest.getSessionId());
AgentKnowledgeRetrievalResult result = retriever.retrieve(retrievalRequest);
if (result == null || result.getDocuments() == null) {
emitKnowledgeRetrievalEvent(query, spec, retrievalRequest, new ArrayList<>());
continue;
}
emitKnowledgeRetrievalEvent(query, spec, retrievalRequest, result.getDocuments());
for (AgentKnowledgeDocument document : result.getDocuments()) {
preserveKnowledgeMetadata(spec, document);
allDocuments.add(document);
}
}
allDocuments.sort(Comparator.comparing(
AgentKnowledgeDocument::getScore,
Comparator.nullsLast(Comparator.reverseOrder())
));
if (allDocuments.size() > globalLimit) {
allDocuments = new ArrayList<>(allDocuments.subList(0, globalLimit));
}
return toDocuments(allDocuments);
}
/**
* 在单条文档上保留知识库级元数据。
*
* @param spec 知识库声明
* @param document 文档
*/
private void preserveKnowledgeMetadata(AgentKnowledgeSpec spec, AgentKnowledgeDocument document) {
Map<String, Object> knowledgeMetadata = new LinkedHashMap<>(spec.getMetadata());
knowledgeMetadata.put("knowledgeId", spec.getKnowledgeId());
knowledgeMetadata.put("knowledgeName", spec.getName());
knowledgeMetadata.put("retrievalMode", spec.getRetrievalMode().name());
knowledgeMetadata.putAll(document.getKnowledgeMetadata());
document.setKnowledgeMetadata(knowledgeMetadata);
document.getMetadata().putIfAbsent("knowledgeId", spec.getKnowledgeId());
document.getMetadata().putIfAbsent("knowledgeName", spec.getName());
}
/**
* 发射知识库检索旁路事件,供聊天界面展示检索过程。
*
* <p>知识库检索本身属于 AgentScope RAG 主线路,返回的 Document 会继续进入
* AgentScope 的上下文注入流程;这里发出的 {@code KNOWLEDGE_RETRIEVAL}
* 只是旁路告知调用方,不会回写 memory也不会参与模型消息序列。</p>
*
* @param query 查询
* @param spec 知识库声明
* @param retrievalRequest 检索请求
* @param documents 检索文档
*/
private void emitKnowledgeRetrievalEvent(String query,
AgentKnowledgeSpec spec,
AgentKnowledgeRetrievalRequest retrievalRequest,
List<AgentKnowledgeDocument> documents) {
AgentRuntimeEvent event = currentEventBridge().event(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL);
event.getPayload().put("query", query);
event.getPayload().put("knowledgeId", spec.getKnowledgeId());
event.getPayload().put("knowledgeName", spec.getName());
event.getPayload().put("knowledgeType", spec.getMetadata().get("knowledgeType"));
event.getPayload().put("faqCollection", spec.getMetadata().get("faqCollection"));
event.getPayload().put("limit", retrievalRequest.getLimit());
event.getPayload().put("scoreThreshold", retrievalRequest.getScoreThreshold());
event.getPayload().put("documentCount", documents == null ? 0 : documents.size());
event.getPayload().put("documents", documentSummaries(documents));
currentEventBridge().emit(event);
}
private AgentRuntimeExecutionContext currentRequest() {
return turnContextHolder == null ? request : turnContextHolder.executionContext(request);
}
private AgentRuntimeEventBridge currentEventBridge() {
if (turnContextHolder != null && turnContextHolder.eventBridge().isPresent()) {
return turnContextHolder.eventBridge().get();
}
return new AgentRuntimeEventBridge(request, turnContextHolder);
}
/**
* 构建用于事件展示的命中片段,保留前端引注需要的原始 chunk 内容。
*
* @param documents 检索文档
* @return 命中片段列表
*/
private List<Map<String, Object>> documentSummaries(List<AgentKnowledgeDocument> documents) {
List<Map<String, Object>> summaries = new ArrayList<>();
if (documents == null) {
return summaries;
}
for (AgentKnowledgeDocument document : documents) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("documentId", document.getDocumentId());
summary.put("documentName", document.getDocumentName());
summary.put("chunkId", document.getChunkId());
summary.put("chunkContent", document.getContent());
summary.put("score", document.getScore());
summary.put("sourceUri", document.getSourceUri());
summary.put("metadata", document.getMetadata());
summaries.add(summary);
}
return summaries; return summaries;
} }
for (AgentKnowledgeDocument document : documents) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("documentId", document.getDocumentId());
summary.put("documentName", document.getDocumentName());
summary.put("chunkId", document.getChunkId());
summary.put("chunkContent", document.getContent());
summary.put("score", document.getScore());
summary.put("sourceUri", document.getSourceUri());
summary.put("metadata", document.getMetadata());
summaries.add(summary);
}
return summaries;
}
/**
* 格式化模型可见的结构化检索证据。
*
* @param knowledgeSpec 知识库声明
* @param query 实际检索词
* @param documents 最终文档
* @return 模型上下文文本
*/
private String modelContent(AgentKnowledgeSpec knowledgeSpec,
String query,
List<AgentKnowledgeDocument> documents) {
String knowledgeName = hasText(knowledgeSpec.getName())
? knowledgeSpec.getName().trim()
: knowledgeSpec.getRuntimeName().trim();
if (documents.isEmpty()) {
return "No relevant documents were found in knowledge base \""
+ knowledgeName + "\" for query: " + query;
}
StringBuilder content = new StringBuilder()
.append("Retrieved evidence from knowledge base \"")
.append(knowledgeName)
.append("\" for query: ")
.append(query)
.append("\n\n");
for (int index = 0; index < documents.size(); index++) {
AgentKnowledgeDocument document = documents.get(index);
content.append("[Evidence ").append(index + 1).append("]\n");
appendField(content, "Document", document.getDocumentName());
appendField(content, "Document ID", document.getDocumentId());
appendField(content, "Chunk ID", document.getChunkId());
appendField(content, "Source", document.getSourceUri());
if (document.getScore() != null) {
content.append("Score: ").append(document.getScore()).append('\n');
}
content.append("Content:\n")
.append(document.getContent() == null ? "" : document.getContent())
.append("\n\n");
}
return content.toString().stripTrailing();
}
/**
* 追加非空证据字段。
*
* @param content 输出缓冲区
* @param label 字段标签
* @param value 字段值
*/
private void appendField(StringBuilder content, String label, String value) {
if (hasText(value)) {
content.append(label).append(": ").append(value.trim()).append('\n');
}
}
/**
* 判断字符串是否包含非空白文本。
*
* @param value 待判断字符串
* @return 包含文本时为 true
*/
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
/**
* 将可空值转换为字符串。
*
* @param value 原始值
* @return 字符串;原始值为空时返回 null
*/
private String stringValue(Object value) {
return value == null ? null : String.valueOf(value);
} }
} }

View File

@@ -13,14 +13,15 @@ import com.easyagents.agent.runtime.hitl.AgentPendingState;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalCoordinator; import com.easyagents.agent.runtime.hitl.AgentToolApprovalCoordinator;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalResolution; import com.easyagents.agent.runtime.hitl.AgentToolApprovalResolution;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRejectedException; import com.easyagents.agent.runtime.hitl.AgentToolApprovalRejectedException;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
import com.easyagents.agent.runtime.knowledge.citation.AgentKnowledgeCitationMatcher; import com.easyagents.agent.runtime.knowledge.citation.AgentKnowledgeCitationMatcher;
import com.easyagents.agent.runtime.knowledge.citation.HeuristicKnowledgeCitationMatcher; import com.easyagents.agent.runtime.knowledge.citation.HeuristicKnowledgeCitationMatcher;
import com.easyagents.agent.runtime.message.*; import com.easyagents.agent.runtime.message.*;
import com.easyagents.agent.runtime.mcp.McpRegistration; import com.easyagents.agent.runtime.mcp.McpRegistration;
import com.easyagents.agent.runtime.mcp.McpSkillRegistration;
import com.easyagents.agent.runtime.mcp.McpSpecValidator; import com.easyagents.agent.runtime.mcp.McpSpecValidator;
import com.easyagents.agent.runtime.mcp.McpToolkitAdapter; import com.easyagents.agent.runtime.mcp.McpToolkitAdapter;
import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore; import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore;
import com.easyagents.agent.runtime.prompt.SystemPromptComposer;
import com.easyagents.agent.runtime.skill.AgentSkillBinding; import com.easyagents.agent.runtime.skill.AgentSkillBinding;
import com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext; import com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext;
import com.easyagents.agent.runtime.tool.AgentToolInvoker; import com.easyagents.agent.runtime.tool.AgentToolInvoker;
@@ -34,9 +35,6 @@ import io.agentscope.core.memory.Memory;
import io.agentscope.core.memory.autocontext.AutoContextMemory; import io.agentscope.core.memory.autocontext.AutoContextMemory;
import io.agentscope.core.message.*; import io.agentscope.core.message.*;
import io.agentscope.core.model.Model; import io.agentscope.core.model.Model;
import io.agentscope.core.rag.Knowledge;
import io.agentscope.core.rag.RAGMode;
import io.agentscope.core.rag.model.RetrieveConfig;
import io.agentscope.core.session.Session; import io.agentscope.core.session.Session;
import io.agentscope.core.skill.SkillBox; import io.agentscope.core.skill.SkillBox;
import io.agentscope.core.state.SessionKey; import io.agentscope.core.state.SessionKey;
@@ -57,18 +55,6 @@ import java.util.function.Supplier;
*/ */
public class AgentScopeReActRuntime implements AgentRuntime { public class AgentScopeReActRuntime implements AgentRuntime {
private static final String ASYNC_TOOL_SYSTEM_PROMPT = """
Async tool protocol:
- Async tools may expose submit, observe, result, cancel, and list sub-tools. Treat these sub-tools as one user-facing tool.
- Do not ask the user to choose submit, observe, result, cancel, or list. These are internal execution phases.
- For a normal user request to use an async tool, call its submit sub-tool first with the user-provided arguments by default.
- After submit returns task_id, immediately call observe with that task_id to check progress.
- If the task is completed and result is available, use the returned result to answer the user.
- If the task is still running after observation, tell the user that the task is running and keep task_id/next_action for later tool calls.
- Use result, list, or cancel directly only when the user explicitly asks to get a known task result, list tasks, or cancel a task.
""";
private final AgentScopeModelFactory modelFactory; private final AgentScopeModelFactory modelFactory;
private final AgentScopeToolAdapter toolAdapter; private final AgentScopeToolAdapter toolAdapter;
private final AgentScopeKnowledgeAdapter knowledgeAdapter; private final AgentScopeKnowledgeAdapter knowledgeAdapter;
@@ -218,7 +204,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
saveSession(); saveSession();
return Flux.just(started(executionContext), cancelled(executionContext)); return Flux.just(started(executionContext), cancelled(executionContext));
}).doOnNext(event -> executionContext.getConversationRecorder().record(executionContext, event)) }).doOnNext(event -> executionContext.getConversationRecorder().record(executionContext, event))
.doFinally(signalType -> cleanupTurn()); .doFinally(signalType -> cleanupStreamSegment(false));
} }
return runAgentStreamAfterLock(executionContext, List::of); return runAgentStreamAfterLock(executionContext, List::of);
}); });
@@ -238,6 +224,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
if (!running.compareAndSet(false, true)) { if (!running.compareAndSet(false, true)) {
return Flux.error(new AgentRuntimeException("Agent runtime is already streaming.")); return Flux.error(new AgentRuntimeException("Agent runtime is already streaming."));
} }
// 新用户消息建立新的 Turn上一 Turn 的 MCP 级批准不能跨轮复用。
approvalCoordinator.clearReusableApprovalScopes();
return runAgentStreamAfterLock(executionContext, inputSupplier); return runAgentStreamAfterLock(executionContext, inputSupplier);
}); });
} }
@@ -296,8 +284,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
.doOnNext(event -> executionContext.getConversationRecorder().record(executionContext, event)) .doOnNext(event -> executionContext.getConversationRecorder().record(executionContext, event))
// 处理中断请求 // 处理中断请求
.doOnCancel(() -> cancelInternal(executionContext, sideEvents, finalText, finalMessage, cancelled)) .doOnCancel(() -> cancelInternal(executionContext, sideEvents, finalText, finalMessage, cancelled))
// 释放运行锁并清掉 turn context // HITL 挂起时保留当前 Turn 的 MCP 批准,其余终态完整清理
.doFinally(signalType -> cleanupTurn()); .doFinally(signalType -> cleanupStreamSegment(suspendedEvent.get() != null));
} }
/** /**
@@ -357,7 +345,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
context.setRuntimeContext(runtimeContext.getRuntimeContext()); context.setRuntimeContext(runtimeContext.getRuntimeContext());
context.setUserMessage(userMessage); context.setUserMessage(userMessage);
context.setToolInvokers(runtimeContext.getToolInvokers()); context.setToolInvokers(runtimeContext.getToolInvokers());
context.setKnowledgeRetrievers(runtimeContext.getKnowledgeRetrievers()); context.setKnowledgeRegistrations(runtimeContext.getKnowledgeRegistrations());
context.setSessionStore(runtimeContext.getSessionStore()); context.setSessionStore(runtimeContext.getSessionStore());
context.setConversationRecorder(runtimeContext.getConversationRecorder()); context.setConversationRecorder(runtimeContext.getConversationRecorder());
context.setMetadata(runtimeContext.getMetadata()); context.setMetadata(runtimeContext.getMetadata());
@@ -378,7 +366,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
context.setAgentDefinition(runtimeContext.getAgentDefinition()); context.setAgentDefinition(runtimeContext.getAgentDefinition());
context.setRuntimeContext(runtimeContext.getRuntimeContext()); context.setRuntimeContext(runtimeContext.getRuntimeContext());
context.setToolInvokers(runtimeContext.getToolInvokers()); context.setToolInvokers(runtimeContext.getToolInvokers());
context.setKnowledgeRetrievers(runtimeContext.getKnowledgeRetrievers()); context.setKnowledgeRegistrations(runtimeContext.getKnowledgeRegistrations());
context.setSessionStore(runtimeContext.getSessionStore()); context.setSessionStore(runtimeContext.getSessionStore());
context.setConversationRecorder(runtimeContext.getConversationRecorder()); context.setConversationRecorder(runtimeContext.getConversationRecorder());
Map<String, Object> metadata = new LinkedHashMap<>(runtimeContext.getMetadata()); Map<String, Object> metadata = new LinkedHashMap<>(runtimeContext.getMetadata());
@@ -582,7 +570,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
suspended.getMetadata().put("approvalStatus", AgentToolApprovalResolution.Status.WAITING.name()); suspended.getMetadata().put("approvalStatus", AgentToolApprovalResolution.Status.WAITING.name());
return Flux.just(started(context), suspended) return Flux.just(started(context), suspended)
.doOnNext(event -> context.getConversationRecorder().record(context, event)) .doOnNext(event -> context.getConversationRecorder().record(context, event))
.doFinally(signalType -> cleanupTurn()); .doFinally(signalType -> cleanupStreamSegment(true));
} }
/** /**
@@ -686,6 +674,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
saveSession(); saveSession();
return Flux.just(cancelled(context)); return Flux.just(cancelled(context));
} }
saveSession();
return Flux.just(failed(context, error)); return Flux.just(failed(context, error));
} }
@@ -730,7 +719,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
* 将取消前已输出的助手内容补写入 AgentScope memory 并保存 session。 * 将取消前已输出的助手内容补写入 AgentScope memory 并保存 session。
* *
* <p>AgentScope 的正常完成路径会自行把最终助手消息写入 memory。取消订阅时不会触发 * <p>AgentScope 的正常完成路径会自行把最终助手消息写入 memory。取消订阅时不会触发
* 完成路径,因此这里仅在已有非空助手内容时补写一次,确保下一轮对话能拿到中断前上下文。</p> * 完成路径,因此这里仅在已有非空助手内容时补写一次,并始终保存已经进入 memory 的
* 用户消息,确保下一轮对话能拿到中断前上下文。</p>
* *
* @param finalText 当前已累计的助手文本 * @param finalText 当前已累计的助手文本
* @param finalMessage 当前已捕获的结构化助手消息 * @param finalMessage 当前已捕获的结构化助手消息
@@ -738,10 +728,9 @@ public class AgentScopeReActRuntime implements AgentRuntime {
private void persistPartialAssistantOnCancel(StringBuilder finalText, private void persistPartialAssistantOnCancel(StringBuilder finalText,
AtomicReference<AgentMessage> finalMessage) { AtomicReference<AgentMessage> finalMessage) {
AgentMessage partialMessage = partialAssistantMessage(finalText, finalMessage); AgentMessage partialMessage = partialAssistantMessage(finalText, finalMessage);
if (partialMessage == null) { if (partialMessage != null) {
return; agent.getMemory().addMessage(messageAdapter.toMsg(partialMessage));
} }
agent.getMemory().addMessage(messageAdapter.toMsg(partialMessage));
saveSession(); saveSession();
} }
@@ -791,10 +780,15 @@ public class AgentScopeReActRuntime implements AgentRuntime {
} }
/** /**
* 清理本轮状态。 * 清理一次 stream/resume 片段状态。
*
* @param preserveReusableApprovalScopes 是否因 HITL 挂起而保留当前 Turn 的 MCP 批准
*/ */
private void cleanupTurn() { private void cleanupStreamSegment(boolean preserveReusableApprovalScopes) {
approvalCoordinator.clearExecutionAuthorizations(); approvalCoordinator.clearExecutionAuthorizations();
if (!preserveReusableApprovalScopes) {
approvalCoordinator.clearReusableApprovalScopes();
}
turnContextHolder.clear(); turnContextHolder.clear();
running.set(false); running.set(false);
} }
@@ -1101,7 +1095,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
context.setAgentDefinition(request.getAgentDefinition()); context.setAgentDefinition(request.getAgentDefinition());
context.setRuntimeContext(request.getRuntimeContext()); context.setRuntimeContext(request.getRuntimeContext());
context.setToolInvokers(request.getToolInvokers()); context.setToolInvokers(request.getToolInvokers());
context.setKnowledgeRetrievers(request.getKnowledgeRetrievers()); context.setKnowledgeRegistrations(request.getKnowledgeRegistrations());
context.setMemorySnapshot(request.getMemorySnapshot());
context.setSessionStore(request.getSessionStore()); context.setSessionStore(request.getSessionStore());
context.setConversationRecorder(request.getConversationRecorder()); context.setConversationRecorder(request.getConversationRecorder());
context.setMetadata(request.getMetadata()); context.setMetadata(request.getMetadata());
@@ -1120,10 +1115,11 @@ public class AgentScopeReActRuntime implements AgentRuntime {
Toolkit toolkit = new Toolkit(); Toolkit toolkit = new Toolkit();
AgentScopeToolkitBuildResult toolkitBuildResult = buildToolkit(context, toolkit); AgentScopeToolkitBuildResult toolkitBuildResult = buildToolkit(context, toolkit);
Map<String, List<AgentTool>> skillTools = toolkitBuildResult.skillTools(); Map<String, List<AgentTool>> skillTools = toolkitBuildResult.skillTools();
AgentScopeMemoryBuildResult memoryResult = memoryAdapter.createMemoryResult(null, definition.getMemoryPolicy(), model); AgentScopeMemoryBuildResult memoryResult = memoryAdapter.createMemoryResult(
context.getMemorySnapshot(), definition.getMemoryPolicy(), model);
Memory memory = memoryResult.getMemory(); 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 // AutoContextInterceptor 是官方 AutoContextHook 的替代实现。这里仍只注册统一 runtime hook
// 避免官方 hook 与 Easy-Agents interceptor 同时触发压缩和 inputMessages 改写。 // 避免官方 hook 与 Easy-Agents interceptor 同时触发压缩和 inputMessages 改写。
AgentRuntimeEventBridge eventBridge = new AgentRuntimeEventBridge(context, turnContextHolder); AgentRuntimeEventBridge eventBridge = new AgentRuntimeEventBridge(context, turnContextHolder);
@@ -1132,7 +1128,10 @@ public class AgentScopeReActRuntime implements AgentRuntime {
interceptors.add(new AutoContextInterceptor(eventBridge, memoryResult.getAutoContextConfig())); interceptors.add(new AutoContextInterceptor(eventBridge, memoryResult.getAutoContextConfig()));
} }
interceptors.add(new MediaReferenceInterceptor(initRequest.getMediaResolver())); interceptors.add(new MediaReferenceInterceptor(initRequest.getMediaResolver()));
List<AgentToolSpec> runtimeToolSpecs = mergeToolSpecs(definition.getToolSpecs(), toolkitBuildResult.mcpToolSpecs(), List<AgentToolSpec> runtimeToolSpecs = mergeToolSpecs(
definition.getToolSpecs(),
toolkitBuildResult.knowledgeToolSpecs(),
toolkitBuildResult.mcpToolSpecs(),
toolkitBuildResult.operateToolSpecs()); toolkitBuildResult.operateToolSpecs());
interceptors.add(new ToolHitlInterceptor(eventBridge, approvalCoordinator, interceptors.add(new ToolHitlInterceptor(eventBridge, approvalCoordinator,
runtimeToolSpecs)); runtimeToolSpecs));
@@ -1147,7 +1146,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
ReActAgent.Builder builder = ReActAgent.builder() ReActAgent.Builder builder = ReActAgent.builder()
.name(definition.getAgentName()) .name(definition.getAgentName())
.description(definition.getDescription()) .description(definition.getDescription())
.sysPrompt(systemPrompt(definition)) .sysPrompt(SystemPromptComposer.compose(definition))
.model(model) .model(model)
.toolkit(toolkit) .toolkit(toolkit)
.memory(memory) .memory(memory)
@@ -1156,41 +1155,12 @@ public class AgentScopeReActRuntime implements AgentRuntime {
.hook(new AgentScopeRuntimeHook(observationManager)) .hook(new AgentScopeRuntimeHook(observationManager))
.enablePendingToolRecovery(true) .enablePendingToolRecovery(true)
.statePersistence(AgentScopeSessionAdapter.toStatePersistence(definition.getPersistencePolicy())); .statePersistence(AgentScopeSessionAdapter.toStatePersistence(definition.getPersistencePolicy()));
if (knowledge != null) {
builder.knowledge(knowledge)
.ragMode(RAGMode.AGENTIC)
.retrieveConfig(defaultRetrieveConfig(definition));
}
if (skillBox != null) { if (skillBox != null) {
builder.skillBox(skillBox); builder.skillBox(skillBox);
} }
return builder.build(); return builder.build();
} }
private String systemPrompt(AgentDefinition definition) {
String prompt = definition.getSystemPrompt();
if (!hasAsyncTool(definition)) {
return prompt;
}
if (prompt == null || prompt.isBlank()) {
return ASYNC_TOOL_SYSTEM_PROMPT.strip();
}
return prompt.stripTrailing() + ASYNC_TOOL_SYSTEM_PROMPT;
}
private boolean hasAsyncTool(AgentDefinition definition) {
if (definition == null || definition.getToolSpecs() == null) {
return false;
}
for (AgentToolSpec toolSpec : definition.getToolSpecs()) {
// AsyncToolSpecExpander marks all generated sub-tools with this runtime metadata.
if (toolSpec != null && Boolean.TRUE.equals(toolSpec.getMetadata().get("asyncTool"))) {
return true;
}
}
return false;
}
/** /**
* 构建 AgentScope Toolkit并返回按 Skill ID 分组的工具。 * 构建 AgentScope Toolkit并返回按 Skill ID 分组的工具。
* *
@@ -1202,8 +1172,11 @@ public class AgentScopeReActRuntime implements AgentRuntime {
Toolkit toolkit) { Toolkit toolkit) {
Map<String, List<AgentTool>> skillTools = new LinkedHashMap<>(); Map<String, List<AgentTool>> skillTools = new LinkedHashMap<>();
if (!context.getAgentDefinition().getExecutionOptions().isToolCallingEnabled()) { if (!context.getAgentDefinition().getExecutionOptions().isToolCallingEnabled()) {
return new AgentScopeToolkitBuildResult(skillTools, List.of(), List.of()); return new AgentScopeToolkitBuildResult(skillTools, List.of(), List.of(), List.of(), List.of());
} }
List<AgentToolSpec> knowledgeToolSpecs = knowledgeAdapter.createToolSpecs(context);
validateRuntimeToolConflicts(context.getAgentDefinition().getToolSpecs(), knowledgeToolSpecs,
List.of(), List.of());
for (AgentToolSpec toolSpec : context.getAgentDefinition().getToolSpecs()) { for (AgentToolSpec toolSpec : context.getAgentDefinition().getToolSpecs()) {
AgentToolInvoker invoker = context.getToolInvokers().get(toolSpec.getName()); AgentToolInvoker invoker = context.getToolInvokers().get(toolSpec.getName());
AgentSkillBinding skillBinding = skillContext.getToolBinding(toolSpec.getName()); AgentSkillBinding skillBinding = skillContext.getToolBinding(toolSpec.getName());
@@ -1215,6 +1188,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
skillTools.computeIfAbsent(skillBinding.getSkillId(), key -> new ArrayList<>()).add(agentTool); skillTools.computeIfAbsent(skillBinding.getSkillId(), key -> new ArrayList<>()).add(agentTool);
} }
} }
knowledgeAdapter.registerTools(context, knowledgeToolSpecs, toolkit, toolAdapter,
approvalCoordinator, turnContextHolder);
McpRegistration mcpRegistration = mcpToolkitAdapter.register( McpRegistration mcpRegistration = mcpToolkitAdapter.register(
context.getAgentDefinition().getMcpSpecs(), toolkit); context.getAgentDefinition().getMcpSpecs(), toolkit);
mcpClients.addAll(mcpRegistration.getClients()); mcpClients.addAll(mcpRegistration.getClients());
@@ -1222,16 +1197,32 @@ public class AgentScopeReActRuntime implements AgentRuntime {
context.getAgentDefinition().getOperateToolSpecs(), toolkit); context.getAgentDefinition().getOperateToolSpecs(), toolkit);
McpSpecValidator.validateToolConflicts(context.getAgentDefinition().getToolSpecs(), McpSpecValidator.validateToolConflicts(context.getAgentDefinition().getToolSpecs(),
mcpRegistration.getToolSpecs(), context.getAgentDefinition().getOperateToolSpecs()); mcpRegistration.getToolSpecs(), context.getAgentDefinition().getOperateToolSpecs());
return new AgentScopeToolkitBuildResult(skillTools, mcpRegistration.getToolSpecs(), operateToolSpecs); validateRuntimeToolConflicts(context.getAgentDefinition().getToolSpecs(), knowledgeToolSpecs,
mcpRegistration.getToolSpecs(), operateToolSpecs);
return new AgentScopeToolkitBuildResult(skillTools, knowledgeToolSpecs,
mcpRegistration.getToolSpecs(), operateToolSpecs, mcpRegistration.getSkillRegistrations());
} }
/**
* 合并所有运行时工具声明供统一治理与事件展示使用。
*
* @param toolSpecs 普通工具声明
* @param knowledgeToolSpecs 知识库工具声明
* @param mcpToolSpecs MCP 工具声明
* @param operateToolSpecs 操作工具声明
* @return 保持注册顺序的工具声明列表
*/
private List<AgentToolSpec> mergeToolSpecs(List<AgentToolSpec> toolSpecs, private List<AgentToolSpec> mergeToolSpecs(List<AgentToolSpec> toolSpecs,
List<AgentToolSpec> knowledgeToolSpecs,
List<AgentToolSpec> mcpToolSpecs, List<AgentToolSpec> mcpToolSpecs,
List<AgentToolSpec> operateToolSpecs) { List<AgentToolSpec> operateToolSpecs) {
List<AgentToolSpec> merged = new ArrayList<>(); List<AgentToolSpec> merged = new ArrayList<>();
if (toolSpecs != null) { if (toolSpecs != null) {
merged.addAll(toolSpecs); merged.addAll(toolSpecs);
} }
if (knowledgeToolSpecs != null) {
merged.addAll(knowledgeToolSpecs);
}
if (mcpToolSpecs != null) { if (mcpToolSpecs != null) {
merged.addAll(mcpToolSpecs); merged.addAll(mcpToolSpecs);
} }
@@ -1241,6 +1232,46 @@ public class AgentScopeReActRuntime implements AgentRuntime {
return merged; return merged;
} }
/**
* 校验不同来源的运行时工具名称没有冲突。
*
* @param toolSpecs 普通工具声明
* @param knowledgeToolSpecs 知识库工具声明
* @param mcpToolSpecs MCP 工具声明
* @param operateToolSpecs 操作工具声明
* @throws AgentRuntimeException 工具名称重复时抛出
*/
private void validateRuntimeToolConflicts(List<AgentToolSpec> toolSpecs,
List<AgentToolSpec> knowledgeToolSpecs,
List<AgentToolSpec> mcpToolSpecs,
List<AgentToolSpec> operateToolSpecs) {
Set<String> names = new LinkedHashSet<>();
for (List<AgentToolSpec> specs : List.of(
safeToolSpecs(toolSpecs),
safeToolSpecs(knowledgeToolSpecs),
safeToolSpecs(mcpToolSpecs),
safeToolSpecs(operateToolSpecs))) {
for (AgentToolSpec spec : specs) {
if (spec == null || spec.getName() == null || spec.getName().isBlank()) {
continue;
}
if (!names.add(spec.getName())) {
throw new AgentRuntimeException("Agent runtime tool name conflict: " + spec.getName());
}
}
}
}
/**
* 将可空工具列表转换为空安全列表。
*
* @param toolSpecs 工具声明
* @return 非空工具声明列表
*/
private List<AgentToolSpec> safeToolSpecs(List<AgentToolSpec> toolSpecs) {
return toolSpecs == null ? List.of() : toolSpecs;
}
private void closeMcpClients() { private void closeMcpClients() {
for (McpClientWrapper client : mcpClients) { for (McpClientWrapper client : mcpClients) {
if (client == null) { if (client == null) {
@@ -1254,28 +1285,6 @@ public class AgentScopeReActRuntime implements AgentRuntime {
mcpClients.clear(); mcpClients.clear();
} }
/**
* 构建聚合知识库的默认检索配置。
*
* @param definition 智能体定义
* @return 检索配置
*/
private RetrieveConfig defaultRetrieveConfig(AgentDefinition definition) {
int limit = definition.getKnowledgeSpecs().stream()
.mapToInt(AgentKnowledgeSpec::getLimit)
.filter(value -> value > 0)
.sum();
double scoreThreshold = definition.getKnowledgeSpecs().stream()
.mapToDouble(AgentKnowledgeSpec::getScoreThreshold)
.filter(value -> value > 0D)
.min()
.orElse(0D);
return RetrieveConfig.builder()
.limit(limit <= 0 ? 5 : limit)
.scoreThreshold(scoreThreshold)
.build();
}
public AgentInitRequest getInitRequest() { public AgentInitRequest getInitRequest() {
return initRequest; return initRequest;
} }
@@ -1290,7 +1299,9 @@ public class AgentScopeReActRuntime implements AgentRuntime {
} }
private record AgentScopeToolkitBuildResult(Map<String, List<AgentTool>> skillTools, private record AgentScopeToolkitBuildResult(Map<String, List<AgentTool>> skillTools,
List<AgentToolSpec> mcpToolSpecs, List<AgentToolSpec> knowledgeToolSpecs,
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.AgentSkillBoxSpec;
import com.easyagents.agent.runtime.skill.AgentSkillCompiler; import com.easyagents.agent.runtime.skill.AgentSkillCompiler;
import com.easyagents.agent.runtime.skill.AgentSkillSpec; import com.easyagents.agent.runtime.skill.AgentSkillSpec;
import com.easyagents.agent.runtime.mcp.McpSkillRegistration;
import io.agentscope.core.skill.AgentSkill; import io.agentscope.core.skill.AgentSkill;
import io.agentscope.core.skill.SkillBox; import io.agentscope.core.skill.SkillBox;
import io.agentscope.core.tool.AgentTool; import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.Toolkit; import io.agentscope.core.tool.Toolkit;
import java.util.List; import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
/** /**
@@ -67,6 +69,22 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler<AgentSkill> {
* @return SkillBox未配置 Skill 时返回 null * @return SkillBox未配置 Skill 时返回 null
*/ */
public SkillBox createSkillBox(AgentSkillBoxSpec spec, Toolkit toolkit, Map<String, List<AgentTool>> skillTools) { public SkillBox createSkillBox(AgentSkillBoxSpec spec, Toolkit toolkit, Map<String, List<AgentTool>> skillTools) {
return createSkillBox(spec, toolkit, skillTools, List.of());
}
/**
* 创建并绑定静态工具及 MCP 工具的 AgentScope SkillBox。
*
* @param spec SkillBox 声明
* @param toolkit Toolkit 实例
* @param skillTools 按 Skill ID 分组的静态工具
* @param skillMcpRegistrations 按 Skill 延迟激活的 MCP client
* @return SkillBox未配置 Skill 时返回 null
*/
public SkillBox createSkillBox(AgentSkillBoxSpec spec,
Toolkit toolkit,
Map<String, List<AgentTool>> skillTools,
List<McpSkillRegistration> skillMcpRegistrations) {
if (spec == null || spec.getSkills().isEmpty()) { if (spec == null || spec.getSkills().isEmpty()) {
return null; return null;
} }
@@ -74,10 +92,15 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler<AgentSkill> {
? new SkillBox(toolkit) ? new SkillBox(toolkit)
: new SkillBox(toolkit, spec.getSkillBoxId()); : new SkillBox(toolkit, spec.getSkillBoxId());
skillBox.setExposeAllSkillMetadata(spec.isExposeAllSkillMetadata()); skillBox.setExposeAllSkillMetadata(spec.isExposeAllSkillMetadata());
Map<String, List<McpSkillRegistration>> mcpBySkill = groupMcpRegistrations(skillMcpRegistrations);
for (AgentSkillSpec skillSpec : spec.getSkills()) { for (AgentSkillSpec skillSpec : spec.getSkills()) {
AgentSkill skill = compile(skillSpec); AgentSkill skill = compile(skillSpec);
List<AgentTool> tools = skillTools == null ? List.of() : skillTools.getOrDefault(skillSpec.getSkillId(), List.of()); List<AgentTool> tools = skillTools == null ? List.of() : skillTools.getOrDefault(skillSpec.getSkillId(), List.of());
if (tools.isEmpty()) { List<McpSkillRegistration> mcpRegistrations = mcpBySkill.remove(skillSpec.getSkillId());
if (mcpRegistrations == null) {
mcpRegistrations = List.of();
}
if (tools.isEmpty() && mcpRegistrations.isEmpty()) {
skillBox.registration() skillBox.registration()
.skill(skill) .skill(skill)
.toolkit(toolkit) .toolkit(toolkit)
@@ -97,11 +120,42 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler<AgentSkill> {
.agentTool(tool) .agentTool(tool)
.apply(); .apply();
} }
for (McpSkillRegistration mcpRegistration : mcpRegistrations) {
skillBox.registration()
.skill(skill)
.toolkit(toolkit)
.enableTools(mcpRegistration.getEnableTools())
.disableTools(mcpRegistration.getDisableTools())
.presetParameters(mcpRegistration.getPresetParameters())
.mcpClient(mcpRegistration.getClient())
.apply();
}
}
if (!mcpBySkill.isEmpty()) {
throw new AgentRuntimeException("Skill-bound MCP references unknown skill: "
+ mcpBySkill.keySet().iterator().next());
} }
skillBox.syncToolGroupStates(); skillBox.syncToolGroupStates();
return skillBox; return skillBox;
} }
private Map<String, List<McpSkillRegistration>> groupMcpRegistrations(
List<McpSkillRegistration> registrations) {
Map<String, List<McpSkillRegistration>> grouped = new LinkedHashMap<>();
if (registrations == null) {
return grouped;
}
for (McpSkillRegistration registration : registrations) {
if (registration == null || registration.getSkillId() == null
|| registration.getSkillId().isBlank()) {
throw new AgentRuntimeException("Skill-bound MCP skill id is required.");
}
grouped.computeIfAbsent(registration.getSkillId(), key -> new java.util.ArrayList<>())
.add(registration);
}
return grouped;
}
/** /**
* 校验 Skill 声明是否具备 AgentScope 注册和模型提示所需的必要信息。 * 校验 Skill 声明是否具备 AgentScope 注册和模型提示所需的必要信息。
* *

View File

@@ -170,7 +170,8 @@ public class AgentScopeToolAdapter {
throw new AgentRuntimeException("Agent tool invoker is required: " + toolSpec.getName()); throw new AgentRuntimeException("Agent tool invoker is required: " + toolSpec.getName());
} }
return new RuntimeAgentTool(toolSpec, invoker, request, approvalCoordinator, turnContextHolder, return new RuntimeAgentTool(toolSpec, invoker, request, approvalCoordinator, turnContextHolder,
skillContext, skillBinding, emitNormalToolResult, true, true); skillContext, skillBinding, emitNormalToolResult, true, true,
resolveInvocationClassLoader(invoker));
} }
/** /**
@@ -203,7 +204,8 @@ public class AgentScopeToolAdapter {
throw new AgentRuntimeException("Agent tool invoker is required: " + toolSpec.getName()); throw new AgentRuntimeException("Agent tool invoker is required: " + toolSpec.getName());
} }
return new RuntimeAgentTool(toolSpec, invoker, request, approvalCoordinator, turnContextHolder, return new RuntimeAgentTool(toolSpec, invoker, request, approvalCoordinator, turnContextHolder,
skillContext, skillBinding, emitNormalToolResult, emitSkillStep, true); skillContext, skillBinding, emitNormalToolResult, emitSkillStep, true,
resolveInvocationClassLoader(invoker));
} }
/** /**
@@ -238,7 +240,24 @@ public class AgentScopeToolAdapter {
throw new AgentRuntimeException("Agent tool invoker is required: " + toolSpec.getName()); throw new AgentRuntimeException("Agent tool invoker is required: " + toolSpec.getName());
} }
return new RuntimeAgentTool(toolSpec, invoker, request, approvalCoordinator, turnContextHolder, return new RuntimeAgentTool(toolSpec, invoker, request, approvalCoordinator, turnContextHolder,
skillContext, skillBinding, emitNormalToolResult, emitSkillStep, handleApprovalInTool); skillContext, skillBinding, emitNormalToolResult, emitSkillStep, handleApprovalInTool,
resolveInvocationClassLoader(invoker));
}
/**
* 解析工具执行时应使用的应用类加载器。
*
* <p>AgentScope 可能在 Reactor 工作线程执行工具。Spring Boot 可执行包中的业务类依赖
* 注册工具时的应用类加载器,不能依赖工作线程可能继承到的系统类加载器。</p>
*
* @param invoker 工具调用器
* @return 工具调用器所属类加载器;无法取得时回退到当前线程上下文类加载器
*/
private ClassLoader resolveInvocationClassLoader(AgentToolInvoker invoker) {
ClassLoader invokerClassLoader = invoker.getClass().getClassLoader();
return invokerClassLoader == null
? Thread.currentThread().getContextClassLoader()
: invokerClassLoader;
} }
private AgentRuntimeTurnContextHolder fixedHolder(AgentRuntimeExecutionContext request, private AgentRuntimeTurnContextHolder fixedHolder(AgentRuntimeExecutionContext request,
@@ -258,7 +277,8 @@ public class AgentScopeToolAdapter {
AgentSkillBinding skillBinding, AgentSkillBinding skillBinding,
boolean emitNormalToolResult, boolean emitNormalToolResult,
boolean emitSkillStep, boolean emitSkillStep,
boolean handleApprovalInTool) implements AgentTool { boolean handleApprovalInTool,
ClassLoader invocationClassLoader) implements AgentTool {
/** /**
* 获取工具名称。 * 获取工具名称。
@@ -402,15 +422,28 @@ public class AgentScopeToolAdapter {
* @return 工具结果块 * @return 工具结果块
*/ */
private ToolResultBlock invokeTool(ToolCallParam param, Map<String, Object> input) { private ToolResultBlock invokeTool(ToolCallParam param, Map<String, Object> input) {
AgentToolContext context = buildContext(param); Thread currentThread = Thread.currentThread();
AgentToolResult result = invoker.invoke(input, context); ClassLoader originalClassLoader = currentThread.getContextClassLoader();
ToolResultBlock block = toToolResultBlock(param, result); boolean switchClassLoader = invocationClassLoader != null
// 有状态 runtime 中,普通工具结果由 AgentScope 原生 PostActingEvent && invocationClassLoader != originalClassLoader;
// 旁路观察器统一发出;旧 sink 辅助路径没有统一 hook因此仍允许 adapter 兼容发射。 if (switchClassLoader) {
if (emitNormalToolResult || (emitSkillStep && activeSkillBinding() != null)) { currentThread.setContextClassLoader(invocationClassLoader);
emit(toolResultEvent(block)); }
try {
AgentToolContext context = buildContext(param);
AgentToolResult result = invoker.invoke(input, context);
ToolResultBlock block = toToolResultBlock(param, result);
// 有状态 runtime 中,普通工具结果由 AgentScope 原生 PostActingEvent
// 旁路观察器统一发出;旧 sink 辅助路径没有统一 hook因此仍允许 adapter 兼容发射。
if (emitNormalToolResult || (emitSkillStep && activeSkillBinding() != null)) {
emit(toolResultEvent(block));
}
return block;
} finally {
if (switchClassLoader) {
currentThread.setContextClassLoader(originalClassLoader);
}
} }
return block;
} }
/** /**
@@ -561,6 +594,7 @@ public class AgentScopeToolAdapter {
} }
target.put("skillId", binding.getSkillId()); target.put("skillId", binding.getSkillId());
target.put("skillName", binding.getSkillName()); target.put("skillName", binding.getSkillName());
target.put("skillDisplayName", binding.getSkillDisplayName());
target.put("skillBoxId", binding.getSkillBoxId()); target.put("skillBoxId", binding.getSkillBoxId());
} }

View File

@@ -107,9 +107,9 @@ public class AgentRuntimeTurnContext {
merged.setUserMessage(executionContext.getUserMessage()); merged.setUserMessage(executionContext.getUserMessage());
merged.setMemorySnapshot(executionContext.getMemorySnapshot()); merged.setMemorySnapshot(executionContext.getMemorySnapshot());
merged.setToolInvokers(fallback == null ? executionContext.getToolInvokers() : fallback.getToolInvokers()); merged.setToolInvokers(fallback == null ? executionContext.getToolInvokers() : fallback.getToolInvokers());
merged.setKnowledgeRetrievers(fallback == null merged.setKnowledgeRegistrations(fallback == null
? executionContext.getKnowledgeRetrievers() ? executionContext.getKnowledgeRegistrations()
: fallback.getKnowledgeRetrievers()); : fallback.getKnowledgeRegistrations());
merged.setSessionStore(fallback == null ? executionContext.getSessionStore() : fallback.getSessionStore()); merged.setSessionStore(fallback == null ? executionContext.getSessionStore() : fallback.getSessionStore());
merged.setConversationRecorder(fallback == null merged.setConversationRecorder(fallback == null
? executionContext.getConversationRecorder() ? executionContext.getConversationRecorder()

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.AgentRuntimeEventType;
import com.easyagents.agent.runtime.event.AgentRuntimeInterceptor; import com.easyagents.agent.runtime.event.AgentRuntimeInterceptor;
import com.easyagents.agent.runtime.hitl.AgentPendingState; 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.AgentToolApprovalCoordinator;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest; import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
import com.easyagents.agent.runtime.tool.AgentToolSpec; import com.easyagents.agent.runtime.tool.AgentToolSpec;
@@ -22,9 +23,11 @@ import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -140,7 +143,12 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
private void interceptPreActing(PreActingEvent event) { private void interceptPreActing(PreActingEvent event) {
ToolUseBlock toolUse = event.getToolUse(); ToolUseBlock toolUse = event.getToolUse();
AgentToolSpec toolSpec = toolUse == null ? null : toolSpecs.get(toolUse.getName()); 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; return;
} }
// 执行授权与 toolCallId、工具名称及入参同时绑定并且只能消费一次。 // 执行授权与 toolCallId、工具名称及入参同时绑定并且只能消费一次。
@@ -239,7 +247,121 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
*/ */
private boolean isApprovalRequired(ToolUseBlock toolUse) { private boolean isApprovalRequired(ToolUseBlock toolUse) {
AgentToolSpec toolSpec = toolUse == null ? null : toolSpecs.get(toolUse.getName()); 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()) { if (toolUses == null || toolUses.isEmpty()) {
return List.of(); return List.of();
} }
return toolUses.stream() List<ToolUseBlock> approvalTools = new ArrayList<>();
.filter(this::isApprovalRequired) Set<String> pendingReusableScopes = new LinkedHashSet<>();
.toList(); 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 Map<String, Object> metadata = approvalRequest == null
? new LinkedHashMap<>() ? new LinkedHashMap<>()
: new LinkedHashMap<>(approvalRequest.getMetadata()); : new LinkedHashMap<>(approvalRequest.getMetadata());
metadata.putAll(toolUse.getMetadata() == null ? Map.of() : toolUse.getMetadata());
if (toolSpec.getMetadata() != null && !toolSpec.getMetadata().isEmpty()) { if (toolSpec.getMetadata() != null && !toolSpec.getMetadata().isEmpty()) {
// ToolSpec 由工具编译阶段生成,必须覆盖模型返回的同名治理字段(例如 toolType、mcpId
metadata.putAll(toolSpec.getMetadata()); 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("phase", "POST_REASONING");
metadata.put("source", "TOOL_HITL_INTERCEPTOR"); metadata.put("source", "TOOL_HITL_INTERCEPTOR");
metadata.putAll(toolUse.getMetadata() == null ? Map.of() : toolUse.getMetadata());
return approvalCoordinator.register( return approvalCoordinator.register(
context == null ? null : context.getSessionId(), context == null ? null : context.getSessionId(),
context == null || context.getAgentDefinition() == null ? null : context.getAgentDefinition().getAgentId(), context == null || context.getAgentDefinition() == null ? null : context.getAgentDefinition().getAgentId(),
@@ -312,6 +457,40 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
approvalBatchId); 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, "toolDisplayName");
putIfPresent(payload, metadata, "rawMcpToolName"); putIfPresent(payload, metadata, "rawMcpToolName");
putIfPresent(payload, metadata, "mcpToolName"); putIfPresent(payload, metadata, "mcpToolName");
putIfPresent(payload, metadata, "mcpId");
putIfPresent(payload, metadata, "mcpName"); putIfPresent(payload, metadata, "mcpName");
putIfPresent(payload, metadata, "mcpTitle"); putIfPresent(payload, metadata, "mcpTitle");
} }

View File

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

View File

@@ -5,11 +5,11 @@ import com.easyagents.agent.runtime.event.AgentRuntimeEventBridge;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType; import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.event.AgentRuntimeObserver; import com.easyagents.agent.runtime.event.AgentRuntimeObserver;
import com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext; import com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext;
import com.easyagents.agent.runtime.tool.AgentToolCategory;
import com.easyagents.agent.runtime.tool.AgentToolSpec; import com.easyagents.agent.runtime.tool.AgentToolSpec;
import io.agentscope.core.hook.HookEvent; import io.agentscope.core.hook.HookEvent;
import io.agentscope.core.hook.PostActingEvent; import io.agentscope.core.hook.PostActingEvent;
import io.agentscope.core.hook.PreActingEvent; import io.agentscope.core.hook.PreActingEvent;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.TextBlock; import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.message.ToolUseBlock;
@@ -103,12 +103,7 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
runtimeEvent.getPayload().put("toolCallId", toolUse.getId()); runtimeEvent.getPayload().put("toolCallId", toolUse.getId());
runtimeEvent.getPayload().put("name", toolUse.getName()); runtimeEvent.getPayload().put("name", toolUse.getName());
runtimeEvent.getPayload().put("toolName", toolUse.getName()); runtimeEvent.getPayload().put("toolName", toolUse.getName());
runtimeEvent.getPayload().put("input", toolUse.getInput());
runtimeEvent.getPayload().put("content", toolUse.getContent());
runtimeEvent.getPayload().put("status", "RUNNING"); runtimeEvent.getPayload().put("status", "RUNNING");
runtimeEvent.getPayload().put("source", "HOOK");
runtimeEvent.getPayload().put("phase", "PRE_ACTING");
runtimeEvent.getMetadata().putAll(nullToEmpty(toolUse.getMetadata()));
enrichToolPayload(runtimeEvent, toolUse.getName()); enrichToolPayload(runtimeEvent, toolUse.getName());
eventBridge.emit(runtimeEvent); eventBridge.emit(runtimeEvent);
} }
@@ -129,32 +124,29 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
runtimeEvent.getPayload().put("toolCallId", toolCallId); runtimeEvent.getPayload().put("toolCallId", toolCallId);
runtimeEvent.getPayload().put("name", toolName); runtimeEvent.getPayload().put("name", toolName);
runtimeEvent.getPayload().put("toolName", toolName); runtimeEvent.getPayload().put("toolName", toolName);
runtimeEvent.getPayload().put("text", resultText(result));
runtimeEvent.getPayload().put("suspended", result != null && result.isSuspended());
runtimeEvent.getPayload().put("status", success(result) ? "SUCCESS" : "FAILED"); runtimeEvent.getPayload().put("status", success(result) ? "SUCCESS" : "FAILED");
runtimeEvent.getPayload().put("success", success(result)); runtimeEvent.getPayload().put("success", success(result));
runtimeEvent.getPayload().put("source", "HOOK");
runtimeEvent.getPayload().put("phase", "POST_ACTING");
if (result != null) {
runtimeEvent.getMetadata().putAll(nullToEmpty(result.getMetadata()));
}
enrichToolPayload(runtimeEvent, toolName); enrichToolPayload(runtimeEvent, toolName);
eventBridge.emit(runtimeEvent); eventBridge.emit(runtimeEvent);
} }
private void enrichToolPayload(AgentRuntimeEvent runtimeEvent, String toolName) { private void enrichToolPayload(AgentRuntimeEvent runtimeEvent, String toolName) {
AgentToolSpec toolSpec = toolSpecs.get(toolName); AgentToolSpec toolSpec = toolSpecs.get(toolName);
if (toolSpec == null || toolSpec.getMetadata() == null || toolSpec.getMetadata().isEmpty()) { if (toolSpec == null) {
return;
}
runtimeEvent.getPayload().put("toolCategory", toolSpec.getCategory().name());
if (toolSpec.getMetadata() == null || toolSpec.getMetadata().isEmpty()) {
return; return;
} }
Map<String, Object> metadata = toolSpec.getMetadata(); Map<String, Object> metadata = toolSpec.getMetadata();
putIfPresent(runtimeEvent.getPayload(), metadata, "toolDisplayName"); putIfPresent(runtimeEvent.getPayload(), metadata, "toolDisplayName");
putIfPresent(runtimeEvent.getPayload(), metadata, "rawMcpToolName"); putIfPresent(runtimeEvent.getPayload(), metadata, "skillId");
putIfPresent(runtimeEvent.getPayload(), metadata, "mcpToolName"); if (toolSpec.getCategory() == AgentToolCategory.KNOWLEDGE) {
putIfPresent(runtimeEvent.getPayload(), metadata, "mcpName"); putIfPresent(runtimeEvent.getPayload(), metadata, "knowledgeId");
putIfPresent(runtimeEvent.getPayload(), metadata, "mcpTitle"); putIfPresent(runtimeEvent.getPayload(), metadata, "knowledgeName");
putIfPresent(runtimeEvent.getPayload(), metadata, "source"); putIfPresent(runtimeEvent.getPayload(), metadata, "knowledgeRuntimeName");
runtimeEvent.getMetadata().putAll(metadata); }
} }
private void putIfPresent(Map<String, Object> payload, Map<String, Object> metadata, String key) { private void putIfPresent(Map<String, Object> payload, Map<String, Object> metadata, String key) {
@@ -168,26 +160,15 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
return false; return false;
} }
Object success = result.getMetadata() == null ? null : result.getMetadata().get("success"); Object success = result.getMetadata() == null ? null : result.getMetadata().get("success");
return !(success instanceof Boolean) || Boolean.TRUE.equals(success); if (success instanceof Boolean) {
} return Boolean.TRUE.equals(success);
private String resultText(ToolResultBlock result) {
if (result == null || result.getOutput() == null || result.getOutput().isEmpty()) {
return "";
} }
StringBuilder builder = new StringBuilder(); // AgentScope 1.x 将工具异常转换为不带 success metadata 的 "Error: ..." 文本结果。
for (ContentBlock block : result.getOutput()) { return result.getOutput().stream()
if (block instanceof TextBlock textBlock) { .filter(TextBlock.class::isInstance)
builder.append(textBlock.getText()); .map(TextBlock.class::cast)
} else { .map(TextBlock::getText)
builder.append(block); .noneMatch(text -> text != null && text.startsWith("Error: "));
}
}
return builder.toString();
}
private Map<String, Object> nullToEmpty(Map<String, Object> map) {
return map == null ? new LinkedHashMap<>() : map;
} }
private boolean isSkillTool(String toolName) { private boolean isSkillTool(String toolName) {

View File

@@ -19,6 +19,9 @@ import java.util.concurrent.CompletableFuture;
*/ */
public class AgentToolApprovalCoordinator { public class AgentToolApprovalCoordinator {
/** MCP 工具类型。 */
private static final String MCP_TOOL_TYPE = "MCP";
/** 是否启用内存审批协调。 */ /** 是否启用内存审批协调。 */
private final boolean enabled; private final boolean enabled;
/** 恢复令牌到待审批项的索引。 */ /** 恢复令牌到待审批项的索引。 */
@@ -29,6 +32,8 @@ public class AgentToolApprovalCoordinator {
private final Map<String, String> tokensByToolCallId = new LinkedHashMap<>(); private final Map<String, String> tokensByToolCallId = new LinkedHashMap<>();
/** 工具调用ID到一次性执行授权的索引。 */ /** 工具调用ID到一次性执行授权的索引。 */
private final Map<String, ExecutionAuthorization> executionAuthorizations = new LinkedHashMap<>(); 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},或通过 * <p>恢复元数据应提供单个 {@code toolCallId/toolName/toolInput},或通过
* {@code approvedToolCalls} 提供上述字段组成的列表。该入口仅供已经完成持久化令牌 * {@code approvedToolCalls} 提供上述字段组成的列表。MCP 调用可额外携带受信任的
* 校验和一次性消费的服务端集成层使用。</p> * {@code toolType/mcpId},用于签发当前 Turn 的复用作用域。该入口仅供已经完成
* 持久化令牌校验和一次性消费的服务端集成层使用。</p>
* *
* @param request 受信任恢复请求 * @param request 受信任恢复请求
*/ */
@@ -318,16 +324,17 @@ public class AgentToolApprovalCoordinator {
: request.getMetadata(); : request.getMetadata();
Object approvedToolCalls = metadata.get("approvedToolCalls"); Object approvedToolCalls = metadata.get("approvedToolCalls");
Map<String, ExecutionAuthorization> trustedAuthorizations = new LinkedHashMap<>(); Map<String, ExecutionAuthorization> trustedAuthorizations = new LinkedHashMap<>();
Set<String> trustedReusableScopes = new LinkedHashSet<>();
int authorizationCount = 0; int authorizationCount = 0;
if (approvedToolCalls instanceof List<?> calls) { if (approvedToolCalls instanceof List<?> calls) {
for (Object call : calls) { for (Object call : calls) {
if (call instanceof Map<?, ?> callMap) { if (call instanceof Map<?, ?> callMap) {
authorizeTrustedCall(callMap, trustedAuthorizations); authorizeTrustedCall(callMap, trustedAuthorizations, trustedReusableScopes);
authorizationCount++; authorizationCount++;
} }
} }
} else if (metadata.containsKey("toolCallId")) { } else if (metadata.containsKey("toolCallId")) {
authorizeTrustedCall(metadata, trustedAuthorizations); authorizeTrustedCall(metadata, trustedAuthorizations, trustedReusableScopes);
authorizationCount++; authorizationCount++;
} }
if (authorizationCount == 0) { if (authorizationCount == 0) {
@@ -335,6 +342,7 @@ public class AgentToolApprovalCoordinator {
"Trusted resume metadata must include approved toolCallId, toolName, and toolInput."); "Trusted resume metadata must include approved toolCallId, toolName, and toolInput.");
} }
executionAuthorizations.putAll(trustedAuthorizations); 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(); executionAuthorizations.clear();
} }
/**
* 清理当前 Turn 的可复用工具审批作用域。
*/
public synchronized void clearReusableApprovalScopes() {
reusableApprovalScopes.clear();
}
/** /**
* 获取指定会话当前仍待处理的审批状态。 * 获取指定会话当前仍待处理的审批状态。
* *
@@ -399,6 +453,7 @@ public class AgentToolApprovalCoordinator {
approvals.clear(); approvals.clear();
tokensByToolCallId.clear(); tokensByToolCallId.clear();
executionAuthorizations.clear(); executionAuthorizations.clear();
reusableApprovalScopes.clear();
} }
/** /**
@@ -459,6 +514,11 @@ public class AgentToolApprovalCoordinator {
*/ */
private void authorize(PendingApproval pendingApproval) { private void authorize(PendingApproval pendingApproval) {
AgentPendingState state = pendingApproval.state; AgentPendingState state = pendingApproval.state;
String approvalScope = reusableApprovalScope(state.getMetadata());
if (approvalScope != null) {
reusableApprovalScopes.add(approvalScope);
return;
}
if (state.getToolCallId() == null || state.getToolCallId().isBlank()) { if (state.getToolCallId() == null || state.getToolCallId().isBlank()) {
throw new AgentRuntimeException("Approved tool call is missing toolCallId."); throw new AgentRuntimeException("Approved tool call is missing toolCallId.");
} }
@@ -471,10 +531,12 @@ public class AgentToolApprovalCoordinator {
* 为服务端持久化审批结果签发一次性执行授权。 * 为服务端持久化审批结果签发一次性执行授权。
* *
* @param callMap 已批准调用元数据 * @param callMap 已批准调用元数据
* @param trustedAuthorizations 本次恢复待签发的临时授权集合 * @param trustedAuthorizations 本次恢复待签发的一次性授权集合
* @param trustedReusableScopes 本次恢复待签发的可复用作用域集合
*/ */
private void authorizeTrustedCall(Map<?, ?> callMap, private void authorizeTrustedCall(Map<?, ?> callMap,
Map<String, ExecutionAuthorization> trustedAuthorizations) { Map<String, ExecutionAuthorization> trustedAuthorizations,
Set<String> trustedReusableScopes) {
String toolCallId = stringValue(callMap.get("toolCallId")); String toolCallId = stringValue(callMap.get("toolCallId"));
String toolName = stringValue(callMap.get("toolName")); String toolName = stringValue(callMap.get("toolName"));
if (toolCallId == null || toolName == null) { if (toolCallId == null || toolName == null) {
@@ -482,6 +544,17 @@ public class AgentToolApprovalCoordinator {
"Trusted resume metadata must include non-empty toolCallId and toolName."); "Trusted resume metadata must include non-empty toolCallId and toolName.");
} }
Map<String, Object> toolInput = stringKeyMap(callMap.get("toolInput")); 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 authorization = new ExecutionAuthorization(toolName, toolInput);
ExecutionAuthorization previous = trustedAuthorizations.put(toolCallId, authorization); ExecutionAuthorization previous = trustedAuthorizations.put(toolCallId, authorization);
if (previous != null 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

@@ -1,10 +0,0 @@
package com.easyagents.agent.runtime.knowledge;
/**
* 知识库检索策略。
*/
public enum AgentKnowledgePolicy {
AGENTIC,
GENERIC,
DISABLED
}

View File

@@ -0,0 +1,43 @@
package com.easyagents.agent.runtime.knowledge;
import java.util.Objects;
/**
* 知识库声明与检索器的运行时绑定。
*/
public final class AgentKnowledgeRegistration {
private final AgentKnowledgeSpec knowledgeSpec;
private final AgentKnowledgeRetriever retriever;
/**
* 创建知识库运行时绑定。
*
* @param knowledgeSpec 知识库声明
* @param retriever 知识库检索器
* @throws NullPointerException 声明或检索器为空时抛出
*/
public AgentKnowledgeRegistration(AgentKnowledgeSpec knowledgeSpec,
AgentKnowledgeRetriever retriever) {
this.knowledgeSpec = Objects.requireNonNull(knowledgeSpec, "knowledgeSpec");
this.retriever = Objects.requireNonNull(retriever, "retriever");
}
/**
* 获取知识库声明。
*
* @return 知识库声明
*/
public AgentKnowledgeSpec getKnowledgeSpec() {
return knowledgeSpec;
}
/**
* 获取知识库检索器。
*
* @return 知识库检索器
*/
public AgentKnowledgeRetriever getRetriever() {
return retriever;
}
}

View File

@@ -9,9 +9,9 @@ import java.util.Map;
public class AgentKnowledgeSpec { public class AgentKnowledgeSpec {
private String knowledgeId; private String knowledgeId;
private String runtimeName;
private String name; private String name;
private String description; private String description;
private AgentKnowledgePolicy retrievalMode = AgentKnowledgePolicy.AGENTIC;
private int limit = 5; private int limit = 5;
private double scoreThreshold = 0D; private double scoreThreshold = 0D;
private Map<String, Object> metadata = new LinkedHashMap<>(); private Map<String, Object> metadata = new LinkedHashMap<>();
@@ -34,6 +34,24 @@ public class AgentKnowledgeSpec {
this.knowledgeId = knowledgeId; this.knowledgeId = knowledgeId;
} }
/**
* 获取调用方提供的英文运行名。
*
* @return 英文运行名
*/
public String getRuntimeName() {
return runtimeName;
}
/**
* 设置调用方提供的英文运行名。
*
* @param runtimeName 英文运行名
*/
public void setRuntimeName(String runtimeName) {
this.runtimeName = runtimeName;
}
/** /**
* 获取知识库名称。 * 获取知识库名称。
* *
@@ -70,24 +88,6 @@ public class AgentKnowledgeSpec {
this.description = description; this.description = description;
} }
/**
* 获取检索模式。
*
* @return 检索模式
*/
public AgentKnowledgePolicy getRetrievalMode() {
return retrievalMode;
}
/**
* 设置检索模式。
*
* @param retrievalMode 检索模式
*/
public void setRetrievalMode(AgentKnowledgePolicy retrievalMode) {
this.retrievalMode = retrievalMode == null ? AgentKnowledgePolicy.AGENTIC : retrievalMode;
}
/** /**
* 获取限制数量。 * 获取限制数量。
* *

View File

@@ -0,0 +1,79 @@
package com.easyagents.agent.runtime.knowledge;
import com.easyagents.agent.runtime.AgentRuntimeException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.regex.Pattern;
/**
* Agent 知识库工具名称规范。
*/
public final class AgentKnowledgeToolNames {
/**
* 知识库工具名称前缀。
*/
public static final String PREFIX = "retrieve_knowledge_";
/**
* OpenAI-compatible Function Call 的通用名称长度上限。
*/
public static final int MAX_TOOL_NAME_LENGTH = 64;
private static final int HASH_LENGTH = 8;
private static final Pattern SAFE_RUNTIME_NAME = Pattern.compile("^[A-Za-z0-9_-]+$");
/**
* 禁止实例化工具类。
*/
private AgentKnowledgeToolNames() {
}
/**
* 根据调用方运行名生成稳定的知识库工具名。
*
* <p>超长名称保留可读前缀并追加稳定短哈希,避免不同模型服务对 Function Call
* 名称长度限制不一致。</p>
*
* @param runtimeName 调用方提供的英文运行名
* @return 合法且稳定的工具名
* @throws AgentRuntimeException 运行名为空或包含非法字符时抛出
*/
public static String build(String runtimeName) {
String normalized = runtimeName == null ? "" : runtimeName.trim();
if (normalized.isEmpty()) {
throw new AgentRuntimeException("Knowledge runtime name is required.");
}
if (!SAFE_RUNTIME_NAME.matcher(normalized).matches()) {
throw new AgentRuntimeException(
"Knowledge runtime name must contain only letters, numbers, underscores, or hyphens: "
+ normalized);
}
String toolName = PREFIX + normalized;
if (toolName.length() <= MAX_TOOL_NAME_LENGTH) {
return toolName;
}
String hash = shortHash(normalized);
int readableLength = MAX_TOOL_NAME_LENGTH - PREFIX.length() - HASH_LENGTH - 1;
return PREFIX + normalized.substring(0, readableLength) + "_" + hash;
}
/**
* 计算用于超长工具名消歧的稳定短哈希。
*
* @param value 原始运行名
* @return 八位十六进制哈希
*/
private static String shortHash(String value) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest).substring(0, HASH_LENGTH);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable.", exception);
}
}
}

View File

@@ -34,9 +34,16 @@ public class HeuristicKnowledgeCitationMatcher implements AgentKnowledgeCitation
if (normalizedAnswer.length() < MIN_NORMALIZED_ANSWER_LENGTH) { if (normalizedAnswer.length() < MIN_NORMALIZED_ANSWER_LENGTH) {
return List.of(); return List.of();
} }
List<String> normalizedSegments = normalizeSegments(answerText);
List<ScoredKnowledgeReference> scoredReferences = new ArrayList<>(); List<ScoredKnowledgeReference> scoredReferences = new ArrayList<>();
for (AgentKnowledgeReference candidate : candidates) { for (AgentKnowledgeReference candidate : candidates) {
double supportScore = supportScore(normalizedAnswer, normalize(candidate == null ? null : candidate.getChunkContent())); String normalizedContent = normalize(candidate == null ? null : candidate.getChunkContent());
double supportScore = supportScore(normalizedAnswer, normalizedContent);
// 长篇汇总回答中,每条证据通常只支撑一个段落或条目。继续只用整篇答案作分母,
// 会随着主题增多把有效引用的重合度稀释到阈值以下。
for (String normalizedSegment : normalizedSegments) {
supportScore = Math.max(supportScore, supportScore(normalizedSegment, normalizedContent));
}
if (supportScore >= MIN_SUPPORT_SCORE) { if (supportScore >= MIN_SUPPORT_SCORE) {
scoredReferences.add(new ScoredKnowledgeReference(candidate, supportScore)); scoredReferences.add(new ScoredKnowledgeReference(candidate, supportScore));
} }
@@ -48,6 +55,22 @@ public class HeuristicKnowledgeCitationMatcher implements AgentKnowledgeCitation
.toList(); .toList();
} }
/**
* 将答案切分为可独立核验的段落或句子并完成归一化。
*
* @param answerText 最终答案文本
* @return 非空的归一化答案片段
*/
private List<String> normalizeSegments(String answerText) {
if (answerText == null || answerText.isBlank()) {
return List.of();
}
return Arrays.stream(answerText.split("[\\r\\n。!?;]+"))
.map(this::normalize)
.filter(segment -> segment.length() >= MIN_NORMALIZED_ANSWER_LENGTH)
.toList();
}
/** /**
* 计算答案与候选片段之间的文本支撑分。 * 计算答案与候选片段之间的文本支撑分。
* *

View File

@@ -0,0 +1,155 @@
package com.easyagents.agent.runtime.mcp;
import io.agentscope.core.tool.mcp.McpClientWrapper;
import io.modelcontextprotocol.spec.McpSchema;
import reactor.core.publisher.Mono;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 将一次验证通过的远端 MCP Tool 清单冻结为只读白名单视图。
*/
final class FrozenMcpClientWrapper extends McpClientWrapper {
private final McpClientWrapper delegate;
private final List<McpSchema.Tool> frozenTools;
private final Map<String, String> runtimeToRaw = new LinkedHashMap<>();
/**
* 创建冻结 MCP client 视图。
*
* @param delegate 原始 client
* @param actualTools 已一次性读取并验证的远端 Tool
* @param manifest 冻结清单
* @param aliases 显式运行别名
* @param prefix 运行名前缀
*/
FrozenMcpClientWrapper(McpClientWrapper delegate,
List<McpSchema.Tool> actualTools,
List<McpToolManifestEntry> manifest,
Map<String, String> aliases,
String prefix) {
super(delegate == null ? "mcp" : delegate.getName());
this.delegate = delegate;
this.frozenTools = freeze(actualTools, manifest, aliases, prefix);
this.frozenTools.forEach(tool -> cachedTools.put(tool.name(), tool));
}
/** {@inheritDoc} */
@Override
public Mono<Void> initialize() {
return delegate.initialize().doOnSuccess(ignored -> initialized = delegate.isInitialized());
}
/** {@inheritDoc} */
@Override
public Mono<List<McpSchema.Tool>> listTools() {
return Mono.just(frozenTools);
}
/** {@inheritDoc} */
@Override
public Mono<McpSchema.CallToolResult> callTool(String toolName, Map<String, Object> arguments) {
return delegate.callTool(runtimeToRaw.getOrDefault(toolName, toolName), arguments);
}
/** {@inheritDoc} */
@Override
public void close() {
delegate.close();
initialized = false;
}
/**
* 按冻结 manifest 顺序裁剪并应用稳定运行别名。
*
* @param actualTools 远端当前 Tool
* @param manifest 冻结清单
* @param aliases 显式别名
* @param prefix 动态前缀
* @return 不可变 Tool 白名单
*/
private List<McpSchema.Tool> freeze(List<McpSchema.Tool> actualTools,
List<McpToolManifestEntry> manifest,
Map<String, String> aliases,
String prefix) {
Map<String, McpSchema.Tool> actualByName = new LinkedHashMap<>();
if (actualTools != null) {
actualTools.stream().filter(tool -> tool != null && tool.name() != null)
.forEach(tool -> actualByName.put(tool.name(), tool));
}
Map<String, String> usedRuntimeNames = new LinkedHashMap<>();
List<McpSchema.Tool> result = new ArrayList<>();
for (McpToolManifestEntry entry : manifest) {
McpSchema.Tool actual = actualByName.get(entry.getName());
if (actual == null) {
throw new IllegalStateException("Frozen MCP tool is missing after validation: " + entry.getName());
}
String runtimeName = uniqueRuntimeName(
runtimeName(actual.name(), aliases, prefix), actual.name(), usedRuntimeNames);
runtimeToRaw.put(runtimeName, actual.name());
Map<String, Object> meta = new LinkedHashMap<>();
if (actual.meta() != null) {
meta.putAll(actual.meta());
}
if (!runtimeName.equals(actual.name())) {
meta.put(AliasedMcpClientWrapper.RAW_TOOL_NAME_META_KEY, actual.name());
}
// 模型可见描述也必须来自发布时冻结清单,避免远端描述在运行中漂移。
result.add(new McpSchema.Tool(runtimeName, actual.title(), entry.getDescription(),
actual.inputSchema(), actual.outputSchema(), actual.annotations(), meta));
}
return List.copyOf(result);
}
/**
* 计算单个 Tool 的运行名。
*
* @param rawName 原始名称
* @param aliases 显式别名
* @param prefix 动态前缀
* @return 运行名
*/
private String runtimeName(String rawName, Map<String, String> aliases, String prefix) {
String alias = aliases == null ? null : aliases.get(rawName);
if (alias != null && !alias.isBlank()) {
return alias;
}
if (prefix == null || prefix.isBlank()) {
return rawName;
}
String segment = String.valueOf(rawName == null ? "" : rawName).trim()
.replaceAll("[^A-Za-z0-9_-]", "_")
.replaceAll("_+", "_");
return prefix.trim() + (segment.isBlank() ? "tool" : segment);
}
/**
* 避免别名碰撞。
*
* @param candidate 候选运行名
* @param rawName 原始名称
* @param used 已使用运行名
* @return 唯一运行名
*/
private String uniqueRuntimeName(String candidate,
String rawName,
Map<String, String> used) {
String existing = used.get(candidate);
if (existing == null || existing.equals(rawName)) {
used.put(candidate, rawName);
return candidate;
}
int suffix = 2;
String value = candidate + "_" + suffix;
while (used.containsKey(value)) {
suffix++;
value = candidate + "_" + suffix;
}
used.put(value, rawName);
return value;
}
}

View File

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

View File

@@ -0,0 +1,88 @@
package com.easyagents.agent.runtime.mcp;
import io.agentscope.core.tool.mcp.McpClientWrapper;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 等待注册到指定 Skill 的 MCP client。
*/
public class McpSkillRegistration {
private final String skillId;
private final McpClientWrapper client;
private final List<String> enableTools;
private final List<String> disableTools;
private final Map<String, Map<String, Object>> presetParameters;
/**
* 创建 Skill MCP 注册声明。
*
* @param skillId Skill ID
* @param client MCP client
* @param enableTools 运行时工具白名单
* @param disableTools 运行时工具黑名单
* @param presetParameters 预设参数
*/
public McpSkillRegistration(String skillId,
McpClientWrapper client,
List<String> enableTools,
List<String> disableTools,
Map<String, Map<String, Object>> presetParameters) {
this.skillId = skillId;
this.client = client;
this.enableTools = enableTools == null ? List.of() : new ArrayList<>(enableTools);
this.disableTools = disableTools == null ? List.of() : new ArrayList<>(disableTools);
this.presetParameters = presetParameters == null
? Map.of()
: new LinkedHashMap<>(presetParameters);
}
/**
* 获取 Skill ID。
*
* @return Skill ID
*/
public String getSkillId() {
return skillId;
}
/**
* 获取 MCP client。
*
* @return MCP client
*/
public McpClientWrapper getClient() {
return client;
}
/**
* 获取运行时工具白名单。
*
* @return 工具白名单
*/
public List<String> getEnableTools() {
return enableTools;
}
/**
* 获取运行时工具黑名单。
*
* @return 工具黑名单
*/
public List<String> getDisableTools() {
return disableTools;
}
/**
* 获取预设参数。
*
* @return 预设参数
*/
public Map<String, Map<String, Object>> getPresetParameters() {
return presetParameters;
}
}

View File

@@ -33,6 +33,9 @@ public class McpSpec {
private boolean approvalRequired; private boolean approvalRequired;
private AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest(); private AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest();
private Map<String, AgentToolApprovalRequest> toolApprovalRequests = new LinkedHashMap<>(); private Map<String, AgentToolApprovalRequest> toolApprovalRequests = new LinkedHashMap<>();
private String skillId;
private List<McpToolManifestEntry> frozenToolManifest = new ArrayList<>();
private String frozenToolManifestHash;
private Map<String, Object> metadata = new LinkedHashMap<>(); private Map<String, Object> metadata = new LinkedHashMap<>();
/** /**
@@ -406,6 +409,62 @@ public class McpSpec {
: new LinkedHashMap<>(toolApprovalRequests); : new LinkedHashMap<>(toolApprovalRequests);
} }
/**
* 获取所属 Skill ID。
*
* @return Skill ID未绑定 Skill 时为空
*/
public String getSkillId() {
return skillId;
}
/**
* 设置所属 Skill ID。
*
* @param skillId Skill ID
*/
public void setSkillId(String skillId) {
this.skillId = skillId;
}
/**
* 获取冻结 Tool 清单。
*
* @return 冻结 Tool 清单
*/
public List<McpToolManifestEntry> getFrozenToolManifest() {
return frozenToolManifest;
}
/**
* 设置冻结 Tool 清单。
*
* @param frozenToolManifest 冻结 Tool 清单
*/
public void setFrozenToolManifest(List<McpToolManifestEntry> frozenToolManifest) {
this.frozenToolManifest = frozenToolManifest == null
? new ArrayList<>()
: new ArrayList<>(frozenToolManifest);
}
/**
* 获取冻结 Tool 清单 hash。
*
* @return 清单 hash
*/
public String getFrozenToolManifestHash() {
return frozenToolManifestHash;
}
/**
* 设置冻结 Tool 清单 hash。
*
* @param frozenToolManifestHash 清单 hash
*/
public void setFrozenToolManifestHash(String frozenToolManifestHash) {
this.frozenToolManifestHash = frozenToolManifestHash;
}
/** /**
* 获取元数据。 * 获取元数据。
* *

View File

@@ -0,0 +1,332 @@
package com.easyagents.agent.runtime.mcp;
import com.alibaba.fastjson2.JSON;
import com.easyagents.agent.runtime.AgentRuntimeException;
import io.modelcontextprotocol.spec.McpSchema;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/**
* MCP Tool 冻结清单规范化与完整性校验器。
*/
public final class McpToolManifest {
/** MCP Tool 原始名称允许的最大 Unicode 字符数。 */
public static final int MAX_TOOL_NAME_LENGTH = 128;
/** MCP Tool 描述允许的最大 Unicode 字符数。 */
public static final int MAX_TOOL_DESCRIPTION_LENGTH = 4_096;
/** 单个输入或输出 Schema 允许的最大 UTF-8 字节数。 */
public static final int MAX_SCHEMA_UTF8_BYTES = 256 * 1_024;
/** 完整规范化 Manifest 允许的最大 UTF-8 字节数。 */
public static final int MAX_MANIFEST_UTF8_BYTES = 2 * 1_024 * 1_024;
private McpToolManifest() {
}
/**
* 将 MCP Tool 转换为稳定清单项。
*
* @param tools MCP Tool 列表
* @return 按名称稳定排序的清单
*/
public static List<McpToolManifestEntry> fromTools(List<McpSchema.Tool> tools) {
if (tools == null || tools.isEmpty()) {
return List.of();
}
List<McpToolManifestEntry> entries = new ArrayList<>();
Set<String> names = new HashSet<>();
int manifestBytes = 2;
for (McpSchema.Tool tool : tools) {
if (tool == null || tool.name() == null || tool.name().isBlank()) {
continue;
}
if (!names.add(tool.name())) {
throw new AgentRuntimeException("Duplicate MCP tool name: " + tool.name());
}
McpToolManifestEntry entry = new McpToolManifestEntry();
entry.setName(tool.name());
entry.setDescription(normalizeText(tool.description()));
entry.setInputSchema(normalizeSchema("MCP tool input schema", tool.inputSchema()));
entry.setOutputSchema(normalizeSchema("MCP tool output schema", tool.outputSchema()));
assertEntryBounds(entry);
manifestBytes += JSON.toJSONString(toCanonicalValue(entry))
.getBytes(StandardCharsets.UTF_8).length;
if (!entries.isEmpty()) {
manifestBytes++;
}
if (manifestBytes > MAX_MANIFEST_UTF8_BYTES) {
throw new AgentRuntimeException("MCP tool manifest exceeds "
+ MAX_MANIFEST_UTF8_BYTES + " UTF-8 bytes.");
}
entries.add(entry);
}
entries.sort(Comparator.comparing(McpToolManifestEntry::getName));
assertManifestSize(entries);
return List.copyOf(entries);
}
/**
* 计算冻结清单的 SHA-256。
*
* @param entries 冻结清单
* @return 十六进制 SHA-256
*/
public static String hash(List<McpToolManifestEntry> entries) {
String json = canonicalJson(entries);
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(json.getBytes(StandardCharsets.UTF_8));
return java.util.HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException error) {
throw new AgentRuntimeException("SHA-256 is unavailable for MCP manifest validation.", error);
}
}
/**
* 校验远端 Tool 与冻结白名单一致,同时忽略远端新增 Tool。
*
* @param spec MCP 运行时声明
* @param actualTools 远端当前 Tool
* @throws AgentRuntimeException 冻结清单缺失、被篡改、Tool 缺失或 Schema 漂移时抛出
*/
public static void assertFrozenManifest(McpSpec spec, List<McpSchema.Tool> actualTools) {
if (spec == null || spec.getSkillId() == null || spec.getSkillId().isBlank()) {
return;
}
List<McpToolManifestEntry> expected = spec.getFrozenToolManifest();
String expectedHash = spec.getFrozenToolManifestHash();
if (expected == null || expected.isEmpty() || expectedHash == null || expectedHash.isBlank()) {
throw new AgentRuntimeException("Skill-bound MCP requires a frozen tool manifest: " + spec.getName());
}
if (!expectedHash.equals(hash(expected))) {
throw new AgentRuntimeException("Skill-bound MCP frozen tool manifest is invalid: " + spec.getName());
}
Set<String> frozenNames = new HashSet<>();
for (McpToolManifestEntry entry : expected) {
if (entry != null && entry.getName() != null && !entry.getName().isBlank()) {
frozenNames.add(entry.getName());
}
}
Map<String, McpToolManifestEntry> actualByName = new LinkedHashMap<>();
if (actualTools != null) {
for (McpSchema.Tool tool : actualTools) {
if (tool == null || tool.name() == null || !frozenNames.contains(tool.name())) {
continue;
}
if (actualByName.containsKey(tool.name())) {
throw new AgentRuntimeException("Duplicate MCP tool name: " + tool.name());
}
List<McpToolManifestEntry> normalized = fromTools(List.of(tool));
if (!normalized.isEmpty()) {
actualByName.put(tool.name(), normalized.get(0));
}
}
}
for (McpToolManifestEntry expectedEntry : expected) {
if (expectedEntry == null || expectedEntry.getName() == null || expectedEntry.getName().isBlank()) {
throw new AgentRuntimeException("Skill-bound MCP frozen tool name is required: " + spec.getName());
}
McpToolManifestEntry actualEntry = actualByName.get(expectedEntry.getName());
if (actualEntry == null) {
throw new AgentRuntimeException("Skill-bound MCP tool is missing: " + expectedEntry.getName());
}
if (!sameRuntimeSchema(expectedEntry, actualEntry)) {
throw new AgentRuntimeException("Skill-bound MCP tool schema has changed: "
+ expectedEntry.getName());
}
}
}
/**
* 比较 Runtime 必须锁定的 Tool 名称及输入、输出 Schema。
*
* <p>描述用于保存与发布阶段的完整 manifest 变更识别,但远端仅调整描述时不会改变
* 已发布 Tool 的可调用边界,因此运行时不应中断既有 Agent。</p>
*
* @param expected 冻结清单项
* @param actual 远端当前清单项
* @return 名称及 Schema 相同时返回 {@code true}
*/
private static boolean sameRuntimeSchema(McpToolManifestEntry expected,
McpToolManifestEntry actual) {
return java.util.Objects.equals(expected.getName(), actual.getName())
&& java.util.Objects.equals(normalizeJson(expected.getInputSchema()),
normalizeJson(actual.getInputSchema()))
&& java.util.Objects.equals(normalizeJson(expected.getOutputSchema()),
normalizeJson(actual.getOutputSchema()));
}
/**
* 将冻结清单转换为稳定 JSON并对反序列化后的清单执行同等边界校验。
*
* @param entries 冻结清单
* @return 稳定 JSON
* @throws AgentRuntimeException 清单包含重复名称或超出预算时抛出
*/
private static String canonicalJson(List<McpToolManifestEntry> entries) {
List<Map<String, Object>> canonical = new ArrayList<>();
Set<String> names = new HashSet<>();
if (entries != null) {
entries.stream()
.filter(entry -> entry != null && entry.getName() != null && !entry.getName().isBlank())
.sorted(Comparator.comparing(McpToolManifestEntry::getName))
.forEach(entry -> {
if (!names.add(entry.getName())) {
throw new AgentRuntimeException("Duplicate MCP tool name: " + entry.getName());
}
McpToolManifestEntry normalized = new McpToolManifestEntry();
normalized.setName(entry.getName());
normalized.setDescription(normalizeText(entry.getDescription()));
normalized.setInputSchema(normalizeSchema(
"MCP tool input schema", entry.getInputSchema()));
normalized.setOutputSchema(normalizeSchema(
"MCP tool output schema", entry.getOutputSchema()));
assertEntryBounds(normalized);
canonical.add(toCanonicalValue(normalized));
});
}
String json = JSON.toJSONString(canonical);
assertUtf8Size("MCP tool manifest", json, MAX_MANIFEST_UTF8_BYTES);
return json;
}
/**
* 校验单个清单项的名称、描述及 Schema 预算。
*
* @param entry 已规范化的清单项
* @throws AgentRuntimeException 任一字段超出预算时抛出
*/
private static void assertEntryBounds(McpToolManifestEntry entry) {
assertTextLength("MCP tool name", entry.getName(), MAX_TOOL_NAME_LENGTH);
assertTextLength("MCP tool description", entry.getDescription(), MAX_TOOL_DESCRIPTION_LENGTH);
assertSchemaSize("MCP tool input schema", entry.getInputSchema());
assertSchemaSize("MCP tool output schema", entry.getOutputSchema());
}
/**
* 校验规范化清单的聚合字节预算。
*
* @param entries 已规范化且排序的清单
* @throws AgentRuntimeException 清单超出聚合预算时抛出
*/
private static void assertManifestSize(List<McpToolManifestEntry> entries) {
List<Map<String, Object>> canonical = entries.stream()
.map(McpToolManifest::toCanonicalValue)
.toList();
assertUtf8Size("MCP tool manifest", JSON.toJSONString(canonical), MAX_MANIFEST_UTF8_BYTES);
}
/**
* 构造用于哈希和预算计算的稳定清单值。
*
* @param entry 已规范化的清单项
* @return 保持字段顺序的清单值
*/
private static Map<String, Object> toCanonicalValue(McpToolManifestEntry entry) {
Map<String, Object> value = new LinkedHashMap<>();
value.put("name", entry.getName());
value.put("description", normalizeText(entry.getDescription()));
value.put("inputSchema", entry.getInputSchema());
value.put("outputSchema", entry.getOutputSchema());
return value;
}
/**
* 校验 Unicode 字符长度,避免 UTF-16 代理对被重复计数。
*
* @param field 字段名称
* @param value 字段值
* @param maxLength 最大 Unicode 字符数
* @throws AgentRuntimeException 字段超长时抛出
*/
private static void assertTextLength(String field, String value, int maxLength) {
if (value != null && value.codePointCount(0, value.length()) > maxLength) {
throw new AgentRuntimeException(field + " exceeds " + maxLength + " characters.");
}
}
/**
* 校验单个 Schema 的 UTF-8 字节预算。
*
* @param field Schema 字段名称
* @param schema 已规范化 Schema
* @throws AgentRuntimeException Schema 超出预算时抛出
*/
private static void assertSchemaSize(String field, Object schema) {
if (schema != null) {
assertUtf8Size(field, JSON.toJSONString(schema), MAX_SCHEMA_UTF8_BYTES);
}
}
/**
* 校验 JSON 或文本的 UTF-8 字节长度。
*
* @param field 字段名称
* @param value 待校验文本
* @param maxBytes 最大 UTF-8 字节数
* @throws AgentRuntimeException 文本超出预算时抛出
*/
private static void assertUtf8Size(String field, String value, int maxBytes) {
int bytes = value.getBytes(StandardCharsets.UTF_8).length;
if (bytes > maxBytes) {
throw new AgentRuntimeException(field + " exceeds " + maxBytes + " UTF-8 bytes.");
}
}
/**
* 在解析和排序前限制原始 Schema避免超大输入进入规范化流程。
*
* @param field Schema 字段名称
* @param value 原始 Schema
* @return 规范化 Schema
* @throws AgentRuntimeException 原始 Schema 超出预算时抛出
*/
private static Object normalizeSchema(String field, Object value) {
if (value == null) {
return null;
}
String json = JSON.toJSONString(value);
assertUtf8Size(field, json, MAX_SCHEMA_UTF8_BYTES);
return sortJson(JSON.parse(json));
}
private static Object normalizeJson(Object value) {
if (value == null) {
return null;
}
return sortJson(JSON.parse(JSON.toJSONString(value)));
}
private static Object sortJson(Object value) {
if (value instanceof Map<?, ?> source) {
Map<String, Object> sorted = new TreeMap<>();
source.forEach((key, child) -> sorted.put(String.valueOf(key), sortJson(child)));
return sorted;
}
if (value instanceof List<?> source) {
List<Object> sorted = new ArrayList<>(source.size());
for (Object child : source) {
sorted.add(sortJson(child));
}
return sorted;
}
return value;
}
private static String normalizeText(String value) {
return value == null ? "" : value;
}
}

View File

@@ -0,0 +1,105 @@
package com.easyagents.agent.runtime.mcp;
import java.util.Objects;
/**
* MCP Tool 冻结清单项。
*/
public class McpToolManifestEntry {
private String name;
private String description;
private Object inputSchema;
private Object outputSchema;
/**
* 获取 Tool 名称。
*
* @return Tool 名称
*/
public String getName() {
return name;
}
/**
* 设置 Tool 名称。
*
* @param name Tool 名称
*/
public void setName(String name) {
this.name = name;
}
/**
* 获取 Tool 描述。
*
* @return Tool 描述
*/
public String getDescription() {
return description;
}
/**
* 设置 Tool 描述。
*
* @param description Tool 描述
*/
public void setDescription(String description) {
this.description = description;
}
/**
* 获取输入 Schema。
*
* @return 输入 Schema
*/
public Object getInputSchema() {
return inputSchema;
}
/**
* 设置输入 Schema。
*
* @param inputSchema 输入 Schema
*/
public void setInputSchema(Object inputSchema) {
this.inputSchema = inputSchema;
}
/**
* 获取输出 Schema。
*
* @return 输出 Schema
*/
public Object getOutputSchema() {
return outputSchema;
}
/**
* 设置输出 Schema。
*
* @param outputSchema 输出 Schema
*/
public void setOutputSchema(Object outputSchema) {
this.outputSchema = outputSchema;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof McpToolManifestEntry that)) {
return false;
}
return Objects.equals(name, that.name)
&& Objects.equals(description, that.description)
&& Objects.equals(inputSchema, that.inputSchema)
&& Objects.equals(outputSchema, that.outputSchema);
}
@Override
public int hashCode() {
return Objects.hash(name, description, inputSchema, outputSchema);
}
}

View File

@@ -52,6 +52,7 @@ public class McpToolkitAdapter {
} }
List<McpClientWrapper> clients = new ArrayList<>(); List<McpClientWrapper> clients = new ArrayList<>();
List<AgentToolSpec> toolSpecs = new ArrayList<>(); List<AgentToolSpec> toolSpecs = new ArrayList<>();
List<McpSkillRegistration> skillRegistrations = new ArrayList<>();
try { try {
for (McpSpec spec : specs) { for (McpSpec spec : specs) {
if (spec == null) { if (spec == null) {
@@ -59,16 +60,59 @@ public class McpToolkitAdapter {
} }
McpSpecValidator.validateConnection(spec); McpSpecValidator.validateConnection(spec);
McpClientWrapper client = clientFactory.create(spec); McpClientWrapper client = clientFactory.create(spec);
client = applyAliases(spec, client);
clients.add(client); clients.add(client);
registerClient(spec, client, toolkit); if (isSkillBound(spec)) {
List<McpSchema.Tool> actualTools = initializeAndListTools(client);
McpToolManifest.assertFrozenManifest(spec, actualTools);
client = new FrozenMcpClientWrapper(client, actualTools,
spec.getFrozenToolManifest(), spec.getToolAliases(), spec.getToolNamePrefix());
} else {
client = applyAliases(spec, client);
}
clients.set(clients.size() - 1, client);
if (isSkillBound(spec)) {
// Skill MCP 必须以冻结 manifest 派生白名单,调用方不能通过空列表放宽到远端全部 Tool。
spec.setEnableTools(frozenRuntimeToolNames(spec, client));
skillRegistrations.add(new McpSkillRegistration(
spec.getSkillId(), client, spec.getEnableTools(), spec.getDisableTools(),
spec.getPresetParameters()));
} else {
registerClient(spec, client, toolkit);
}
toolSpecs.addAll(toToolSpecs(spec, registeredTools(spec, client))); toolSpecs.addAll(toToolSpecs(spec, registeredTools(spec, client)));
} }
} catch (RuntimeException error) { } catch (RuntimeException error) {
closeQuietly(clients); closeQuietly(clients);
throw error; throw error;
} }
return new McpRegistration(clients, toolSpecs); return new McpRegistration(clients, toolSpecs, skillRegistrations);
}
/**
* 根据冻结原始 Tool 名称和别名后的远端清单生成强制运行白名单。
*
* @param spec Skill MCP 声明
* @param client 已应用运行别名的 client
* @return 冻结 Tool 对应的运行名
*/
private List<String> frozenRuntimeToolNames(McpSpec spec, McpClientWrapper client) {
Set<String> frozenRawNames = new LinkedHashSet<>();
for (McpToolManifestEntry entry : spec.getFrozenToolManifest()) {
if (entry != null && entry.getName() != null && !entry.getName().isBlank()) {
frozenRawNames.add(entry.getName());
}
}
List<String> names = new ArrayList<>();
for (McpSchema.Tool tool : listTools(client)) {
if (tool != null && frozenRawNames.contains(rawToolName(spec, tool))) {
names.add(tool.name());
}
}
if (names.size() != frozenRawNames.size()) {
throw new AgentRuntimeException("Skill-bound MCP frozen tool aliases are incomplete: "
+ spec.getName());
}
return List.copyOf(names);
} }
private McpClientWrapper applyAliases(McpSpec spec, McpClientWrapper client) { private McpClientWrapper applyAliases(McpSpec spec, McpClientWrapper client) {
@@ -95,7 +139,7 @@ public class McpToolkitAdapter {
} }
private List<McpSchema.Tool> registeredTools(McpSpec spec, McpClientWrapper client) { private List<McpSchema.Tool> registeredTools(McpSpec spec, McpClientWrapper client) {
List<McpSchema.Tool> tools = client.listTools().block(); List<McpSchema.Tool> tools = listTools(client);
if (tools == null || tools.isEmpty()) { if (tools == null || tools.isEmpty()) {
return List.of(); return List.of();
} }
@@ -108,6 +152,30 @@ public class McpToolkitAdapter {
return filtered; return filtered;
} }
private List<McpSchema.Tool> listTools(McpClientWrapper client) {
List<McpSchema.Tool> tools = client.listTools().block();
return tools == null ? List.of() : tools;
}
/**
* 初始化 client 后读取一次远端 Tool 清单。
*
* @param client MCP client
* @return Tool 清单
*/
private List<McpSchema.Tool> initializeAndListTools(McpClientWrapper client) {
// AgentScope validates the initialized flag when listTools() is invoked. Build the
// second publisher only after initialization has completed, otherwise eager publisher
// assembly can fail even though the server initializes successfully moments later.
client.initialize().block();
List<McpSchema.Tool> tools = client.listTools().block();
return tools == null ? List.of() : tools;
}
private boolean isSkillBound(McpSpec spec) {
return spec.getSkillId() != null && !spec.getSkillId().isBlank();
}
private boolean shouldRegister(String toolName, List<String> enableTools, List<String> disableTools) { private boolean shouldRegister(String toolName, List<String> enableTools, List<String> disableTools) {
if (enableTools != null && !enableTools.isEmpty()) { if (enableTools != null && !enableTools.isEmpty()) {
return enableTools.contains(toolName); return enableTools.contains(toolName);
@@ -166,6 +234,9 @@ public class McpToolkitAdapter {
metadata.put("rawMcpToolName", rawToolName(spec, tool)); metadata.put("rawMcpToolName", rawToolName(spec, tool));
metadata.put("toolDisplayName", toolDisplayName(spec, tool)); metadata.put("toolDisplayName", toolDisplayName(spec, tool));
metadata.put("transportType", spec.getTransportType().configValue()); metadata.put("transportType", spec.getTransportType().configValue());
if (isSkillBound(spec)) {
metadata.put("skillId", spec.getSkillId());
}
return metadata; return metadata;
} }

View File

@@ -0,0 +1,105 @@
package com.easyagents.agent.runtime.prompt;
import com.easyagents.agent.runtime.AgentDefinition;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import java.util.ArrayList;
import java.util.List;
/**
* 组合智能体运行时使用的系统提示词。
*/
public final class SystemPromptComposer {
private static final String KNOWLEDGE_TOOL_PROTOCOL = """
Knowledge tool protocol:
- Knowledge tools provide grounded information for the scopes described in their tool descriptions.
- Before answering a request involving facts, policies, procedures, entitlements, prices, product or service details, or other information within a knowledge tool's scope, call the most relevant knowledge tool first.
- Do not rely only on general model knowledge for claims that fall within an available knowledge tool's scope.
- If it is uncertain whether the request falls within a knowledge tool's scope, prefer making one retrieval call.
- Build a concise, standalone query from the current request and only the necessary conversation context. Resolve pronouns and omitted subjects, and preserve relevant names, time, location, product, membership level, constraints, and user intent. Do not copy the entire conversation.
- If the returned results are empty or do not address the request, reformulate the query and retry once when a materially different query is possible. Do not repeat the same query or retrieve indefinitely.
- Base knowledge-backed claims only on information actually returned by the tools. Never imply that a knowledge base contains information that was not returned.
- If retrieval remains insufficient, follow the Agent's configured system prompt for the response strategy.
- Skip retrieval for greetings, casual conversation, pure writing or translation, and tasks that clearly do not depend on knowledge-base facts.
""".strip();
private static final String ASYNC_TOOL_PROTOCOL = """
Async tool protocol:
- Async tools may expose submit, observe, result, cancel, and list sub-tools. Treat these sub-tools as one user-facing tool.
- Do not ask the user to choose submit, observe, result, cancel, or list. These are internal execution phases.
- For a normal user request to use an async tool, call its submit sub-tool first with the user-provided arguments by default.
- After submit returns task_id, immediately call observe with that task_id to check progress.
- If the task is completed and result is available, use the returned result to answer the user.
- If the task is still running after observation, tell the user that the task is running and keep task_id/next_action for later tool calls.
- Use result, list, or cancel directly only when the user explicitly asks to get a known task result, list tasks, or cancel a task.
""".strip();
/**
* 阻止工具类被实例化。
*/
private SystemPromptComposer() {
}
/**
* 按固定顺序组合用户提示词与运行时工具协议。
*
* @param definition 智能体定义
* @return 最终系统提示词;没有任何提示词时返回原始空值
*/
public static String compose(AgentDefinition definition) {
if (definition == null) {
return null;
}
boolean knowledgeProtocolEnabled = hasEnabledKnowledgeTool(definition);
boolean asyncProtocolEnabled = hasAsyncTool(definition);
String userPrompt = definition.getSystemPrompt();
if (!knowledgeProtocolEnabled && !asyncProtocolEnabled) {
return userPrompt;
}
List<String> promptSections = new ArrayList<>(3);
if (userPrompt != null && !userPrompt.isBlank()) {
promptSections.add(userPrompt.stripTrailing());
}
if (knowledgeProtocolEnabled) {
promptSections.add(KNOWLEDGE_TOOL_PROTOCOL);
}
if (asyncProtocolEnabled) {
promptSections.add(ASYNC_TOOL_PROTOCOL);
}
return String.join("\n\n", promptSections);
}
/**
* 判断当前定义是否会注册可调用的知识库工具。
*
* @param definition 智能体定义
* @return 知识库工具可用时返回 true
*/
private static boolean hasEnabledKnowledgeTool(AgentDefinition definition) {
return definition.getExecutionOptions() != null
&& definition.getExecutionOptions().isToolCallingEnabled()
&& definition.getKnowledgeSpecs() != null
&& !definition.getKnowledgeSpecs().isEmpty();
}
/**
* 判断当前定义是否包含异步工具。
*
* @param definition 智能体定义
* @return 包含异步工具时返回 true
*/
private static boolean hasAsyncTool(AgentDefinition definition) {
if (definition.getToolSpecs() == null) {
return false;
}
for (AgentToolSpec toolSpec : definition.getToolSpecs()) {
// AsyncToolSpecExpander 会为生成的全部子工具写入该运行时元数据。
if (toolSpec != null && Boolean.TRUE.equals(toolSpec.getMetadata().get("asyncTool"))) {
return true;
}
}
return false;
}
}

View File

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

View File

@@ -11,6 +11,7 @@ public class AgentSkillLoadCall {
private final String toolCallId; private final String toolCallId;
private final String skillId; private final String skillId;
private final String skillName; private final String skillName;
private final String skillDisplayName;
private final String skillBoxId; private final String skillBoxId;
private final String path; private final String path;
private final Map<String, Object> input; private final Map<String, Object> input;
@@ -31,9 +32,33 @@ public class AgentSkillLoadCall {
String skillBoxId, String skillBoxId,
String path, String path,
Map<String, Object> input) { Map<String, Object> input) {
this(toolCallId, skillId, skillName, skillName, skillBoxId, path, input);
}
/**
* 创建带展示名称的 Skill 加载工具调用记录。
*
* @param toolCallId 工具调用 ID
* @param skillId Skill ID
* @param skillName Skill 规范名称
* @param skillDisplayName Skill 展示名称
* @param skillBoxId SkillBox ID
* @param path 资源路径
* @param input 工具输入
*/
public AgentSkillLoadCall(String toolCallId,
String skillId,
String skillName,
String skillDisplayName,
String skillBoxId,
String path,
Map<String, Object> input) {
this.toolCallId = toolCallId; this.toolCallId = toolCallId;
this.skillId = skillId; this.skillId = skillId;
this.skillName = skillName; this.skillName = skillName;
this.skillDisplayName = skillDisplayName == null || skillDisplayName.isBlank()
? skillName
: skillDisplayName;
this.skillBoxId = skillBoxId; this.skillBoxId = skillBoxId;
this.path = path; this.path = path;
this.input = input == null ? new LinkedHashMap<>() : new LinkedHashMap<>(input); this.input = input == null ? new LinkedHashMap<>() : new LinkedHashMap<>(input);
@@ -66,6 +91,15 @@ public class AgentSkillLoadCall {
return skillName; return skillName;
} }
/**
* 获取 Skill 展示名称。
*
* @return Skill 展示名称
*/
public String getSkillDisplayName() {
return skillDisplayName;
}
/** /**
* 获取 SkillBox ID。 * 获取 SkillBox ID。
* *

View File

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

View File

@@ -1,6 +1,7 @@
package com.easyagents.agent.runtime.tool; package com.easyagents.agent.runtime.tool;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest; import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalPolicy;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
@@ -18,6 +19,7 @@ public class AgentToolSpec {
private AgentToolVisibility visibility = AgentToolVisibility.VISIBLE; private AgentToolVisibility visibility = AgentToolVisibility.VISIBLE;
private boolean approvalRequired; private boolean approvalRequired;
private AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest(); private AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest();
private AgentToolApprovalPolicy approvalPolicy;
private Map<String, Object> metadata = new LinkedHashMap<>(); private Map<String, Object> metadata = new LinkedHashMap<>();
/** /**
@@ -164,6 +166,24 @@ public class AgentToolSpec {
this.approvalRequest = approvalRequest == null ? new AgentToolApprovalRequest() : approvalRequest; 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.AgentToolSpec;
import com.easyagents.agent.runtime.tool.AgentToolVisibility; import com.easyagents.agent.runtime.tool.AgentToolVisibility;
import io.agentscope.core.tool.Toolkit; 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.nio.file.Path;
import java.util.*; import java.util.*;
/** /**
* AgentScope 内置操作工具适配器。 * AgentScope 内置操作工具适配器。
* *
* <p>该适配器只负责将 Easy-Agents 的操作工具声明转换为 AgentScope Toolkit 中的原生工具。 * <p>该适配器将 Easy-Agents 的操作工具声明转换为 AgentScope 1.x 工具名和 Schema 兼容的
* Shell 工具的人工审批不使用 AgentScope {@code ShellCommandTool} 的同步 callback而是通过 * 受控实现。Shell 人工审批继续通过 Easy-Agents {@code ToolHitlInterceptor} 处理,以保持
* Easy-Agents 现有 {@code ToolHitlInterceptor} 统一处理,以保持 SSE 暂停、恢复和审计语义一致。 * SSE 暂停、恢复和审计语义一致。
*/ */
public class AgentOperateToolAdapter { public class AgentOperateToolAdapter {
@@ -28,6 +22,7 @@ public class AgentOperateToolAdapter {
public static final String LIST_DIRECTORY_TOOL = "list_directory"; public static final String LIST_DIRECTORY_TOOL = "list_directory";
public static final String WRITE_TEXT_FILE_TOOL = "write_text_file"; 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 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"; 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(WRITE_TEXT_FILE_TOOL);
names.add(INSERT_TEXT_FILE_TOOL); names.add(INSERT_TEXT_FILE_TOOL);
} }
case PATCH -> names.add(APPLY_PATCH_TOOL);
case SHELL -> names.add(EXECUTE_SHELL_COMMAND_TOOL); case SHELL -> names.add(EXECUTE_SHELL_COMMAND_TOOL);
default -> { default -> {
} }
@@ -88,54 +84,66 @@ public class AgentOperateToolAdapter {
if (type == null) { if (type == null) {
throw new AgentRuntimeException("Agent operate tool type is required."); 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) { switch (type) {
case READ_FILE -> { case READ_FILE -> {
assertNoToolConflict(toolkit, VIEW_TEXT_FILE_TOOL); assertNoToolConflict(toolkit, VIEW_TEXT_FILE_TOOL);
assertNoToolConflict(toolkit, LIST_DIRECTORY_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, VIEW_TEXT_FILE_TOOL, "View text file content.", false));
toolSpecs.add(toolSpec(spec, LIST_DIRECTORY_TOOL, "List files and directories.", false)); toolSpecs.add(toolSpec(spec, LIST_DIRECTORY_TOOL, "List files and directories.", false));
} }
case WRITE_FILE -> { case WRITE_FILE -> {
assertNoToolConflict(toolkit, WRITE_TEXT_FILE_TOOL); assertNoToolConflict(toolkit, WRITE_TEXT_FILE_TOOL);
assertNoToolConflict(toolkit, INSERT_TEXT_FILE_TOOL); assertNoToolConflict(toolkit, INSERT_TEXT_FILE_TOOL);
toolkit.registerTool(new WriteFileTool(baseDir.toString())); SafeWriteFileTool writeFileTool = new SafeWriteFileTool(pathGuard, quotaGuard);
toolSpecs.add(toolSpec(spec, WRITE_TEXT_FILE_TOOL, "Write or replace text file content.", true)); toolkit.registerAgentTool(writeFileTool.writeTextFileTool());
toolSpecs.add(toolSpec(spec, INSERT_TEXT_FILE_TOOL, "Insert text into a file.", true)); 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 -> { case SHELL -> {
assertNoToolConflict(toolkit, EXECUTE_SHELL_COMMAND_TOOL); assertNoToolConflict(toolkit, EXECUTE_SHELL_COMMAND_TOOL);
Charset charset = parseCharset(spec); ControlledShellTool shellTool = new ControlledShellTool(pathGuard, quotaGuard, spec);
toolkit.registerAgentTool(new ShellCommandTool(baseDir.toString(), spec.getShellAllowedCommands(), null, toolkit.registerAgentTool(shellTool);
null, charset)); AgentToolSpec shellToolSpec = toolSpec(
toolSpecs.add(toolSpec(spec, EXECUTE_SHELL_COMMAND_TOOL, "Execute shell command.", true)); 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); default -> throw new AgentRuntimeException("Unsupported agent operate tool type: " + type);
} }
} }
private Path validateBaseDir(AgentOperateToolSpec spec) { private WorkspacePathGuard createPathGuard(AgentOperateToolSpec spec) {
String baseDir = spec.getBaseDir(); String baseDir = spec.getBaseDir();
if (baseDir == null || baseDir.isBlank()) { if (baseDir == null || baseDir.isBlank()) {
throw new AgentRuntimeException("Agent operate tool baseDir is required."); throw new AgentRuntimeException("Agent operate tool baseDir is required.");
} }
Path path = Path.of(baseDir).toAbsolutePath().normalize();
if (!Path.of(baseDir).isAbsolute()) { if (!Path.of(baseDir).isAbsolute()) {
throw new AgentRuntimeException("Agent operate tool baseDir must be an absolute path: " + baseDir); throw new AgentRuntimeException("Agent operate tool baseDir must be an absolute path.");
}
return path;
}
private Charset parseCharset(AgentOperateToolSpec spec) {
String charsetName = spec.getShellCharset();
if (charsetName == null || charsetName.isBlank()) {
return StandardCharsets.UTF_8;
} }
try { try {
return Charset.forName(charsetName.trim()); return new WorkspacePathGuard(Path.of(baseDir).toAbsolutePath().normalize());
} catch (Exception error) { } catch (RuntimeException error) {
throw new AgentRuntimeException("Invalid shell charset: " + charsetName, 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.setVisibility(AgentToolVisibility.VISIBLE);
toolSpec.setApprovalRequired(approvalRequired); toolSpec.setApprovalRequired(approvalRequired);
toolSpec.setApprovalRequest(approvalRequest(operateSpec, approvalRequired)); toolSpec.setApprovalRequest(approvalRequest(operateSpec, approvalRequired));
toolSpec.setMetadata(metadata(operateSpec)); toolSpec.setMetadata(metadata(operateSpec, approvalRequired));
return toolSpec; return toolSpec;
} }
@@ -168,11 +176,14 @@ public class AgentOperateToolAdapter {
return defaultRequest; return defaultRequest;
} }
private Map<String, Object> metadata(AgentOperateToolSpec spec) { private Map<String, Object> metadata(AgentOperateToolSpec spec, boolean approvalRequired) {
Map<String, Object> metadata = new LinkedHashMap<>(); Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("operateTool", true); metadata.put("operateTool", true);
metadata.put("operateToolType", spec.getType().name()); 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; return metadata;
} }

View File

@@ -4,13 +4,13 @@ import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.Set; import java.util.Set;
import java.time.Duration;
/** /**
* Agent 操作类工具声明。 * Agent 操作类工具声明。
* *
* <p>操作类工具 runtime 直接适配的 AgentScope 内置工具,用于读文件、写文件和执行 Shell。 * <p>操作类工具 runtime 适配为与 AgentScope 1.x 契约兼容的受控工具。调用方必须按
* 这些工具直接作用于后端 JVM 所在宿主环境,调用方必须按 agent、session 或 user 维度传入受控 * agent、session 或 user 维度传入独立的绝对工作目录,并通过配额与 Shell 参数限制资源使用。
* 的绝对工作目录。
*/ */
public class AgentOperateToolSpec { public class AgentOperateToolSpec {
@@ -19,8 +19,18 @@ public class AgentOperateToolSpec {
private String baseDir; private String baseDir;
private Boolean approvalRequired; private Boolean approvalRequired;
private AgentToolApprovalRequest approvalRequest; private AgentToolApprovalRequest approvalRequest;
private Set<String> shellAllowedCommands = new LinkedHashSet<>(); private WorkspaceQuotaLimits workspaceQuotaLimits = WorkspaceQuotaLimits.unlimited();
private String shellCharset; 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; 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) { public void setShellCharset(String shellCharset) {
this.shellCharset = 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, WRITE_FILE,
/**
* 以补丁方式新增、更新或删除工作区文本文件。
*/
PATCH,
/** /**
* 在服务进程所在宿主环境执行 Shell 命令。 * 在服务进程所在宿主环境执行 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

@@ -0,0 +1,226 @@
package com.easyagents.agent.runtime.agentscope;
import com.easyagents.agent.runtime.AgentDefinition;
import com.easyagents.agent.runtime.AgentRuntimeExecutionContext;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventBridge;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.event.AgentRuntimeTurnContext;
import com.easyagents.agent.runtime.event.AgentRuntimeTurnContextHolder;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalCoordinator;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeToolNames;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock;
import io.agentscope.core.tool.ToolCallParam;
import io.agentscope.core.tool.Toolkit;
import org.junit.Assert;
import org.junit.Test;
import reactor.core.publisher.Sinks;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
/**
* {@link AgentScopeKnowledgeAdapter} 回归测试。
*/
public class AgentScopeKnowledgeAdapterTest {
/**
* 验证每个知识库生成独立工具,工具名、描述和 Schema 向模型暴露完整信息。
*/
@Test
public void shouldCreateOneToolPerKnowledge() {
AgentKnowledgeSpec first = knowledgeSpec("knowledge-1", "homeinn_faq", 5);
first.setName("如家 FAQ");
first.setDescription("如家酒店入住、退房和会员服务常见问题");
AgentKnowledgeSpec second = knowledgeSpec("knowledge-2", "hotel_policy", 3);
AgentRuntimeExecutionContext context = executionContext(List.of(first, second));
context.setKnowledgeRegistrations(List.of(
registration(first, 1, 0.9D),
registration(second, 1, 0.8D)));
List<AgentToolSpec> toolSpecs = new AgentScopeKnowledgeAdapter().createToolSpecs(context);
Assert.assertEquals(2, toolSpecs.size());
Assert.assertEquals("retrieve_knowledge_homeinn_faq", toolSpecs.get(0).getName());
Assert.assertTrue(toolSpecs.get(0).getDescription().contains("如家 FAQ"));
Assert.assertTrue(toolSpecs.get(0).getDescription().contains("入住、退房"));
Assert.assertEquals(List.of("query"), toolSpecs.get(0).getParametersSchema().get("required"));
Assert.assertFalse(String.valueOf(toolSpecs.get(0).getParametersSchema()).contains("limit"));
}
/**
* 验证选中一个知识库工具时只调用对应 Retriever并使用绑定 limit 和阈值。
*/
@Test
public void shouldDispatchOnlyToSelectedKnowledgeRetriever() {
AgentKnowledgeSpec first = knowledgeSpec("knowledge-1", "homeinn_faq", 2);
first.setScoreThreshold(0.5D);
AgentKnowledgeSpec second = knowledgeSpec("knowledge-2", "hotel_policy", 2);
AgentRuntimeExecutionContext context = executionContext(List.of(first, second));
AtomicInteger firstCalls = new AtomicInteger();
AtomicInteger secondCalls = new AtomicInteger();
context.setKnowledgeRegistrations(List.of(
new AgentKnowledgeRegistration(first, request -> {
firstCalls.incrementAndGet();
Assert.assertEquals("如家几点退房", request.getQuery());
Assert.assertEquals(2, request.getLimit());
Assert.assertEquals(0.5D, request.getScoreThreshold(), 0.0001D);
return AgentKnowledgeRetrievalResult.of(List.of(
document("knowledge-1", 0.9D),
document("knowledge-1-low", 0.2D)));
}),
new AgentKnowledgeRegistration(second, request -> {
secondCalls.incrementAndGet();
return AgentKnowledgeRetrievalResult.of(List.of(document("knowledge-2", 0.8D)));
})));
RegisteredKnowledgeTools registered = register(context);
ToolResultBlock result = registered.toolkit().getTool("retrieve_knowledge_homeinn_faq")
.callAsync(toolCall("retrieve_knowledge_homeinn_faq", "如家几点退房"))
.block();
Assert.assertNotNull(result);
Assert.assertEquals(1, firstCalls.get());
Assert.assertEquals(0, secondCalls.get());
Assert.assertEquals(1, result.getMetadata().get("documentCount"));
}
/**
* 验证最终知识库事件与阈值过滤、排序和截断后的模型可见文档一致。
*/
@Test
public void retrievalEventShouldMatchFinalToolDocuments() {
AgentKnowledgeSpec spec = knowledgeSpec("knowledge-1", "homeinn_faq", 2);
spec.setScoreThreshold(0.5D);
AgentRuntimeExecutionContext context = executionContext(List.of(spec));
context.setKnowledgeRegistrations(List.of(new AgentKnowledgeRegistration(spec, request ->
AgentKnowledgeRetrievalResult.of(List.of(
document("third", 0.7D),
document("first", 0.9D),
document("filtered", 0.2D),
document("second", 0.8D))))));
RegisteredKnowledgeTools registered = register(context);
ToolResultBlock result = registered.toolkit().getTool("retrieve_knowledge_homeinn_faq")
.callAsync(toolCall("retrieve_knowledge_homeinn_faq", "query"))
.block();
List<AgentRuntimeEvent> events = registered.eventSink().asFlux()
.filter(event -> event.getEventType() == AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL)
.take(1)
.collectList()
.block(Duration.ofSeconds(1));
Assert.assertNotNull(result);
Assert.assertNotNull(events);
Assert.assertEquals(1, events.size());
AgentRuntimeEvent event = events.get(0);
Assert.assertEquals(2, event.getPayload().get("documentCount"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> eventDocuments =
(List<Map<String, Object>>) event.getPayload().get("documents");
@SuppressWarnings("unchecked")
List<Map<String, Object>> resultDocuments =
(List<Map<String, Object>>) result.getMetadata().get("documents");
Assert.assertEquals(resultDocuments, eventDocuments);
Assert.assertEquals("first", eventDocuments.get(0).get("documentId"));
Assert.assertEquals("second", eventDocuments.get(1).get("documentId"));
}
/**
* 验证超长运行名会稳定压缩到 Function Call 长度上限。
*/
@Test
public void longRuntimeNameShouldProduceStableBoundedToolName() {
String runtimeName = "knowledge_" + "a".repeat(80);
String first = AgentKnowledgeToolNames.build(runtimeName);
String second = AgentKnowledgeToolNames.build(runtimeName);
Assert.assertEquals(first, second);
Assert.assertEquals(AgentKnowledgeToolNames.MAX_TOOL_NAME_LENGTH, first.length());
Assert.assertTrue(first.startsWith(AgentKnowledgeToolNames.PREFIX));
}
private RegisteredKnowledgeTools register(AgentRuntimeExecutionContext context) {
AgentScopeKnowledgeAdapter adapter = new AgentScopeKnowledgeAdapter();
List<AgentToolSpec> toolSpecs = adapter.createToolSpecs(context);
Toolkit toolkit = new Toolkit();
Sinks.Many<AgentRuntimeEvent> eventSink = Sinks.many().replay().all();
AgentRuntimeTurnContextHolder holder = new AgentRuntimeTurnContextHolder();
AgentRuntimeEventBridge eventBridge = new AgentRuntimeEventBridge(context, holder);
holder.set(new AgentRuntimeTurnContext(context, eventSink, eventBridge));
adapter.registerTools(context, toolSpecs, toolkit, new AgentScopeToolAdapter(),
AgentToolApprovalCoordinator.disabled(), holder);
return new RegisteredKnowledgeTools(toolkit, eventSink);
}
private ToolCallParam toolCall(String toolName, String query) {
ToolUseBlock toolUseBlock = ToolUseBlock.builder()
.id("call-1")
.name(toolName)
.input(Map.of("query", query))
.build();
return ToolCallParam.builder()
.toolUseBlock(toolUseBlock)
.input(toolUseBlock.getInput())
.build();
}
private AgentRuntimeExecutionContext executionContext(List<AgentKnowledgeSpec> specs) {
AgentDefinition definition = new AgentDefinition();
definition.setAgentId("agent-1");
definition.setKnowledgeSpecs(specs);
AgentRuntimeExecutionContext context = new AgentRuntimeExecutionContext();
context.setAgentDefinition(definition);
context.setTraceId("trace-1");
context.setSessionId("session-1");
return context;
}
private AgentKnowledgeSpec knowledgeSpec(String knowledgeId, String runtimeName, int limit) {
AgentKnowledgeSpec spec = new AgentKnowledgeSpec();
spec.setKnowledgeId(knowledgeId);
spec.setRuntimeName(runtimeName);
spec.setName(knowledgeId);
spec.setLimit(limit);
return spec;
}
private AgentKnowledgeRegistration registration(AgentKnowledgeSpec spec,
int documentCount,
double firstScore) {
return new AgentKnowledgeRegistration(spec, request ->
AgentKnowledgeRetrievalResult.of(documents(spec.getKnowledgeId(), documentCount, firstScore)));
}
private List<AgentKnowledgeDocument> documents(String knowledgeId, int count, double firstScore) {
List<AgentKnowledgeDocument> documents = new ArrayList<>();
for (int index = 0; index < count; index++) {
documents.add(document(knowledgeId + "-doc-" + index, firstScore - (index * 0.01D)));
}
return documents;
}
private AgentKnowledgeDocument document(String documentId, double score) {
AgentKnowledgeDocument document = new AgentKnowledgeDocument();
document.setDocumentId(documentId);
document.setDocumentName("FAQ");
document.setChunkId(documentId + "-chunk");
document.setContent("content-" + documentId);
document.setScore(score);
return document;
}
private record RegisteredKnowledgeTools(Toolkit toolkit,
Sinks.Many<AgentRuntimeEvent> eventSink) {
}
}

View File

@@ -10,6 +10,7 @@ import com.easyagents.agent.runtime.event.observer.SkillExecutionObserver;
import com.easyagents.agent.runtime.event.observer.ToolExecutionObserver; import com.easyagents.agent.runtime.event.observer.ToolExecutionObserver;
import com.easyagents.agent.runtime.hitl.AgentResumeToken; import com.easyagents.agent.runtime.hitl.AgentResumeToken;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument; import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult; import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec; import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
import com.easyagents.agent.runtime.memory.AgentMemoryCompressionParameter; import com.easyagents.agent.runtime.memory.AgentMemoryCompressionParameter;
@@ -23,6 +24,7 @@ import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSess
import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec; import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec;
import com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext; import com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext;
import com.easyagents.agent.runtime.skill.AgentSkillSpec; import com.easyagents.agent.runtime.skill.AgentSkillSpec;
import com.easyagents.agent.runtime.tool.AgentToolCategory;
import com.easyagents.agent.runtime.tool.AgentToolResult; import com.easyagents.agent.runtime.tool.AgentToolResult;
import com.easyagents.agent.runtime.tool.AgentToolSpec; import com.easyagents.agent.runtime.tool.AgentToolSpec;
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolAdapter; import com.easyagents.agent.runtime.tool.operate.AgentOperateToolAdapter;
@@ -153,6 +155,27 @@ public class AgentScopeStatefulRuntimeTest {
Assert.assertTrue(runtime.getAgent().getSysPrompt().contains("immediately call observe")); Assert.assertTrue(runtime.getAgent().getSysPrompt().contains("immediately call observe"));
} }
@Test
public void shouldAppendKnowledgeToolProtocolPromptWhenKnowledgeToolsExist() {
AgentInitRequest request = initRequest();
AgentKnowledgeSpec knowledgeSpec = new AgentKnowledgeSpec();
knowledgeSpec.setKnowledgeId("knowledge-faq");
knowledgeSpec.setRuntimeName("faq");
request.getAgentDefinition().setKnowledgeSpecs(List.of(knowledgeSpec));
request.setKnowledgeRegistrations(List.of(new AgentKnowledgeRegistration(
knowledgeSpec, retrievalRequest -> AgentKnowledgeRetrievalResult.of(List.of()))));
AgentScopeReActRuntime runtime = fakeRuntime();
runtime.init(request);
Assert.assertTrue(runtime.getAgent().getSysPrompt()
.startsWith("system\n\nKnowledge tool protocol:"));
Assert.assertTrue(runtime.getAgent().getSysPrompt()
.contains("call the most relevant knowledge tool first"));
Assert.assertTrue(runtime.getAgent().getSysPrompt()
.contains("reformulate the query and retry once"));
}
@Test @Test
public void shouldEmitSideEventWithRuntimeIdentityFromBridge() throws Exception { public void shouldEmitSideEventWithRuntimeIdentityFromBridge() throws Exception {
AgentRuntimeExecutionContext context = new AgentRuntimeExecutionContext(); AgentRuntimeExecutionContext context = new AgentRuntimeExecutionContext();
@@ -235,12 +258,33 @@ public class AgentScopeStatefulRuntimeTest {
Assert.assertTrue(interceptors.stream().anyMatch(ToolHitlInterceptor.class::isInstance)); Assert.assertTrue(interceptors.stream().anyMatch(ToolHitlInterceptor.class::isInstance));
} }
/**
* 验证调用方提供的历史快照会在 Agent 首次构建时进入模型记忆。
*/
@Test
public void shouldAttachInitialConversationHistoryToAgentMemory() {
AgentInitRequest request = initRequest();
AgentMemorySnapshot snapshot = new AgentMemorySnapshot();
snapshot.addMessage(AgentMessage.text(AgentMessageRole.USER, "previous user question"));
snapshot.addMessage(AgentMessage.text(AgentMessageRole.ASSISTANT, "previous assistant answer"));
request.setMemorySnapshot(snapshot);
AgentScopeReActRuntime runtime = fakeRuntime();
runtime.init(request);
List<Msg> messages = runtime.getAgent().getMemory().getMessages();
Assert.assertEquals(2, messages.size());
Assert.assertEquals("previous user question", messages.get(0).getTextContent());
Assert.assertEquals("previous assistant answer", messages.get(1).getTextContent());
}
@Test @Test
public void shouldRegisterOperateToolsIntoToolkit() { public void shouldRegisterOperateToolsIntoToolkit() {
AgentInitRequest request = initRequest(); AgentInitRequest request = initRequest();
request.getAgentDefinition().setOperateToolSpecs(List.of( request.getAgentDefinition().setOperateToolSpecs(List.of(
operateToolSpec(AgentOperateToolType.READ_FILE), operateToolSpec(AgentOperateToolType.READ_FILE),
operateToolSpec(AgentOperateToolType.WRITE_FILE), operateToolSpec(AgentOperateToolType.WRITE_FILE),
operateToolSpec(AgentOperateToolType.PATCH),
operateToolSpec(AgentOperateToolType.SHELL))); operateToolSpec(AgentOperateToolType.SHELL)));
AgentScopeReActRuntime runtime = fakeRuntime(); AgentScopeReActRuntime runtime = fakeRuntime();
@@ -251,6 +295,7 @@ public class AgentScopeStatefulRuntimeTest {
Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.LIST_DIRECTORY_TOOL)); Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.LIST_DIRECTORY_TOOL));
Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.WRITE_TEXT_FILE_TOOL)); Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.WRITE_TEXT_FILE_TOOL));
Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.INSERT_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)); Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL));
} }
@@ -258,14 +303,13 @@ public class AgentScopeStatefulRuntimeTest {
public void shouldSuspendShellOperateToolWithToolHitlInterceptor() { public void shouldSuspendShellOperateToolWithToolHitlInterceptor() {
AgentInitRequest request = initRequest(); AgentInitRequest request = initRequest();
AgentOperateToolSpec shell = operateToolSpec(AgentOperateToolType.SHELL); AgentOperateToolSpec shell = operateToolSpec(AgentOperateToolType.SHELL);
shell.setShellAllowedCommands(Set.of());
request.getAgentDefinition().setOperateToolSpecs(List.of(shell)); request.getAgentDefinition().setOperateToolSpecs(List.of(shell));
AgentScopeReActRuntime runtime = runtimeWithModel(List.of(ChatResponse.builder() AgentScopeReActRuntime runtime = runtimeWithModel(List.of(ChatResponse.builder()
.id("shell-call-message") .id("shell-call-message")
.content(List.of(ToolUseBlock.builder() .content(List.of(ToolUseBlock.builder()
.id("call-shell") .id("call-shell")
.name(AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL) .name(AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL)
.input(Map.of("command", "echo hello")) .input(Map.of("command", "pwd"))
.build())) .build()))
.finishReason("tool_calls") .finishReason("tool_calls")
.build())); .build()));
@@ -280,6 +324,36 @@ public class AgentScopeStatefulRuntimeTest {
Assert.assertTrue(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.SUSPENDED)); 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) @Test(expected = AgentRuntimeException.class)
public void shouldRejectOperateToolNameConflictWithBusinessTool() { public void shouldRejectOperateToolNameConflictWithBusinessTool() {
AgentInitRequest request = initRequest(); AgentInitRequest request = initRequest();
@@ -498,10 +572,12 @@ public class AgentScopeStatefulRuntimeTest {
ToolUseBlock toolUse = ToolUseBlock.builder() ToolUseBlock toolUse = ToolUseBlock.builder()
.id("call-1") .id("call-1")
.name("search") .name("search")
.input(Map.of("q", "easyflow")) .input(Map.of("q", "sentinel-secret-input"))
.metadata(Map.of("authorization", "sentinel-secret-metadata"))
.build(); .build();
ToolResultBlock toolResult = ToolResultBlock.of("call-1", "search", 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 PreActingEvent(agent, toolkit, toolUse)).block();
observer.observe(new PostActingEvent(agent, toolkit, toolUse, toolResult)).block(); observer.observe(new PostActingEvent(agent, toolkit, toolUse, toolResult)).block();
@@ -509,13 +585,92 @@ public class AgentScopeStatefulRuntimeTest {
Assert.assertEquals(AgentRuntimeEventType.TOOL_CALL, events.get(0).getEventType()); Assert.assertEquals(AgentRuntimeEventType.TOOL_CALL, events.get(0).getEventType());
Assert.assertEquals("RUNNING", events.get(0).getPayload().get("status")); 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 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(AgentRuntimeEventType.TOOL_RESULT, events.get(1).getEventType());
Assert.assertEquals("SUCCESS", events.get(1).getPayload().get("status")); 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.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"));
}
/**
* 验证知识库工具复用统一工具生命周期,并携带可供调用方投影的知识库分类。
*
* @throws Exception 等待旁路事件失败时抛出
*/
@Test
public void shouldEmitKnowledgeToolLifecycleFromToolExecutionObserver() throws Exception {
AgentRuntimeExecutionContext context = executionContext();
Sinks.Many<AgentRuntimeEvent> sink = Sinks.many().replay().all();
AgentRuntimeEventBridge bridge = AgentRuntimeEventBridge.fixed(context, sink);
AgentToolSpec toolSpec = new AgentToolSpec();
toolSpec.setName("retrieve_knowledge_homeinn_faq");
toolSpec.setCategory(AgentToolCategory.KNOWLEDGE);
toolSpec.getMetadata().put("toolDisplayName", "如家 FAQ");
toolSpec.getMetadata().put("knowledgeId", "knowledge-1");
toolSpec.getMetadata().put("knowledgeName", "如家 FAQ");
toolSpec.getMetadata().put("knowledgeRuntimeName", "homeinn_faq");
ToolExecutionObserver observer = new ToolExecutionObserver(bridge, null, List.of(toolSpec));
ReActAgent agent = initializedAgent();
Toolkit toolkit = agent.getToolkit();
ToolUseBlock toolUse = ToolUseBlock.builder()
.id("knowledge-call-1")
.name("retrieve_knowledge_homeinn_faq")
.input(Map.of("query", "几点退房"))
.build();
ToolResultBlock toolResult = ToolResultBlock.of(
"knowledge-call-1",
"retrieve_knowledge_homeinn_faq",
TextBlock.builder().text("中午十二点退房").build(),
Map.of("success", true));
observer.observe(new PreActingEvent(agent, toolkit, toolUse)).block();
observer.observe(new PostActingEvent(agent, toolkit, toolUse, toolResult)).block();
List<AgentRuntimeEvent> events = sink.asFlux().take(2).collectList().toFuture()
.get(3, TimeUnit.SECONDS);
Assert.assertEquals(AgentRuntimeEventType.TOOL_CALL, events.get(0).getEventType());
Assert.assertEquals("KNOWLEDGE", events.get(0).getPayload().get("toolCategory"));
Assert.assertEquals("knowledge-1", events.get(0).getPayload().get("knowledgeId"));
Assert.assertEquals("RUNNING", events.get(0).getPayload().get("status"));
Assert.assertEquals(AgentRuntimeEventType.TOOL_RESULT, events.get(1).getEventType());
Assert.assertEquals("KNOWLEDGE", events.get(1).getPayload().get("toolCategory"));
Assert.assertEquals("SUCCESS", events.get(1).getPayload().get("status"));
}
/**
* 验证 AgentScope 的文本错误结果会投影为失败工具状态。
*
* @throws Exception 等待旁路事件失败时抛出
*/
@Test
public void shouldMarkAgentScopeKnowledgeToolErrorAsFailed() throws Exception {
AgentRuntimeExecutionContext context = executionContext();
Sinks.Many<AgentRuntimeEvent> sink = Sinks.many().replay().all();
AgentRuntimeEventBridge bridge = AgentRuntimeEventBridge.fixed(context, sink);
AgentToolSpec toolSpec = new AgentToolSpec();
toolSpec.setName("retrieve_knowledge_homeinn_faq");
toolSpec.setCategory(AgentToolCategory.KNOWLEDGE);
ToolExecutionObserver observer = new ToolExecutionObserver(bridge, null, List.of(toolSpec));
ReActAgent agent = initializedAgent();
ToolUseBlock toolUse = ToolUseBlock.builder()
.id("knowledge-call-error")
.name("retrieve_knowledge_homeinn_faq")
.input(Map.of("query", "异常查询"))
.build();
ToolResultBlock toolResult = ToolResultBlock.error("retriever unavailable")
.withIdAndName("knowledge-call-error", "retrieve_knowledge_homeinn_faq");
observer.observe(new PostActingEvent(agent, agent.getToolkit(), toolUse, toolResult)).block();
AgentRuntimeEvent event = sink.asFlux().next().toFuture().get(3, TimeUnit.SECONDS);
Assert.assertEquals(AgentRuntimeEventType.TOOL_RESULT, event.getEventType());
Assert.assertEquals("FAILED", event.getPayload().get("status"));
Assert.assertEquals(Boolean.FALSE, event.getPayload().get("success"));
} }
@Test @Test
@@ -748,7 +903,12 @@ public class AgentScopeStatefulRuntimeTest {
@Test @Test
public void shouldRejectConcurrentStatefulStream() { 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()); runtime.init(initRequest());
reactor.core.Disposable disposable = runtime.stream(AgentMessage.text(AgentMessageRole.USER, "first")) reactor.core.Disposable disposable = runtime.stream(AgentMessage.text(AgentMessageRole.USER, "first"))
@@ -797,6 +957,59 @@ public class AgentScopeStatefulRuntimeTest {
&& "partial answer".equals(message.getTextContent()))); && "partial answer".equals(message.getTextContent())));
} }
@Test
public void shouldPersistUserMessageWhenModelFails() {
InMemoryAgentSessionStore sessionStore = new InMemoryAgentSessionStore();
AgentInitRequest request = initRequest();
request.setSessionStore(sessionStore);
AgentScopeReActRuntime runtime = runtimeWithError(new IllegalStateException("model unavailable"));
runtime.init(request);
List<AgentRuntimeEvent> events = runtime.stream(
AgentMessage.text(AgentMessageRole.USER, "remember this request"))
.collectList()
.block();
Assert.assertTrue(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.FAILED));
Assert.assertTrue(sessionStore.exists("session-1"));
AgentScopeReActRuntime restoredRuntime = fakeRuntime();
restoredRuntime.init(request);
Assert.assertTrue(restoredRuntime.getAgent().getMemory().getMessages().stream()
.anyMatch(message -> message.getRole() == MsgRole.USER
&& "remember this request".equals(message.getTextContent())));
}
@Test
public void shouldPersistUserMessageWhenReasoningOnlyStreamIsCancelled() throws Exception {
InMemoryAgentSessionStore sessionStore = new InMemoryAgentSessionStore();
AgentInitRequest request = initRequest();
request.setSessionStore(sessionStore);
AgentScopeReActRuntime runtime = runtimeWithModel(List.of(
ChatResponse.builder()
.id("reasoning-only")
.content(List.of(ThinkingBlock.builder().thinking("still thinking").build()))
.build()), Duration.ofSeconds(5));
runtime.init(request);
CompletableFuture<AgentRuntimeEvent> firstReasoning = new CompletableFuture<>();
reactor.core.Disposable disposable = runtime.stream(
AgentMessage.text(AgentMessageRole.USER, "remember reasoning request"))
.subscribe(event -> {
if (event.getEventType() == AgentRuntimeEventType.REASONING_DELTA) {
firstReasoning.complete(event);
}
}, firstReasoning::completeExceptionally);
firstReasoning.get(3, TimeUnit.SECONDS);
disposable.dispose();
awaitCondition(() -> sessionStore.exists("session-1"));
AgentScopeReActRuntime restoredRuntime = fakeRuntime();
restoredRuntime.init(request);
Assert.assertTrue(restoredRuntime.getAgent().getMemory().getMessages().stream()
.anyMatch(message -> message.getRole() == MsgRole.USER
&& "remember reasoning request".equals(message.getTextContent())));
}
@Test @Test
public void shouldNotDuplicateNormalToolEventsFromMainStream() { public void shouldNotDuplicateNormalToolEventsFromMainStream() {
AgentInitRequest request = initRequest(); AgentInitRequest request = initRequest();
@@ -974,6 +1187,188 @@ public class AgentScopeStatefulRuntimeTest {
Assert.assertTrue(sessionStore.exists("session-1")); 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));
}
/** /**
* 验证同一轮推理包含多个审批工具时,全部批准前不会执行任何工具。 * 验证同一轮推理包含多个审批工具时,全部批准前不会执行任何工具。
*/ */
@@ -1354,9 +1749,12 @@ public class AgentScopeStatefulRuntimeTest {
AgentInitRequest request = initRequest(); AgentInitRequest request = initRequest();
AgentKnowledgeSpec knowledgeSpec = new AgentKnowledgeSpec(); AgentKnowledgeSpec knowledgeSpec = new AgentKnowledgeSpec();
knowledgeSpec.setKnowledgeId("knowledge-1"); knowledgeSpec.setKnowledgeId("knowledge-1");
knowledgeSpec.setRuntimeName("faq");
knowledgeSpec.setName("知识库"); knowledgeSpec.setName("知识库");
request.getAgentDefinition().setKnowledgeSpecs(List.of(knowledgeSpec)); request.getAgentDefinition().setKnowledgeSpecs(List.of(knowledgeSpec));
request.setKnowledgeRetrievers(Map.of("knowledge-1", retrievalRequest -> { AtomicBoolean knowledgeInvoked = new AtomicBoolean(false);
request.setKnowledgeRegistrations(List.of(new AgentKnowledgeRegistration(knowledgeSpec, retrievalRequest -> {
knowledgeInvoked.set(true);
AgentKnowledgeDocument document = new AgentKnowledgeDocument(); AgentKnowledgeDocument document = new AgentKnowledgeDocument();
document.setDocumentId("doc-1"); document.setDocumentId("doc-1");
document.setDocumentName("说明文档"); document.setDocumentName("说明文档");
@@ -1364,9 +1762,27 @@ public class AgentScopeStatefulRuntimeTest {
document.setContent("fake answer"); document.setContent("fake answer");
document.setScore(0.9D); document.setScore(0.9D);
return AgentKnowledgeRetrievalResult.of(List.of(document)); return AgentKnowledgeRetrievalResult.of(List.of(document));
})); })));
AgentScopeReActRuntime runtime = fakeRuntime(); AgentScopeReActRuntime runtime = runtimeWithModel(List.of(
ChatResponse.builder()
.id("knowledge-tool-call")
.content(List.of(ToolUseBlock.builder()
.id("call-search")
.name("retrieve_knowledge_faq")
.input(Map.of("query", "fake answer"))
.content("{\"query\":\"fake answer\"}")
.build()))
.finishReason("tool_calls")
.build(),
ChatResponse.builder()
.id("knowledge-final")
.content(List.of(TextBlock.builder().text("fake answer").build()))
.finishReason("stop")
.build()));
runtime.init(request); runtime.init(request);
Assert.assertNotNull(runtime.getAgent().getToolkit().getTool("retrieve_knowledge_faq"));
Assert.assertTrue(runtime.getAgent().getToolkit().getToolSchemas().stream()
.anyMatch(schema -> "retrieve_knowledge_faq".equals(schema.getName())));
List<AgentRuntimeEvent> events = runtime.stream(AgentMessage.text(AgentMessageRole.USER, "query knowledge")) List<AgentRuntimeEvent> events = runtime.stream(AgentMessage.text(AgentMessageRole.USER, "query knowledge"))
.collectList() .collectList()
@@ -1377,19 +1793,44 @@ public class AgentScopeStatefulRuntimeTest {
.findFirst() .findFirst()
.orElseThrow(); .orElseThrow();
Assert.assertNotNull(completed.getMessage()); Assert.assertNotNull(completed.getMessage());
if (completed.getMessage().getKnowledgeReferences().isEmpty()) { Assert.assertTrue("knowledgeInvoked=" + knowledgeInvoked.get() + ", events="
/* + events.stream().map(AgentRuntimeEvent::getEventType).toList(),
* 当前 runtime 将知识库注册为 AgentScope AGENTIC RAG模型需要主动调用 events.stream().anyMatch(event ->
* retrieve_knowledge 才会产生 KNOWLEDGE_RETRIEVAL 旁路事件。fake model event.getEventType() == AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL));
* 不会调用该工具时,不应强行猜引用。 Assert.assertTrue(events.stream().anyMatch(event ->
*/ event.getEventType() == AgentRuntimeEventType.TOOL_CALL
Assert.assertFalse(events.stream().anyMatch(event -> && "KNOWLEDGE".equals(event.getPayload().get("toolCategory"))));
event.getEventType() == AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL)); Assert.assertTrue(events.stream().anyMatch(event ->
return; event.getEventType() == AgentRuntimeEventType.TOOL_RESULT
} && "KNOWLEDGE".equals(event.getPayload().get("toolCategory"))));
Assert.assertEquals("chunk-1", completed.getMessage().getKnowledgeReferences().get(0).getChunkId()); Assert.assertEquals("chunk-1", completed.getMessage().getKnowledgeReferences().get(0).getChunkId());
} }
/**
* 验证知识库生成工具与普通工具同名时拒绝初始化,避免 Toolkit 静默覆盖。
*/
@Test(expected = AgentRuntimeException.class)
public void shouldRejectKnowledgeToolNameConflictWithRegularTool() {
AgentInitRequest request = initRequest();
AgentKnowledgeSpec knowledgeSpec = new AgentKnowledgeSpec();
knowledgeSpec.setKnowledgeId("knowledge-1");
knowledgeSpec.setRuntimeName("faq");
knowledgeSpec.setName("知识库");
request.getAgentDefinition().setKnowledgeSpecs(List.of(knowledgeSpec));
request.setKnowledgeRegistrations(List.of(new AgentKnowledgeRegistration(
knowledgeSpec,
retrievalRequest -> AgentKnowledgeRetrievalResult.of(List.of()))));
AgentToolSpec regularTool = new AgentToolSpec();
regularTool.setName("retrieve_knowledge_faq");
regularTool.setDescription("conflicting tool");
request.getAgentDefinition().setToolSpecs(List.of(regularTool));
request.setToolInvokers(Map.of(
regularTool.getName(),
(arguments, context) -> AgentToolResult.success("done")));
fakeRuntime().init(request);
}
private AgentScopeReActRuntime fakeRuntime() { private AgentScopeReActRuntime fakeRuntime() {
return new AgentScopeReActRuntime(new FakeAgentScopeModelFactory(), new AgentScopeToolAdapter(), return new AgentScopeReActRuntime(new FakeAgentScopeModelFactory(), new AgentScopeToolAdapter(),
new AgentScopeKnowledgeAdapter(), new AgentScopeMemoryAdapter(), new AgentScopeSkillAdapter(), new AgentScopeKnowledgeAdapter(), new AgentScopeMemoryAdapter(), new AgentScopeSkillAdapter(),
@@ -1413,6 +1854,58 @@ public class AgentScopeStatefulRuntimeTest {
new AgentScopeMessageAdapter()); new AgentScopeMessageAdapter());
} }
/**
* 创建模型调用直接失败的运行时。
*
* @param error 模型异常
* @return 测试运行时
*/
private AgentScopeReActRuntime runtimeWithError(Throwable error) {
AgentScopeModelFactory modelFactory = new AgentScopeModelFactory() {
@Override
public Model create(AgentModelSpec modelSpec,
com.easyagents.agent.runtime.model.AgentGenerationOptions generationOptions) {
return new Model() {
@Override
public Flux<ChatResponse> stream(List<Msg> messages,
List<ToolSchema> toolSchemas,
GenerateOptions options) {
return Flux.error(error);
}
@Override
public String getModelName() {
return modelSpec == null ? "fake-model" : modelSpec.getModelName();
}
};
}
};
return new AgentScopeReActRuntime(modelFactory, new AgentScopeToolAdapter(),
new AgentScopeKnowledgeAdapter(), new AgentScopeMemoryAdapter(), new AgentScopeSkillAdapter(),
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 +1943,44 @@ public class AgentScopeStatefulRuntimeTest {
return request; 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 static class ScriptedModel implements Model {
private final String modelName; private final String modelName;
@@ -1483,6 +2014,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

@@ -0,0 +1,107 @@
package com.easyagents.agent.runtime.agentscope;
import com.easyagents.agent.runtime.AgentDefinition;
import com.easyagents.agent.runtime.AgentRuntimeExecutionContext;
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
import com.easyagents.agent.runtime.tool.AgentToolResult;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock;
import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.ToolCallParam;
import org.junit.Assert;
import org.junit.Test;
import java.lang.reflect.Proxy;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
/**
* {@link AgentScopeToolAdapter} 回归测试。
*/
public class AgentScopeToolAdapterTest {
/**
* 验证 Reactor 工作线程执行工具时使用调用器所属类加载器,并在结束后恢复原上下文。
*/
@Test
public void shouldUseInvokerClassLoaderAndRestoreWorkerContext() {
ClassLoader parentClassLoader = AgentToolInvoker.class.getClassLoader();
ClassLoader invocationClassLoader = new ClassLoader(parentClassLoader) {
};
AtomicReference<ClassLoader> observedClassLoader = new AtomicReference<>();
AgentToolInvoker invoker = (AgentToolInvoker) Proxy.newProxyInstance(
invocationClassLoader,
new Class<?>[]{AgentToolInvoker.class},
(proxy, method, arguments) -> {
observedClassLoader.set(Thread.currentThread().getContextClassLoader());
return AgentToolResult.success("ok");
});
AgentTool tool = new AgentScopeToolAdapter().adapt(toolSpec(), invoker, executionContext());
Thread currentThread = Thread.currentThread();
ClassLoader originalClassLoader = currentThread.getContextClassLoader();
ClassLoader workerClassLoader = new ClassLoader(originalClassLoader) {
};
try {
currentThread.setContextClassLoader(workerClassLoader);
ToolResultBlock result = tool.callAsync(toolCall()).block();
Assert.assertNotNull(result);
Assert.assertSame(invocationClassLoader, observedClassLoader.get());
Assert.assertSame(workerClassLoader, currentThread.getContextClassLoader());
} finally {
currentThread.setContextClassLoader(originalClassLoader);
}
}
/**
* 创建测试工具声明。
*
* @return 工具声明
*/
private AgentToolSpec toolSpec() {
AgentToolSpec toolSpec = new AgentToolSpec();
toolSpec.setName("test_tool");
toolSpec.setDescription("Test tool");
toolSpec.setParametersSchema(Map.of(
"type", "object",
"properties", Map.of(),
"additionalProperties", false));
return toolSpec;
}
/**
* 创建测试运行上下文。
*
* @return 运行上下文
*/
private AgentRuntimeExecutionContext executionContext() {
AgentDefinition definition = new AgentDefinition();
definition.setAgentId("agent-1");
AgentRuntimeExecutionContext context = new AgentRuntimeExecutionContext();
context.setAgentDefinition(definition);
context.setRequestId("request-1");
context.setTraceId("trace-1");
context.setSessionId("session-1");
return context;
}
/**
* 创建测试工具调用参数。
*
* @return 工具调用参数
*/
private ToolCallParam toolCall() {
ToolUseBlock toolUseBlock = ToolUseBlock.builder()
.id("call-1")
.name("test_tool")
.input(Map.of())
.content("{}")
.build();
return ToolCallParam.builder()
.toolUseBlock(toolUseBlock)
.input(Map.of())
.build();
}
}

View File

@@ -158,6 +158,138 @@ public class AgentToolApprovalCoordinatorTest {
coordinator, "call-1", "search", Map.of("q", "easyflow")); 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 不能被重新绑定到不同工具内容。 * 验证同一 toolCallId 不能被重新绑定到不同工具内容。
*/ */

View File

@@ -72,6 +72,32 @@ public class HeuristicKnowledgeCitationMatcherTest {
Assert.assertTrue(references.isEmpty()); Assert.assertTrue(references.isEmpty());
} }
/**
* 长篇多主题汇总回答应按独立条目匹配引用,避免整篇答案稀释局部证据。
*/
@Test
public void shouldMatchReferencesFromLongMultiTopicAnswer() {
AgentKnowledgeReference checkIn = reference("faq-check-in",
"问题:最早入住酒店时间说明 答案最早入住时间为入住日当天下午14:00提前到店按房间状况安排。");
AgentKnowledgeReference luggage = reference("faq-luggage",
"问题:酒店寄存行李服务说明 答案离店客人通常可免费寄存2天第3天起收费。");
AgentKnowledgeReference unrelated = reference("faq-unrelated",
"问题:会员卡如何补办 答案:请携带本人证件到指定服务网点申请补办。");
String answer = "根据知识库整理,主要主题如下:\n"
+ "一、预订与支付官方渠道包括APP、微信小程序和客服电话。\n"
+ "二、入住与退房最早入住时间为下午14:00提前到店按房态安排。\n"
+ "三、酒店服务离店后行李通常可免费寄存2天第3天起收费。\n"
+ "四、会员商城:可使用彩虹如愿豆兑换商品。";
List<AgentKnowledgeReference> references = matcher.match(
answer, List.of(unrelated, checkIn, luggage));
Assert.assertEquals(2, references.size());
Assert.assertTrue(references.stream().anyMatch(reference -> "faq-check-in".equals(reference.getDocumentId())));
Assert.assertTrue(references.stream().anyMatch(reference -> "faq-luggage".equals(reference.getDocumentId())));
}
/** /**
* 空答案或空候选不应返回引用。 * 空答案或空候选不应返回引用。
*/ */

View File

@@ -0,0 +1,168 @@
package com.easyagents.agent.runtime.mcp;
import com.easyagents.agent.runtime.AgentRuntimeException;
import io.modelcontextprotocol.spec.McpSchema;
import org.junit.Assert;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 测试 MCP Tool 冻结清单的稳定化与服务端预算。
*/
public class McpToolManifestTest {
/**
* 验证远端返回重复原始 Tool 名称时立即拒绝。
*/
@Test
public void shouldRejectDuplicateRawToolNames() {
expectManifestFailure(
() -> McpToolManifest.fromTools(List.of(tool("search", "first", smallSchema()),
tool("search", "second", smallSchema()))),
"Duplicate");
}
/**
* 验证 Tool 名称超出字符预算时拒绝。
*/
@Test
public void shouldRejectOverlongToolName() {
String name = "n".repeat(McpToolManifest.MAX_TOOL_NAME_LENGTH + 1);
expectManifestFailure(() -> McpToolManifest.fromTools(List.of(
tool(name, "description", smallSchema()))), "name");
}
/**
* 验证 Tool 描述超出字符预算时拒绝。
*/
@Test
public void shouldRejectOverlongToolDescription() {
String description = "d".repeat(McpToolManifest.MAX_TOOL_DESCRIPTION_LENGTH + 1);
expectManifestFailure(() -> McpToolManifest.fromTools(List.of(
tool("search", description, smallSchema()))), "description");
}
/**
* 验证单个输入或输出 Schema 超出 UTF-8 预算时拒绝。
*/
@Test
public void shouldRejectOversizedSingleSchema() {
McpSchema.JsonSchema oversized = schemaWithDescription(
"x".repeat(McpToolManifest.MAX_SCHEMA_UTF8_BYTES));
expectManifestFailure(() -> McpToolManifest.fromTools(List.of(
tool("search", "description", oversized))), "schema");
}
/**
* 验证各 Schema 合法但规范化 Manifest 聚合超过预算时拒绝。
*/
@Test
public void shouldRejectOversizedAggregateManifest() {
McpSchema.JsonSchema schema = schemaWithDescription("x".repeat(220_000));
List<McpSchema.Tool> tools = new ArrayList<>();
for (int index = 0; index < 10; index++) {
tools.add(new McpSchema.Tool("tool_" + index, "tool_" + index, "description",
schema, null, null, null));
}
expectManifestFailure(() -> McpToolManifest.fromTools(tools), "manifest");
}
/**
* 验证哈希入口同样拒绝反序列化后的重复名称,避免绕过发布阶段校验。
*/
@Test
public void shouldRejectDuplicateNamesWhenHashingFrozenManifest() {
McpToolManifestEntry first = manifestEntry("search");
McpToolManifestEntry second = manifestEntry("search");
expectManifestFailure(() -> McpToolManifest.hash(List.of(first, second)), "Duplicate");
}
/**
* 验证运行时完全忽略冻结白名单外新增 Tool即使新增 Tool 超出发布清单预算。
*/
@Test
public void shouldIgnoreOversizedRemoteToolOutsideFrozenWhitelist() {
McpSchema.Tool frozenTool = tool("search", "description", smallSchema());
McpSpec spec = new McpSpec();
spec.setName("demo");
spec.setSkillId("skill-1");
spec.setFrozenToolManifest(McpToolManifest.fromTools(List.of(frozenTool)));
spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest()));
McpSchema.Tool extraTool = tool(
"new_remote_tool",
"x".repeat(McpToolManifest.MAX_TOOL_DESCRIPTION_LENGTH + 1),
smallSchema());
McpToolManifest.assertFrozenManifest(spec, List.of(frozenTool, extraTool));
}
/**
* 构造普通 MCP Tool。
*
* @param name Tool 名称
* @param description Tool 描述
* @param schema 输入 Schema
* @return MCP Tool
*/
private McpSchema.Tool tool(String name, String description, McpSchema.JsonSchema schema) {
return new McpSchema.Tool(name, name, description, schema, null, null, null);
}
/**
* 构造小型合法 Schema。
*
* @return 合法 Schema
*/
private McpSchema.JsonSchema smallSchema() {
return schemaWithDescription("query");
}
/**
* 构造带指定属性描述的 Schema。
*
* @param description 属性描述
* @return MCP JSON Schema
*/
private McpSchema.JsonSchema schemaWithDescription(String description) {
return new McpSchema.JsonSchema("object",
Map.of("value", Map.of("type", "string", "description", description)),
List.of("value"), null, null, null);
}
/**
* 构造最小冻结清单项。
*
* @param name Tool 名称
* @return 冻结清单项
*/
private McpToolManifestEntry manifestEntry(String name) {
McpToolManifestEntry entry = new McpToolManifestEntry();
entry.setName(name);
entry.setDescription("description");
entry.setInputSchema(Map.of("type", "object"));
return entry;
}
/**
* 断言清单转换抛出包含指定片段的运行时异常。
*
* @param action 待执行动作
* @param messageFragment 预期错误片段
*/
private void expectManifestFailure(Runnable action, String messageFragment) {
try {
action.run();
Assert.fail("Expected MCP manifest validation failure.");
} catch (AgentRuntimeException expected) {
Assert.assertTrue(expected.getMessage(), expected.getMessage().contains(messageFragment));
}
}
}

View File

@@ -1,6 +1,9 @@
package com.easyagents.agent.runtime.mcp; package com.easyagents.agent.runtime.mcp;
import com.easyagents.agent.runtime.AgentRuntimeException; import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.agentscope.AgentScopeSkillAdapter;
import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec;
import com.easyagents.agent.runtime.skill.AgentSkillSpec;
import com.easyagents.agent.runtime.tool.AgentToolSpec; import com.easyagents.agent.runtime.tool.AgentToolSpec;
import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.tool.Toolkit; import io.agentscope.core.tool.Toolkit;
@@ -161,6 +164,130 @@ public class McpToolkitAdapterTest {
} }
} }
/**
* 验证 Skill MCP 只注册到禁用的 Skill Tool Group加载前不向模型暴露。
*/
@Test
public void shouldRegisterSkillMcpAsInactiveSkillToolGroup() {
List<McpSchema.Tool> frozenTools = List.of(tool("search"));
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo",
List.of(tool("search"), tool("new_remote_tool")));
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpSpec spec = stdioSpec();
spec.setSkillId("skill-1");
spec.setToolAliases(Map.of("search", "skill_1_mcp_search"));
spec.setFrozenToolManifest(McpToolManifest.fromTools(frozenTools));
spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest()));
Toolkit toolkit = new Toolkit();
McpRegistration registration = adapter.register(List.of(spec), toolkit);
Assert.assertNull(toolkit.getTool("skill_1_mcp_search"));
Assert.assertEquals(1, registration.getSkillRegistrations().size());
Assert.assertEquals(List.of("skill_1_mcp_search"),
registration.getSkillRegistrations().get(0).getEnableTools());
AgentSkillSpec skill = new AgentSkillSpec();
skill.setSkillId("skill-1");
skill.setName("Search Skill");
skill.setDescription("Search through MCP.");
skill.setSkillContent("Load this skill before searching.");
AgentSkillBoxSpec skillBoxSpec = new AgentSkillBoxSpec();
skillBoxSpec.setSkills(List.of(skill));
new AgentScopeSkillAdapter().createSkillBox(skillBoxSpec, toolkit, Map.of(),
registration.getSkillRegistrations());
Assert.assertNotNull(toolkit.getTool("skill_1_mcp_search"));
Assert.assertFalse(toolkit.getActiveGroups().contains("skill-1_skill_tools"));
Assert.assertTrue(toolkit.getToolSchemas().stream()
.noneMatch(schema -> "skill_1_mcp_search".equals(schema.getName())));
Assert.assertNull(toolkit.getTool("skill_1_mcp_new_remote_tool"));
Assert.assertEquals(1, client.remoteListCalls.get());
}
/**
* 验证 Skill MCP 会在读取 Tool 清单前完成异步初始化。
*/
@Test
public void shouldInitializeSkillMcpBeforeListingTools() {
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", List.of(tool("search")));
client.deferInitialization = true;
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpSpec spec = stdioSpec();
spec.setSkillId("skill-1");
spec.setFrozenToolManifest(McpToolManifest.fromTools(List.of(tool("search"))));
spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest()));
McpRegistration registration = adapter.register(List.of(spec), new Toolkit());
Assert.assertTrue(client.isInitialized());
Assert.assertEquals(1, client.remoteListCalls.get());
Assert.assertEquals(1, registration.getSkillRegistrations().size());
}
/**
* 验证冻结 Tool 缺失时拒绝注册并关闭 client。
*/
@Test
public void shouldRejectMissingFrozenSkillMcpToolAndCloseClient() {
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", List.of(tool("other")));
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpSpec spec = stdioSpec();
spec.setSkillId("skill-1");
spec.setFrozenToolManifest(McpToolManifest.fromTools(List.of(tool("search"))));
spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest()));
try {
adapter.register(List.of(spec), new Toolkit());
Assert.fail("Expected frozen MCP tool validation failure.");
} catch (AgentRuntimeException expected) {
Assert.assertTrue(expected.getMessage().contains("missing"));
Assert.assertTrue(client.closed.get());
}
}
/**
* 验证冻结 Tool Schema 漂移时拒绝注册。
*/
@Test(expected = AgentRuntimeException.class)
public void shouldRejectChangedFrozenSkillMcpSchema() {
McpSchema.Tool expectedTool = tool("search");
McpSchema.JsonSchema changedSchema = new McpSchema.JsonSchema("object",
Map.of("keyword", Map.of("type", "string")), List.of("keyword"), null, null, null);
McpSchema.Tool actualTool = new McpSchema.Tool("search", "search", "search description",
changedSchema, null, null, null);
McpToolkitAdapter adapter = new McpToolkitAdapter(
new FakeMcpClientFactory(new FakeMcpClientWrapper("demo", List.of(actualTool))));
McpSpec spec = stdioSpec();
spec.setSkillId("skill-1");
spec.setFrozenToolManifest(McpToolManifest.fromTools(List.of(expectedTool)));
spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest()));
adapter.register(List.of(spec), new Toolkit());
}
/**
* 验证远端仅调整 Tool 描述时不破坏已发布 Skill 的运行兼容性。
*/
@Test
public void shouldAllowChangedDescriptionWhenFrozenSchemaIsStable() {
McpSchema.Tool expectedTool = tool("search");
McpSchema.Tool actualTool = new McpSchema.Tool(
"search", "search", "updated description",
expectedTool.inputSchema(), expectedTool.outputSchema(), null, null);
McpToolkitAdapter adapter = new McpToolkitAdapter(
new FakeMcpClientFactory(new FakeMcpClientWrapper("demo", List.of(actualTool))));
McpSpec spec = stdioSpec();
spec.setSkillId("skill-1");
spec.setFrozenToolManifest(McpToolManifest.fromTools(List.of(expectedTool)));
spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest()));
McpRegistration registration = adapter.register(List.of(spec), new Toolkit());
Assert.assertEquals(1, registration.getSkillRegistrations().size());
Assert.assertEquals("search description", registration.getToolSpecs().get(0).getDescription());
}
private McpSpec stdioSpec() { private McpSpec stdioSpec() {
McpSpec spec = new McpSpec(); McpSpec spec = new McpSpec();
spec.setName("demo"); spec.setName("demo");
@@ -197,7 +324,10 @@ public class McpToolkitAdapterTest {
private final List<McpSchema.Tool> tools; private final List<McpSchema.Tool> tools;
private final AtomicBoolean closed = new AtomicBoolean(false); private final AtomicBoolean closed = new AtomicBoolean(false);
private final AtomicReference<String> lastCalledToolName = new AtomicReference<>(); private final AtomicReference<String> lastCalledToolName = new AtomicReference<>();
private final java.util.concurrent.atomic.AtomicInteger remoteListCalls =
new java.util.concurrent.atomic.AtomicInteger();
private boolean failOnListTools; private boolean failOnListTools;
private boolean deferInitialization;
private FakeMcpClientWrapper(String name, List<McpSchema.Tool> tools) { private FakeMcpClientWrapper(String name, List<McpSchema.Tool> tools) {
super(name); super(name);
@@ -206,12 +336,19 @@ public class McpToolkitAdapterTest {
@Override @Override
public Mono<Void> initialize() { public Mono<Void> initialize() {
if (deferInitialization) {
return Mono.fromRunnable(() -> initialized = true);
}
initialized = true; initialized = true;
return Mono.empty(); return Mono.empty();
} }
@Override @Override
public Mono<List<McpSchema.Tool>> listTools() { public Mono<List<McpSchema.Tool>> listTools() {
if (!initialized) {
return Mono.error(new IllegalStateException("client is not initialized"));
}
remoteListCalls.incrementAndGet();
if (failOnListTools) { if (failOnListTools) {
return Mono.error(new IllegalStateException("list tools failed")); return Mono.error(new IllegalStateException("list tools failed"));
} }

View File

@@ -0,0 +1,149 @@
package com.easyagents.agent.runtime.prompt;
import com.easyagents.agent.runtime.AgentDefinition;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import org.junit.Assert;
import org.junit.Test;
import java.util.List;
/**
* 测试运行时系统提示词组合器。
*/
public class SystemPromptComposerTest {
@Test
public void shouldKeepUserPromptWhenNoRuntimeProtocolIsRequired() {
AgentDefinition definition = definition(" user prompt ");
Assert.assertEquals(" user prompt ", SystemPromptComposer.compose(definition));
}
@Test
public void shouldReturnKnowledgeProtocolWhenUserPromptIsBlank() {
AgentDefinition definition = definition(" ");
definition.setKnowledgeSpecs(List.of(knowledge("faq")));
String prompt = SystemPromptComposer.compose(definition);
Assert.assertTrue(prompt.startsWith("Knowledge tool protocol:"));
Assert.assertFalse(prompt.startsWith("\n"));
}
@Test
public void shouldAppendKnowledgeProtocolAfterUserPrompt() {
AgentDefinition definition = definition("user prompt");
definition.setKnowledgeSpecs(List.of(knowledge("faq")));
String prompt = SystemPromptComposer.compose(definition);
Assert.assertTrue(prompt.startsWith("user prompt\n\nKnowledge tool protocol:"));
Assert.assertTrue(prompt.contains("prefer making one retrieval call"));
Assert.assertTrue(prompt.contains("reformulate the query and retry once"));
}
@Test
public void shouldAppendKnowledgeProtocolOnlyOnceForMultipleKnowledgeTools() {
AgentDefinition definition = definition("user prompt");
definition.setKnowledgeSpecs(List.of(knowledge("faq"), knowledge("policy")));
String prompt = SystemPromptComposer.compose(definition);
Assert.assertEquals(1, occurrences(prompt, "Knowledge tool protocol:"));
}
@Test
public void shouldSkipKnowledgeProtocolWhenToolCallingIsDisabled() {
AgentDefinition definition = definition("user prompt");
definition.setKnowledgeSpecs(List.of(knowledge("faq")));
definition.getExecutionOptions().setToolCallingEnabled(false);
Assert.assertEquals("user prompt", SystemPromptComposer.compose(definition));
}
@Test
public void shouldPreserveAsyncToolProtocolBehavior() {
AgentDefinition definition = definition(null);
definition.setToolSpecs(List.of(asyncTool()));
String prompt = SystemPromptComposer.compose(definition);
Assert.assertTrue(prompt.startsWith("Async tool protocol:"));
Assert.assertTrue(prompt.contains("These are internal execution phases."));
Assert.assertTrue(prompt.contains("call its submit sub-tool first with the user-provided arguments by default"));
Assert.assertTrue(prompt.contains("immediately call observe"));
}
@Test
public void shouldComposeUserKnowledgeAndAsyncProtocolsInStableOrder() {
AgentDefinition definition = definition("user prompt");
definition.setKnowledgeSpecs(List.of(knowledge("faq")));
definition.setToolSpecs(List.of(asyncTool()));
String prompt = SystemPromptComposer.compose(definition);
int userIndex = prompt.indexOf("user prompt");
int knowledgeIndex = prompt.indexOf("Knowledge tool protocol:");
int asyncIndex = prompt.indexOf("Async tool protocol:");
Assert.assertTrue(userIndex >= 0);
Assert.assertTrue(knowledgeIndex > userIndex);
Assert.assertTrue(asyncIndex > knowledgeIndex);
Assert.assertEquals(1, occurrences(prompt, "Knowledge tool protocol:"));
Assert.assertEquals(1, occurrences(prompt, "Async tool protocol:"));
}
/**
* 创建测试智能体定义。
*
* @param systemPrompt 用户系统提示词
* @return 智能体定义
*/
private AgentDefinition definition(String systemPrompt) {
AgentDefinition definition = new AgentDefinition();
definition.setSystemPrompt(systemPrompt);
return definition;
}
/**
* 创建测试知识库定义。
*
* @param runtimeName 知识库运行名
* @return 知识库定义
*/
private AgentKnowledgeSpec knowledge(String runtimeName) {
AgentKnowledgeSpec spec = new AgentKnowledgeSpec();
spec.setKnowledgeId("knowledge-" + runtimeName);
spec.setRuntimeName(runtimeName);
return spec;
}
/**
* 创建测试异步工具定义。
*
* @return 异步工具定义
*/
private AgentToolSpec asyncTool() {
AgentToolSpec toolSpec = new AgentToolSpec();
toolSpec.setName("demo_submit");
toolSpec.getMetadata().put("asyncTool", true);
return toolSpec;
}
/**
* 统计文本片段出现次数。
*
* @param value 待检查文本
* @param fragment 目标片段
* @return 出现次数
*/
private int occurrences(String value, String fragment) {
int count = 0;
int offset = 0;
while ((offset = value.indexOf(fragment, offset)) >= 0) {
count++;
offset += fragment.length();
}
return count;
}
}

View File

@@ -8,6 +8,9 @@ import org.junit.Test;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.nio.file.Files;
import java.nio.file.Path;
import java.io.IOException;
/** /**
* 测试 Agent 操作类工具适配器。 * 测试 Agent 操作类工具适配器。
@@ -30,7 +33,7 @@ public class AgentOperateToolAdapterTest {
} }
@Test @Test
public void shouldRegisterWriteFileToolsWithDefaultHitlEnabled() { public void shouldRegisterWriteFileToolsWithDefaultHitlDisabled() {
Toolkit toolkit = new Toolkit(); Toolkit toolkit = new Toolkit();
AgentOperateToolSpec spec = spec(AgentOperateToolType.WRITE_FILE); 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.WRITE_TEXT_FILE_TOOL));
Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.INSERT_TEXT_FILE_TOOL)); Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.INSERT_TEXT_FILE_TOOL));
Assert.assertEquals(2, toolSpecs.size()); 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 @Test
public void shouldRegisterShellToolWithEmptyWhitelist() { public void shouldRegisterShellWithForcedRmApprovalMetadata() {
Toolkit toolkit = new Toolkit(); Toolkit toolkit = new Toolkit();
AgentOperateToolSpec spec = spec(AgentOperateToolType.SHELL); AgentOperateToolSpec spec = spec(AgentOperateToolType.SHELL);
spec.setShellAllowedCommands(Set.of());
List<AgentToolSpec> toolSpecs = adapter.register(List.of(spec), toolkit); List<AgentToolSpec> toolSpecs = adapter.register(List.of(spec), toolkit);
Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL)); Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL));
Assert.assertEquals(1, toolSpecs.size()); Assert.assertEquals(1, toolSpecs.size());
Assert.assertTrue(toolSpecs.get(0).isApprovalRequired()); 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 @Test
@@ -95,7 +135,12 @@ public class AgentOperateToolAdapterTest {
private AgentOperateToolSpec spec(AgentOperateToolType type) { private AgentOperateToolSpec spec(AgentOperateToolType type) {
AgentOperateToolSpec spec = new AgentOperateToolSpec(); AgentOperateToolSpec spec = new AgentOperateToolSpec();
spec.setType(type); 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; 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> <groupId>com.easyagents</groupId>
<artifactId>easy-agents-agent-runtime</artifactId> <artifactId>easy-agents-agent-runtime</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-agui</artifactId>
</dependency>
<!--agent runtime end--> <!--agent runtime end-->
<!--search engines start--> <!--search engines start-->

View File

@@ -27,6 +27,9 @@ public class ChatConfig extends BaseModelConfig {
protected Boolean supportToolMessage; protected Boolean supportToolMessage;
protected Boolean supportThinking; protected Boolean supportThinking;
/** OpenAI-compatible 消息 content 的序列化格式。 */
protected ChatMessageContentFormat messageContentFormat = ChatMessageContentFormat.STANDARD;
// 在调用工具的时候,是否需要推理结果作为 reasoning_content 传给大模型, 比如 Deepseek // 在调用工具的时候,是否需要推理结果作为 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 // 参考文档: 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; protected Boolean needReasoningContentForToolMessage;
@@ -135,6 +138,35 @@ public class ChatConfig extends BaseModelConfig {
return supportThinking == null || supportThinking; 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() { public Boolean getNeedReasoningContentForToolMessage() {
return needReasoningContentForToolMessage; 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) { } else if (message instanceof ToolMessage) {
buildToolMessageObject(objectMap, (ToolMessage) message, config); buildToolMessageObject(objectMap, (ToolMessage) message, config);
} }
normalizeMessageContent(objectMap, config);
messageList.add(objectMap); messageList.add(objectMap);
}); });
return messageList; 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) { protected void buildToolMessageObject(Map<String, Object> objectMap, ToolMessage message, ChatConfig config) {
if (config.isSupportToolMessage()) { if (config.isSupportToolMessage()) {
objectMap.put("role", "tool"); objectMap.put("role", "tool");
@@ -289,4 +317,3 @@ public class OpenAIChatMessageSerializer implements ChatMessageSerializer {
} }
} }
} }

View File

@@ -43,6 +43,11 @@ public class StoreOptions extends Metadata {
public void setEmbeddingOptions(EmbeddingOptions embeddingOptions) { public void setEmbeddingOptions(EmbeddingOptions embeddingOptions) {
throw new IllegalStateException("Can not set embeddingOptions to the default instance."); throw new IllegalStateException("Can not set embeddingOptions to the default instance.");
} }
@Override
public void setTimeoutMillis(Long timeoutMillis) {
throw new IllegalStateException("Can not set timeoutMillis to the default instance.");
}
}; };
/** /**
@@ -65,6 +70,11 @@ public class StoreOptions extends Metadata {
*/ */
private EmbeddingOptions embeddingOptions = EmbeddingOptions.DEFAULT; private EmbeddingOptions embeddingOptions = EmbeddingOptions.DEFAULT;
/**
* Optional upper bound for one store operation.
*/
private Long timeoutMillis;
public String getCollectionName() { public String getCollectionName() {
return collectionName; return collectionName;
@@ -111,6 +121,17 @@ public class StoreOptions extends Metadata {
this.embeddingOptions = embeddingOptions; this.embeddingOptions = embeddingOptions;
} }
public Long getTimeoutMillis() {
return timeoutMillis;
}
public void setTimeoutMillis(Long timeoutMillis) {
if (timeoutMillis != null && timeoutMillis <= 0L) {
throw new IllegalArgumentException("timeoutMillis must be greater than zero");
}
this.timeoutMillis = timeoutMillis;
}
public static StoreOptions ofCollectionName(String collectionName) { public static StoreOptions ofCollectionName(String collectionName) {
StoreOptions storeOptions = new StoreOptions(); StoreOptions storeOptions = new StoreOptions();

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*/
package com.easyagents.core.store;
/**
* Indicates that a store operation exhausted its caller-provided time budget.
*/
public class StoreTimeoutException extends RuntimeException {
public StoreTimeoutException(String message) {
super(message);
}
public StoreTimeoutException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -1,7 +1,12 @@
package com.easyagents.core.test.model.client; 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.message.UserMessage;
import com.easyagents.core.model.chat.ChatConfig; import com.easyagents.core.model.chat.ChatConfig;
import com.easyagents.core.model.chat.ChatMessageContentFormat;
import com.easyagents.core.model.client.OpenAIChatMessageSerializer; import com.easyagents.core.model.client.OpenAIChatMessageSerializer;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Test; import org.junit.Test;
@@ -14,6 +19,50 @@ import java.util.Map;
*/ */
public class OpenAIChatMessageSerializerTest { 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 字段。 * 验证 Data URI 会写入标准的 image_url.url 字段。
*/ */
@@ -33,4 +82,71 @@ public class OpenAIChatMessageSerializerTest {
Assert.assertEquals("image_url", imageContent.get("type")); Assert.assertEquals("image_url", imageContent.get("type"));
Assert.assertEquals(dataUri, imageUrl.get("url")); 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

@@ -2,6 +2,7 @@ package com.easyagents.document.core.async;
import com.easyagents.core.util.StringUtil; import com.easyagents.core.util.StringUtil;
import com.easyagents.document.core.exception.DocumentParseException; import com.easyagents.document.core.exception.DocumentParseException;
import com.easyagents.document.core.exception.DocumentAsyncTaskNotFoundException;
import com.easyagents.document.core.entity.ParseResponse; import com.easyagents.document.core.entity.ParseResponse;
import com.easyagents.document.core.entity.ParseTaskInfo; import com.easyagents.document.core.entity.ParseTaskInfo;
import com.easyagents.document.core.entity.ParseTaskStatus; import com.easyagents.document.core.entity.ParseTaskStatus;
@@ -135,7 +136,7 @@ public class DocumentAsyncTaskManager {
} }
DocumentAsyncTaskRecord record = repository.find(taskId); DocumentAsyncTaskRecord record = repository.find(taskId);
if (record == null) { if (record == null) {
throw new DocumentParseException("Document async task not found: " + taskId); throw new DocumentAsyncTaskNotFoundException(taskId);
} }
return record; return record;
} }

View File

@@ -0,0 +1,29 @@
package com.easyagents.document.core.exception;
/**
* 进程内异步文档任务不存在。
*
* <p>本地 Office 解析任务允许使用内存仓库;进程重启后,调用方可以
* 通过该异常识别执行实例已经丢失,并从持久化业务任务重新提交。</p>
*
* @author Codex
* @since 2026-09-02
*/
public class DocumentAsyncTaskNotFoundException extends DocumentParseException {
private final String taskId;
public DocumentAsyncTaskNotFoundException(String taskId) {
super("Document async task not found: " + taskId);
this.taskId = taskId;
}
/**
* 获取已丢失的任务 ID。
*
* @return 任务 ID
*/
public String getTaskId() {
return taskId;
}
}

View File

@@ -4,6 +4,7 @@ import com.easyagents.document.core.entity.ParseResponse;
import com.easyagents.document.core.entity.ParseResult; import com.easyagents.document.core.entity.ParseResult;
import com.easyagents.document.core.entity.ParseTaskInfo; import com.easyagents.document.core.entity.ParseTaskInfo;
import com.easyagents.document.core.entity.ParseTaskStatus; import com.easyagents.document.core.entity.ParseTaskStatus;
import com.easyagents.document.core.exception.DocumentAsyncTaskNotFoundException;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Test; import org.junit.Test;
@@ -18,6 +19,21 @@ import java.util.concurrent.Executor;
*/ */
public class DocumentAsyncTaskManagerTest { public class DocumentAsyncTaskManagerTest {
@Test
public void shouldExposeMissingInMemoryTaskAsRecoverableSignal() {
DocumentAsyncTaskManager manager = new DocumentAsyncTaskManager(
new InMemoryDocumentAsyncTaskRepository(),
Runnable::run
);
try {
manager.queryTaskInfo("lost-task");
Assert.fail("expected DocumentAsyncTaskNotFoundException");
} catch (DocumentAsyncTaskNotFoundException error) {
Assert.assertEquals("lost-task", error.getTaskId());
}
}
@Test @Test
public void shouldTrackTaskLifecycleAndResult() { public void shouldTrackTaskLifecycleAndResult() {
Executor directExecutor = new Executor() { Executor directExecutor = new Executor() {

View File

@@ -14,6 +14,7 @@ import com.easyagents.document.core.entity.ParseRequest;
import com.easyagents.document.core.entity.ParseResponse; import com.easyagents.document.core.entity.ParseResponse;
import com.easyagents.document.core.entity.ParseResult; import com.easyagents.document.core.entity.ParseResult;
import com.easyagents.document.core.entity.XlsxParseRequest; import com.easyagents.document.core.entity.XlsxParseRequest;
import com.easyagents.document.core.exception.DocumentParseException;
import com.easyagents.document.core.support.AbstractAsyncDocumentParseService; import com.easyagents.document.core.support.AbstractAsyncDocumentParseService;
import com.easyagents.document.xlsx.XlsxDocumentProvider; import com.easyagents.document.xlsx.XlsxDocumentProvider;
import com.easyagents.document.xlsx.model.XlsxCellArtifact; import com.easyagents.document.xlsx.model.XlsxCellArtifact;
@@ -52,6 +53,10 @@ import java.util.concurrent.Executors;
public class MineruXlsxDocumentParseService extends AbstractAsyncDocumentParseService<XlsxParseRequest> implements XlsxDocumentProvider { public class MineruXlsxDocumentParseService extends AbstractAsyncDocumentParseService<XlsxParseRequest> implements XlsxDocumentProvider {
public static final String PROVIDER_NAME = "mineru"; public static final String PROVIDER_NAME = "mineru";
private static final byte[] OLE2_SIGNATURE = new byte[] {
(byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0,
(byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1
};
private final MineruProperties properties; private final MineruProperties properties;
private final MineruClient client; private final MineruClient client;
@@ -145,6 +150,9 @@ public class MineruXlsxDocumentParseService extends AbstractAsyncDocumentParseSe
@Override @Override
protected ParseResponse doParse(XlsxParseRequest request, DocumentAsyncTaskUpdater updater) { protected ParseResponse doParse(XlsxParseRequest request, DocumentAsyncTaskUpdater updater) {
for (ParseFile file : request.getFiles()) {
validateXlsxContent(file);
}
ParseResponse response = new ParseResponse(); ParseResponse response = new ParseResponse();
List<ParseResult> results = new ArrayList<ParseResult>(); List<ParseResult> results = new ArrayList<ParseResult>();
String backend = null; String backend = null;
@@ -211,6 +219,50 @@ public class MineruXlsxDocumentParseService extends AbstractAsyncDocumentParseSe
return aggregate; return aggregate;
} }
/**
* 在 POI 打开工作簿前校验 XLSX 容器签名,避免向用户暴露底层格式异常。
*
* @param file 待解析文件
*/
private void validateXlsxContent(ParseFile file) {
byte[] content = file == null ? null : file.getContent();
if (hasZipSignature(content)) {
return;
}
String fileName = file == null || !StringUtil.hasText(file.getFileName())
? "当前文件"
: "文件“" + file.getFileName() + "";
String reason = startsWith(content, OLE2_SIGNATURE)
? "可能是旧版 XLS 或已加密文件"
: "文件内容与 .xlsx 扩展名不一致或文件已损坏";
throw new DocumentParseException(
fileName + "不是标准 XLSX" + reason
+ "。请解除保护后用 Excel/WPS 另存为 XLSX修改文件后缀无效"
);
}
private boolean hasZipSignature(byte[] content) {
return content != null
&& content.length >= 4
&& content[0] == 'P'
&& content[1] == 'K'
&& ((content[2] == 3 && content[3] == 4)
|| (content[2] == 5 && content[3] == 6)
|| (content[2] == 7 && content[3] == 8));
}
private boolean startsWith(byte[] content, byte[] signature) {
if (content == null || content.length < signature.length) {
return false;
}
for (int index = 0; index < signature.length; index++) {
if (content[index] != signature[index]) {
return false;
}
}
return true;
}
private SheetExtraction extractSheet(XSSFSheet sheet, private SheetExtraction extractSheet(XSSFSheet sheet,
int sheetIndex, int sheetIndex,
DataFormatter formatter, DataFormatter formatter,

View File

@@ -16,6 +16,7 @@ import com.easyagents.document.core.entity.ParseTaskStatus;
import com.easyagents.document.core.entity.XlsxParseRequest; import com.easyagents.document.core.entity.XlsxParseRequest;
import com.easyagents.document.core.exception.DocumentParseException; import com.easyagents.document.core.exception.DocumentParseException;
import com.easyagents.document.xlsx.model.XlsxParseArtifact; import com.easyagents.document.xlsx.model.XlsxParseArtifact;
import org.apache.poi.hssf.usermodel.HSSFWorkbook;
import org.apache.poi.ss.usermodel.ClientAnchor; import org.apache.poi.ss.usermodel.ClientAnchor;
import org.apache.poi.xssf.usermodel.XSSFDrawing; import org.apache.poi.xssf.usermodel.XSSFDrawing;
import org.apache.poi.xssf.usermodel.XSSFSheet; import org.apache.poi.xssf.usermodel.XSSFSheet;
@@ -119,6 +120,31 @@ public class MineruXlsxDocumentParseServiceTest {
Assert.assertEquals("image/jpeg", result.getImages().get(0).getMimeType()); Assert.assertEquals("image/jpeg", result.getImages().get(0).getMimeType());
} }
@Test
public void shouldRejectLegacyXlsContentWithActionableMessage() throws Exception {
RecordingClient client = new RecordingClient(defaultProperties());
MineruMapper mapper = new MineruMapper(defaultProperties());
MineruXlsxDocumentParseService service = new MineruXlsxDocumentParseService(
defaultProperties(),
client,
mapper,
new DocumentAsyncTaskManager(new InMemoryDocumentAsyncTaskRepository(), directExecutor())
);
XlsxParseRequest request = new XlsxParseRequest();
request.addFile(ParseFile.of("legacy.xlsx", buildLegacyWorkbookBytes()));
DocumentParseException error = Assert.assertThrows(
DocumentParseException.class,
() -> service.parse(request)
);
Assert.assertEquals(
"文件“legacy.xlsx”不是标准 XLSX可能是旧版 XLS 或已加密文件。"
+ "请解除保护后用 Excel/WPS 另存为 XLSX修改文件后缀无效",
error.getMessage()
);
}
@Test @Test
public void shouldAppendImageReferenceForImageOnlySheet() throws Exception { public void shouldAppendImageReferenceForImageOnlySheet() throws Exception {
RecordingClient client = new RecordingClient(defaultProperties()); RecordingClient client = new RecordingClient(defaultProperties());
@@ -255,6 +281,15 @@ public class MineruXlsxDocumentParseServiceTest {
return writeWorkbook(workbook); return writeWorkbook(workbook);
} }
private byte[] buildLegacyWorkbookBytes() throws Exception {
try (HSSFWorkbook workbook = new HSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
workbook.createSheet("Sheet1").createRow(0).createCell(0).setCellValue("旧版表格");
workbook.write(outputStream);
return outputStream.toByteArray();
}
}
private void addPicture(XSSFWorkbook workbook, private void addPicture(XSSFWorkbook workbook,
XSSFSheet sheet, XSSFSheet sheet,
int rowIndex, int rowIndex,

View File

@@ -634,7 +634,8 @@ public class Chain {
NodeStateField NodeStateField
.EXECUTION_ATTEMPT_KEY); .EXECUTION_ATTEMPT_KEY);
} }
if (node.getCondition() == null) { if (node.getJoinMode() == NodeJoinMode.ANY
&& node.getCondition() == null) {
s.recordTrigger(triggerEdgeId); s.recordTrigger(triggerEdgeId);
fields.add(NodeStateField.TRIGGER_COUNT); fields.add(NodeStateField.TRIGGER_COUNT);
fields.add(NodeStateField.TRIGGER_EDGE_IDS); fields.add(NodeStateField.TRIGGER_EDGE_IDS);
@@ -795,7 +796,7 @@ public class Chain {
private boolean shouldSkipNode(Node node, String edgeId) { private boolean shouldSkipNode(Node node, String edgeId) {
NodeCondition condition = node.getCondition(); NodeCondition condition = node.getCondition();
if (condition == null) { if (node.getJoinMode() == NodeJoinMode.ANY && condition == null) {
return false; return false;
} }
return executeWithLock(stateInstanceId, 10, TimeUnit.SECONDS, () -> { return executeWithLock(stateInstanceId, 10, TimeUnit.SECONDS, () -> {
@@ -805,7 +806,11 @@ public class Chain {
}); });
Map<String, Object> prevResult = Collections.emptyMap(); Map<String, Object> prevResult = Collections.emptyMap();
boolean shouldSkipNode = !condition.check(this, newState, prevResult); boolean joinPending = node.getJoinMode() == NodeJoinMode.ALL
&& !newState.isUpstreamFullyExecuted();
boolean shouldSkipNode = joinPending
|| (condition != null
&& !condition.check(this, newState, prevResult));
if (shouldSkipNode) { if (shouldSkipNode) {
updateStateSafely(state -> { updateStateSafely(state -> {
return state.addUncheckedNodeId(node.id) return state.addUncheckedNodeId(node.id)
@@ -1732,36 +1737,166 @@ public class Chain {
} }
/**
* 仅在工作流处于暂停状态时恢复执行。
*
* <p>状态判断与恢复动作在同一个实例锁内完成,避免并发恢复请求重复注入变量
* 或把终态实例重新改为运行中。</p>
*
* @param variables 恢复时注入的变量
* @return 本次是否完成了暂停态到运行态的转换
*/
public boolean resumeIfSuspended(Map<String, Object> variables) {
return executeWithLock(
stateInstanceId,
10L,
TimeUnit.SECONDS,
() -> resumeSuspended(variables));
}
private void validateResumeVariables(
ChainState state, Map<String, Object> variables) {
List<Parameter> parameters = state.getSuspendForParameters();
if (parameters == null || parameters.isEmpty()) {
return;
}
Map<String, Object> submitted = variables == null
? Collections.emptyMap()
: variables;
Set<String> expectedKeys = parameters.stream()
.map(Parameter::getName)
.filter(Objects::nonNull)
.collect(java.util.stream.Collectors.toCollection(
LinkedHashSet::new));
Set<String> extraKeys = new LinkedHashSet<>(submitted.keySet());
extraKeys.removeAll(expectedKeys);
if (!extraKeys.isEmpty()) {
throw new ChainResumeException(
"确认参数包含未声明字段");
}
for (Parameter parameter : parameters) {
String name = parameter.getName();
Object value = submitted.get(name);
String label = StringUtil.getFirstWithText(
parameter.getFormLabel(), name);
if (!submitted.containsKey(name) || isBlankResumeValue(value)) {
throw new ChainResumeException(
"确认参数[" + label + "]不能为空");
}
validateResumeOption(parameter, value, label);
}
}
private void validateResumeOption(
Parameter parameter, Object value, String label) {
List<ParameterOption> options = parameter.getOptions();
if (options == null || options.isEmpty()) {
return;
}
Set<String> allowedValues = options.stream()
.map(ParameterOption::getValue)
.filter(Objects::nonNull)
.collect(java.util.stream.Collectors.toCollection(
LinkedHashSet::new));
if ("checkbox".equals(parameter.getFormType())) {
if (!(value instanceof Collection<?> selected)) {
throw new ChainResumeException(
"确认参数[" + label + "]必须提交字符串数组");
}
Set<String> unique = new LinkedHashSet<>();
for (Object item : selected) {
if (!(item instanceof String selectedValue)
|| !allowedValues.contains(selectedValue)) {
throw new ChainResumeException(
"确认参数[" + label + "]包含未配置选项");
}
if (!unique.add(selectedValue)) {
throw new ChainResumeException(
"确认参数[" + label + "]不能重复选择同一选项");
}
}
return;
}
if (!(value instanceof String selectedValue)) {
throw new ChainResumeException(
"确认参数[" + label + "]必须提交单个字符串值");
}
if (!allowedValues.contains(selectedValue)) {
throw new ChainResumeException(
"确认参数[" + label + "]包含未配置选项");
}
}
private boolean isBlankResumeValue(Object value) {
if (value == null) {
return true;
}
if (value instanceof String text) {
return text.trim().isEmpty();
}
return value instanceof Collection<?> collection
&& collection.isEmpty();
}
/**
* 恢复暂停中的工作流。
*
* @param variables 恢复时注入的变量
*/
public void resume(Map<String, Object> variables) { public void resume(Map<String, Object> variables) {
ChainState newState = updateStateSafely(state -> { resumeIfSuspended(variables);
if (variables != null) { }
state.getMemory().putAll(variables);
return EnumSet.of(ChainStateField.MEMORY); /**
} else { * 在调用方持有实例锁且已确认暂停状态后执行恢复动作。
*
* @param variables 恢复时注入的变量
*/
private boolean resumeSuspended(Map<String, Object> variables) {
AtomicBoolean resumed = new AtomicBoolean(false);
AtomicReference<Set<String>> suspendedNodeIds =
new AtomicReference<>(Collections.emptySet());
updateStateSafely(state -> {
resumed.set(false);
suspendedNodeIds.set(Collections.emptySet());
if (state.getStatus() != ChainStatus.SUSPEND) {
return null; return null;
} }
validateResumeVariables(state, variables);
if (state.getSuspendNodeIds() != null) {
suspendedNodeIds.set(
new LinkedHashSet<>(state.getSuspendNodeIds()));
}
EnumSet<ChainStateField> updatedFields = EnumSet.of(
ChainStateField.STATUS,
ChainStateField.SUSPEND_NODE_IDS,
ChainStateField.SUSPEND_FOR_PARAMETERS);
if (variables != null && !variables.isEmpty()) {
state.getMemory().putAll(variables);
updatedFields.add(ChainStateField.MEMORY);
}
state.setStatus(ChainStatus.RUNNING);
state.setSuspendNodeIds(null);
state.setSuspendForParameters(null);
resumed.set(true);
return updatedFields;
}); });
if (!resumed.get()) {
return false;
}
notifyEvent(new ChainResumeEvent(this, variables)); notifyEvent(new ChainResumeEvent(this, variables));
setStatusAndNotifyEvent(ChainStatus.RUNNING); notifyEvent(new ChainStatusChangeEvent(
this, ChainStatus.RUNNING, ChainStatus.SUSPEND));
Set<String> suspendNodeIds = newState.getSuspendNodeIds(); for (String id : suspendedNodeIds.get()) {
if (suspendNodeIds != null && !suspendNodeIds.isEmpty()) { Node node = definition.getNodeById(id);
// 移除 suspend 状态,方便二次 suspend 时,不带有旧数据 if (node == null) {
updateStateSafely(state -> { throw new ChainException("Node not found: " + id);
state.setSuspendNodeIds(null);
state.setSuspendForParameters(null);
return EnumSet.of(ChainStateField.SUSPEND_NODE_IDS, ChainStateField.SUSPEND_FOR_PARAMETERS);
});
for (String id : suspendNodeIds) {
Node node = definition.getNodeById(id);
if (node == null) {
throw new ChainException("Node not found: " + id);
}
scheduleNode(node, null, TriggerType.RESUME, 0L);
} }
scheduleNode(node, null, TriggerType.RESUME, 0L);
} }
return true;
} }
public void resume() { public void resume() {

View File

@@ -0,0 +1,16 @@
/**
* Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com).
* <p>
* Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0.
*/
package com.easyagents.flow.core.chain;
/**
* 工作流挂起参数不满足当前恢复请求时抛出的异常。
*/
public class ChainResumeException extends ChainException {
public ChainResumeException(String message) {
super(message);
}
}

View File

@@ -42,6 +42,7 @@ public abstract class Node implements Serializable {
protected NodeCondition condition; protected NodeCondition condition;
protected NodeValidator validator; protected NodeValidator validator;
protected NodeJoinMode joinMode = NodeJoinMode.ANY;
// 循环执行相关属性 // 循环执行相关属性
protected boolean loopEnable = false; // 是否启用循环执行 protected boolean loopEnable = false; // 是否启用循环执行
@@ -70,6 +71,10 @@ public abstract class Node implements Serializable {
} }
public void setParentId(String parentId) { public void setParentId(String parentId) {
if (StringUtil.hasText(parentId) && getJoinMode() == NodeJoinMode.ALL) {
throw new IllegalArgumentException(
"joinMode 'all' is not supported for loop child nodes");
}
this.parentId = parentId; this.parentId = parentId;
} }
@@ -121,6 +126,31 @@ public abstract class Node implements Serializable {
this.validator = validator; this.validator = validator;
} }
/**
* 获取节点的直接入边汇聚模式。
*
* <p>旧序列化对象缺少该字段时返回 {@link NodeJoinMode#ANY}。</p>
*
* @return 汇聚模式
*/
public NodeJoinMode getJoinMode() {
return joinMode == null ? NodeJoinMode.ANY : joinMode;
}
/**
* 设置节点的直接入边汇聚模式。
*
* @param joinMode 汇聚模式
*/
public void setJoinMode(NodeJoinMode joinMode) {
NodeJoinMode resolved = joinMode == null ? NodeJoinMode.ANY : joinMode;
if (resolved == NodeJoinMode.ALL && StringUtil.hasText(parentId)) {
throw new IllegalArgumentException(
"joinMode 'all' is not supported for loop child nodes");
}
this.joinMode = resolved;
}
// protected void addOutwardEdge(Edge edge) { // protected void addOutwardEdge(Edge edge) {
// if (this.outwardEdges == null) { // if (this.outwardEdges == null) {
// this.outwardEdges = new ArrayList<>(); // this.outwardEdges = new ArrayList<>();

View File

@@ -0,0 +1,60 @@
/**
* Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com).
* <p>
* Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.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.gnu.org/licenses/lgpl-3.0.txt
* <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.flow.core.chain;
import java.util.Locale;
/**
* 多入边节点的触发汇聚模式。
*/
public enum NodeJoinMode {
/** 任意一条直接入边到达即可执行。 */
ANY("any"),
/** 全部直接入边到达后才执行。 */
ALL("all");
private final String value;
NodeJoinMode(String value) {
this.value = value;
}
public String getValue() {
return value;
}
/**
* 按工作流 JSON 值解析汇聚模式。
*
* @param value 配置值
* @return 汇聚模式
* @throws IllegalArgumentException 配置为空或不受支持
*/
public static NodeJoinMode ofValue(String value) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException("joinMode must be 'any' or 'all'");
}
String normalized = value.trim().toLowerCase(Locale.ROOT);
for (NodeJoinMode mode : values()) {
if (mode.value.equals(normalized)) {
return mode;
}
}
throw new IllegalArgumentException(
"Unsupported joinMode: " + value + "; expected 'any' or 'all'");
}
}

View File

@@ -49,6 +49,11 @@ public class Parameter implements Serializable, Cloneable {
*/ */
protected List<Object> enums; protected List<Object> enums;
/**
* 显示文案与实际值分离的结构化选项。
*/
protected List<ParameterOption> options;
/** /**
* 用户输入的表单类型,例如:"input" "textarea" "select" "radio" "checkbox" 等等 * 用户输入的表单类型,例如:"input" "textarea" "select" "radio" "checkbox" 等等
*/ */
@@ -242,6 +247,14 @@ public class Parameter implements Serializable, Cloneable {
} }
} }
public List<ParameterOption> getOptions() {
return options;
}
public void setOptions(List<ParameterOption> options) {
this.options = options;
}
public String getFormType() { public String getFormType() {
return formType; return formType;
} }
@@ -298,6 +311,7 @@ public class Parameter implements Serializable, Cloneable {
", flattenAggregation=" + flattenAggregation + ", flattenAggregation=" + flattenAggregation +
", children=" + children + ", children=" + children +
", enums=" + enums + ", enums=" + enums +
", options=" + options +
", formType='" + formType + '\'' + ", formType='" + formType + '\'' +
", formLabel='" + formLabel + '\'' + ", formLabel='" + formLabel + '\'' +
", formPlaceholder='" + formPlaceholder + '\'' + ", formPlaceholder='" + formPlaceholder + '\'' +
@@ -320,6 +334,13 @@ public class Parameter implements Serializable, Cloneable {
clone.enums = new ArrayList<>(this.enums.size()); clone.enums = new ArrayList<>(this.enums.size());
clone.enums.addAll(this.enums); clone.enums.addAll(this.enums);
} }
if (this.options != null) {
clone.options = new ArrayList<>(this.options.size());
for (ParameterOption option : this.options) {
clone.options.add(new ParameterOption(
option.getLabel(), option.getValue()));
}
}
return clone; return clone;
} catch (CloneNotSupportedException e) { } catch (CloneNotSupportedException e) {
throw new AssertionError(); throw new AssertionError();

View File

@@ -0,0 +1,55 @@
/**
* Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com).
* <p>
* Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0.
*/
package com.easyagents.flow.core.chain;
import java.io.Serializable;
/**
* 用户输入参数的结构化选项。
*/
public class ParameterOption implements Serializable {
private static final long serialVersionUID = 1L;
private String label;
private String value;
public ParameterOption() {
}
public ParameterOption(String label, String value) {
setLabel(label);
setValue(value);
}
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = trim(label);
}
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = trim(value);
}
private static String trim(String value) {
return value == null ? null : value.trim();
}
@Override
public String toString() {
return "ParameterOption{" +
"label='" + label + '\'' +
", value='" + value + '\'' +
'}';
}
}

View File

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

View File

@@ -18,6 +18,7 @@ package com.easyagents.flow.core.knowledge;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
public class KnowledgeManager { public class KnowledgeManager {
@@ -51,4 +52,20 @@ public class KnowledgeManager {
} }
return null; return null;
} }
/**
* 将完整知识检索请求交给首个能够处理它的 Provider。
*
* @param request 检索请求
* @return 节点输出;没有 Provider 能处理时返回 null
*/
public Map<String, Object> search(KnowledgeSearchRequest request) {
for (KnowledgeProvider provider : providers) {
Map<String, Object> result = provider.search(request);
if (result != null) {
return result;
}
}
return null;
}
} }

View File

@@ -15,6 +15,37 @@
*/ */
package com.easyagents.flow.core.knowledge; package com.easyagents.flow.core.knowledge;
import com.easyagents.flow.core.util.Maps;
import java.util.List;
import java.util.Map;
public interface KnowledgeProvider { public interface KnowledgeProvider {
Knowledge getKnowledge(Object id); Knowledge getKnowledge(Object id);
/**
* 执行完整的知识库节点检索请求。
*
* <p>默认实现保留单知识库兼容。需要跨知识库汇总的业务 Provider
* 应覆盖本方法并返回完整节点输出。</p>
*
* @param request 检索请求
* @return 节点输出;当前 Provider 不支持该请求时返回 null
*/
default Map<String, Object> search(KnowledgeSearchRequest request) {
if (request == null || request.getKnowledgeIds().size() != 1) {
return null;
}
Object knowledgeId = request.getKnowledgeIds().get(0);
Knowledge knowledge = getKnowledge(knowledgeId);
if (knowledge == null) {
return null;
}
List<Map<String, Object>> documents = knowledge.search(
request.getKeyword(),
request.getLimit(),
request.getKnowledgeNode(),
request.getChain());
return Maps.of("documents", documents);
}
} }

View File

@@ -0,0 +1,67 @@
/**
* Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com).
* <p>
* Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0.
*/
package com.easyagents.flow.core.knowledge;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.node.KnowledgeNode;
import java.util.Collections;
import java.util.ArrayList;
import java.util.List;
/**
* 工作流知识库节点的完整检索请求。
*/
public class KnowledgeSearchRequest {
private final List<Object> knowledgeIds;
private final String keyword;
private final int limit;
private final String retrievalMode;
private final KnowledgeNode knowledgeNode;
private final Chain chain;
public KnowledgeSearchRequest(
List<Object> knowledgeIds,
String keyword,
int limit,
String retrievalMode,
KnowledgeNode knowledgeNode,
Chain chain) {
this.knowledgeIds = knowledgeIds == null
? Collections.emptyList()
: Collections.unmodifiableList(new ArrayList<>(knowledgeIds));
this.keyword = keyword;
this.limit = limit;
this.retrievalMode = retrievalMode;
this.knowledgeNode = knowledgeNode;
this.chain = chain;
}
public List<Object> getKnowledgeIds() {
return knowledgeIds;
}
public String getKeyword() {
return keyword;
}
public int getLimit() {
return limit;
}
public String getRetrievalMode() {
return retrievalMode;
}
public KnowledgeNode getKnowledgeNode() {
return knowledgeNode;
}
public Chain getChain() {
return chain;
}
}

View File

@@ -15,21 +15,55 @@
*/ */
package com.easyagents.flow.core.node; package com.easyagents.flow.core.node;
import com.easyagents.flow.core.chain.Chain; import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.ChainSuspendException; import com.easyagents.flow.core.chain.ChainSuspendException;
import com.easyagents.flow.core.chain.DataType;
import com.easyagents.flow.core.chain.Parameter; import com.easyagents.flow.core.chain.Parameter;
import com.easyagents.flow.core.chain.ParameterOption;
import com.easyagents.flow.core.chain.RefType; import com.easyagents.flow.core.chain.RefType;
import com.easyagents.flow.core.chain.repository.ChainStateField; import com.easyagents.flow.core.chain.repository.ChainStateField;
import com.easyagents.flow.core.chain.runtime.Trigger;
import com.easyagents.flow.core.chain.runtime.TriggerContext;
import com.easyagents.flow.core.chain.runtime.TriggerType;
import java.util.*; import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
public class ConfirmNode extends BaseNode { public class ConfirmNode extends BaseNode {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
public static final String DEFAULT_OUTPUT_NAME = "selection";
public static final int MAX_OPTIONS = 100;
public static final int MAX_MESSAGE_LENGTH = 2000;
public static final int MAX_OPTION_LENGTH = 200;
public static final Set<String> SUPPORTED_CONFIGURATION_KEYS = Set.of(
"condition",
"description",
"expand",
"joinMode",
"loopBreakCondition",
"loopEnable",
"loopIntervalMs",
"maxLoopCount",
"maxRetryCount",
"message",
"multiple",
"options",
"outputDefs",
"resetRetryCountAfterNormal",
"retryEnable",
"retryIntervalMs",
"title");
private String message; private String message;
private List<Parameter> confirms; private boolean multiple;
private List<String> options;
public String getMessage() { public String getMessage() {
return message; return message;
@@ -39,115 +73,152 @@ public class ConfirmNode extends BaseNode {
this.message = message; this.message = message;
} }
public List<Parameter> getConfirms() { public boolean isMultiple() {
return confirms; return multiple;
} }
public void setConfirms(List<Parameter> confirms) { public void setMultiple(boolean multiple) {
if (confirms != null) { this.multiple = multiple;
for (Parameter confirm : confirms) {
confirm.setRefType(RefType.INPUT);
confirm.setRequired(true); // 必填,才能正确通过 getParameterValuesOnly 获取参数值
confirm.setName(confirm.getName());
}
}
this.confirms = confirms;
} }
public List<String> getOptions() {
return options;
}
public void setOptions(List<String> options) {
this.options = options;
}
@Override @Override
public Map<String, Object> execute(Chain chain) { public Map<String, Object> execute(Chain chain) {
validateConfiguration();
String outputName = resolveOutputName();
Parameter parameter = buildParameter();
List<Parameter> confirmParameters = new ArrayList<>(); // 确认值只能来自经过 Chain.resumeIfSuspended 校验后创建的恢复触发器。
addConfirmParameter(confirmParameters); // 启动参数与普通节点内存中的同名值均不能绕过人工确认。
if (!isValidatedResumeTrigger(chain)) {
if (confirms != null) { chain.updateStateSafely(state -> {
for (Parameter confirm : confirms) { if (state.getMemory().remove(parameter.getName()) == null) {
Parameter clone = confirm.clone(); return null;
clone.setName(confirm.getName() + "__" + getId()); }
clone.setRefType(RefType.INPUT); return EnumSet.of(ChainStateField.MEMORY);
confirmParameters.add(clone); });
}
} }
Map<String, Object> values; Map<String, Object> values;
try { try {
values = chain.getExecutionState() values = chain.getExecutionState()
.resolveParameters(this, confirmParameters); .resolveParameters(this, Collections.singletonList(parameter));
// 移除 confirm 参数,方便在其他节点二次确认,或者在 for 循环中第二次获取
chain.updateStateSafely(state -> { chain.updateStateSafely(state -> {
for (Parameter confirmParameter : confirmParameters) { if (!state.getMemory().containsKey(parameter.getName())) {
state.getMemory().remove(confirmParameter.getName()); return null;
} }
state.getMemory().remove(parameter.getName());
return EnumSet.of(ChainStateField.MEMORY); return EnumSet.of(ChainStateField.MEMORY);
}); });
} catch (ChainSuspendException e) { } catch (ChainSuspendException exception) {
chain.updateStateSafely(state -> { chain.updateStateSafely(state -> {
state.setMessage(message); state.setMessage(message);
return EnumSet.of(ChainStateField.MESSAGE); return EnumSet.of(ChainStateField.MESSAGE);
}); });
throw exception;
if (confirms != null) {
List<Parameter> newParameters = new ArrayList<>();
for (Parameter confirm : confirms) {
Parameter clone = confirm.clone();
clone.setName(confirm.getName() + "__" + getId());
clone.setRefType(RefType.REF); // 固定为 REF
newParameters.add(clone);
}
// 获取参数值,不会触发 ChainSuspendException 错误
Map<String, Object> parameterValues =
chain.getExecutionState().resolveParameters(
this,
newParameters,
null,
true);
// 设置 enums方便前端给用户进行选择
for (Parameter confirmParameter : confirmParameters) {
if (confirmParameter.getEnums() == null) {
Object enumsObject = parameterValues.get(confirmParameter.getName());
confirmParameter.setEnumsObject(enumsObject);
}
}
}
throw e;
} }
return Collections.singletonMap(
outputName,
values.get(parameter.getName()));
}
Map<String, Object> results = new HashMap<>(values.size()); /**
values.forEach((key, value) -> { * 获取并校验当前节点配置的唯一输出名称。
int index = key.lastIndexOf("__"); *
if (index >= 0) { * @return 用户配置的输出名称
results.put(key.substring(0, index), value); */
} else { public String resolveOutputName() {
results.put(key, value); if (outputDefs == null || outputDefs.size() != 1
|| outputDefs.get(0) == null) {
throw new IllegalArgumentException(
"用户确认节点必须配置一个输出参数");
}
Parameter output = outputDefs.get(0);
String outputName = output.getName();
if (outputName == null || outputName.trim().isEmpty()) {
throw new IllegalArgumentException(
"用户确认节点输出参数名称不能为空");
}
DataType expectedType = multiple
? DataType.Array_String
: DataType.String;
if (output.getDataType() != expectedType) {
throw new IllegalArgumentException(
"用户确认节点输出参数类型必须与选择方式一致");
}
return outputName;
}
private boolean isValidatedResumeTrigger(Chain chain) {
Trigger trigger = TriggerContext.getCurrentTrigger();
return trigger != null
&& trigger.getType() == TriggerType.RESUME
&& Objects.equals(
chain.getStateInstanceId(),
trigger.getStateInstanceId())
&& Objects.equals(getId(), trigger.getNodeId());
}
public void validateConfiguration() {
requireText(message, MAX_MESSAGE_LENGTH, "确认提示内容");
if (options == null || options.isEmpty()) {
throw new IllegalArgumentException("用户确认节点至少需要一个选项");
}
if (options.size() > MAX_OPTIONS) {
throw new IllegalArgumentException(
"用户确认节点最多支持 " + MAX_OPTIONS + " 个选项");
}
Set<String> normalizedOptions = new HashSet<>();
for (String option : options) {
String normalized = requireText(option, MAX_OPTION_LENGTH, "选项内容");
if (!normalizedOptions.add(normalized)) {
throw new IllegalArgumentException("用户确认节点选项内容重复: " + normalized);
} }
}); }
return results;
} }
private Parameter buildParameter() {
private void addConfirmParameter(List<Parameter> parameters) {
// “确认 和 取消” 的参数
Parameter parameter = new Parameter(); Parameter parameter = new Parameter();
parameter.setId(DEFAULT_OUTPUT_NAME);
parameter.setName(DEFAULT_OUTPUT_NAME + "__" + getId());
parameter.setDataType(multiple ? DataType.Array_String : DataType.String);
parameter.setRefType(RefType.INPUT); parameter.setRefType(RefType.INPUT);
parameter.setId("confirm");
parameter.setName("confirm__" + getId());
parameter.setRequired(true); parameter.setRequired(true);
List<Object> selectionData = new ArrayList<>();
selectionData.add("yes");
selectionData.add("no");
parameter.setEnums(selectionData);
parameter.setContentType("text"); parameter.setContentType("text");
parameter.setFormType("confirm"); parameter.setFormType(multiple ? "checkbox" : "radio");
parameters.add(parameter); parameter.setFormLabel("选择内容");
parameter.setOptions(buildRuntimeOptions());
return parameter;
} }
private List<ParameterOption> buildRuntimeOptions() {
List<ParameterOption> runtimeOptions = new ArrayList<>(options.size());
for (String option : options) {
String normalized = option.trim();
runtimeOptions.add(new ParameterOption(normalized, normalized));
}
return runtimeOptions;
}
private static String requireText(
String value, int maxLength, String fieldName) {
String normalized = value == null ? "" : value.trim();
if (normalized.isEmpty()) {
throw new IllegalArgumentException(fieldName + "不能为空");
}
if (normalized.length() > maxLength) {
throw new IllegalArgumentException(
fieldName + "不能超过 " + maxLength + " 个字符");
}
return normalized;
}
} }

View File

@@ -17,15 +17,15 @@ package com.easyagents.flow.core.node;
import com.easyagents.flow.core.chain.Chain; import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.ChainState; import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.knowledge.Knowledge;
import com.easyagents.flow.core.knowledge.KnowledgeManager; import com.easyagents.flow.core.knowledge.KnowledgeManager;
import com.easyagents.flow.core.util.Maps; import com.easyagents.flow.core.knowledge.KnowledgeSearchRequest;
import com.easyagents.flow.core.util.StringUtil; import com.easyagents.flow.core.util.StringUtil;
import com.easyagents.flow.core.util.TextTemplate; import com.easyagents.flow.core.util.TextTemplate;
import org.slf4j.Logger; import org.slf4j.Logger;
import java.util.Arrays; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -36,6 +36,7 @@ public class KnowledgeNode extends BaseNode {
private static final Logger logger = org.slf4j.LoggerFactory.getLogger(KnowledgeNode.class); private static final Logger logger = org.slf4j.LoggerFactory.getLogger(KnowledgeNode.class);
private Object knowledgeId; private Object knowledgeId;
private List<Object> knowledgeIds = new ArrayList<>();
private String keyword; private String keyword;
private String limit; private String limit;
private String retrievalMode = "HYBRID"; private String retrievalMode = "HYBRID";
@@ -48,6 +49,32 @@ public class KnowledgeNode extends BaseNode {
this.knowledgeId = knowledgeId; this.knowledgeId = knowledgeId;
} }
/**
* 获取规范化的知识库集合,兼容历史单值字段。
*
* @return 去重后的知识库 ID
*/
public List<Object> getKnowledgeIds() {
if (knowledgeIds != null && !knowledgeIds.isEmpty()) {
return Collections.unmodifiableList(knowledgeIds);
}
return knowledgeId == null
? Collections.emptyList()
: Collections.singletonList(knowledgeId);
}
public void setKnowledgeIds(List<?> knowledgeIds) {
LinkedHashSet<Object> normalized = new LinkedHashSet<>();
if (knowledgeIds != null) {
for (Object id : knowledgeIds) {
if (id != null && StringUtil.hasText(String.valueOf(id))) {
normalized.add(id);
}
}
}
this.knowledgeIds = new ArrayList<>(normalized);
}
public String getKeyword() { public String getKeyword() {
return keyword; return keyword;
} }
@@ -88,25 +115,44 @@ public class KnowledgeNode extends BaseNode {
if (StringUtil.hasText(realLimitString)) { if (StringUtil.hasText(realLimitString)) {
try { try {
realLimit = Integer.parseInt(realLimitString); realLimit = Integer.parseInt(realLimitString);
} catch (Exception e) { } catch (NumberFormatException exception) {
logger.error(e.toString(), e); throw new IllegalArgumentException(
"知识库节点最终返回条数必须为正整数", exception);
} }
} }
if (realLimit <= 0) {
Knowledge knowledge = KnowledgeManager.getInstance().getKnowledge(knowledgeId); throw new IllegalArgumentException(
"知识库节点最终返回条数必须为正整数");
if (knowledge == null) {
return Collections.emptyMap();
} }
List<Map<String, Object>> result = knowledge.search(realKeyword, realLimit, this, chain); List<Object> resolvedKnowledgeIds = getKnowledgeIds();
return Maps.of("documents", result); if (resolvedKnowledgeIds.isEmpty()) {
throw new IllegalArgumentException("知识库节点至少需要选择一个知识库");
}
if (resolvedKnowledgeIds.size() > 1
&& !"VECTOR".equalsIgnoreCase(retrievalMode)) {
throw new IllegalArgumentException("多知识库检索仅支持 VECTOR 模式");
}
Map<String, Object> result = KnowledgeManager.getInstance().search(
new KnowledgeSearchRequest(
resolvedKnowledgeIds,
realKeyword,
realLimit,
retrievalMode,
this,
chain));
if (result == null) {
throw new IllegalStateException("没有可用的知识库 Provider");
}
return result;
} }
@Override @Override
public String toString() { public String toString() {
return "KnowledgeNode{" + return "KnowledgeNode{" +
"knowledgeId=" + knowledgeId + "knowledgeId=" + knowledgeId +
", knowledgeIds=" + knowledgeIds +
", keyword='" + keyword + '\'' + ", keyword='" + keyword + '\'' +
", limit='" + limit + '\'' + ", limit='" + limit + '\'' +
", retrievalMode='" + retrievalMode + '\'' + ", retrievalMode='" + retrievalMode + '\'' +

View File

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

View File

@@ -21,6 +21,7 @@ import com.alibaba.fastjson.JSONObject;
import com.easyagents.flow.core.chain.DataType; import com.easyagents.flow.core.chain.DataType;
import com.easyagents.flow.core.chain.JsCodeCondition; import com.easyagents.flow.core.chain.JsCodeCondition;
import com.easyagents.flow.core.chain.Node; import com.easyagents.flow.core.chain.Node;
import com.easyagents.flow.core.chain.NodeJoinMode;
import com.easyagents.flow.core.chain.Parameter; import com.easyagents.flow.core.chain.Parameter;
import com.easyagents.flow.core.chain.RefType; import com.easyagents.flow.core.chain.RefType;
import com.easyagents.flow.core.node.BaseNode; import com.easyagents.flow.core.node.BaseNode;
@@ -123,6 +124,10 @@ public abstract class BaseNodeParser<T extends BaseNode> implements NodeParser<T
if (!data.isEmpty()) { if (!data.isEmpty()) {
if (data.containsKey("joinMode")) {
node.setJoinMode(NodeJoinMode.ofValue(data.getString("joinMode")));
}
addParameters(node, data); addParameters(node, data);
addOutputDefs(node, data); addOutputDefs(node, data);

View File

@@ -15,11 +15,12 @@
*/ */
package com.easyagents.flow.core.parser.impl; package com.easyagents.flow.core.parser.impl;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject; import com.alibaba.fastjson.JSONObject;
import com.easyagents.flow.core.chain.Parameter;
import com.easyagents.flow.core.node.ConfirmNode; import com.easyagents.flow.core.node.ConfirmNode;
import com.easyagents.flow.core.parser.BaseNodeParser; import com.easyagents.flow.core.parser.BaseNodeParser;
import java.util.ArrayList;
import java.util.List; import java.util.List;
public class ConfirmNodeParser extends BaseNodeParser<ConfirmNode> { public class ConfirmNodeParser extends BaseNodeParser<ConfirmNode> {
@@ -28,12 +29,36 @@ public class ConfirmNodeParser extends BaseNodeParser<ConfirmNode> {
public ConfirmNode doParse(JSONObject root, JSONObject data, JSONObject chainJSONObject) { public ConfirmNode doParse(JSONObject root, JSONObject data, JSONObject chainJSONObject) {
ConfirmNode confirmNode = new ConfirmNode(); ConfirmNode confirmNode = new ConfirmNode();
confirmNode.setMessage(data.getString("message")); for (String key : data.keySet()) {
if (!ConfirmNode.SUPPORTED_CONFIGURATION_KEYS.contains(key)) {
List<Parameter> confirms = getParameters(data, "confirms"); throw new IllegalArgumentException(
if (confirms != null && !confirms.isEmpty()) { "用户确认节点包含无效配置字段: " + key);
confirmNode.setConfirms(confirms); }
} }
Object message = data.get("message");
if (!(message instanceof String)) {
throw new IllegalArgumentException("用户确认节点提示内容必须为字符串");
}
confirmNode.setMessage((String) message);
Object multiple = data.get("multiple");
if (!(multiple instanceof Boolean)) {
throw new IllegalArgumentException("用户确认节点选择方式必须为布尔值");
}
confirmNode.setMultiple((Boolean) multiple);
Object optionsValue = data.get("options");
if (!(optionsValue instanceof JSONArray options)) {
throw new IllegalArgumentException("用户确认节点选项必须为数组");
}
List<String> confirmOptions = new ArrayList<>(options.size());
for (Object option : options) {
if (!(option instanceof String)) {
throw new IllegalArgumentException("用户确认节点选项内容必须为字符串");
}
confirmOptions.add((String) option);
}
confirmNode.setOptions(confirmOptions);
return confirmNode; return confirmNode;
} }

View File

@@ -16,15 +16,41 @@
package com.easyagents.flow.core.parser.impl; package com.easyagents.flow.core.parser.impl;
import com.alibaba.fastjson.JSONObject; import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.JSONArray;
import com.easyagents.flow.core.node.KnowledgeNode; import com.easyagents.flow.core.node.KnowledgeNode;
import com.easyagents.flow.core.parser.BaseNodeParser; import com.easyagents.flow.core.parser.BaseNodeParser;
import java.util.ArrayList;
public class KnowledgeNodeParser extends BaseNodeParser<KnowledgeNode> { public class KnowledgeNodeParser extends BaseNodeParser<KnowledgeNode> {
@Override @Override
public KnowledgeNode doParse(JSONObject root, JSONObject data, JSONObject chainJSONObject) { public KnowledgeNode doParse(JSONObject root, JSONObject data, JSONObject chainJSONObject) {
KnowledgeNode knowledgeNode = new KnowledgeNode(); KnowledgeNode knowledgeNode = new KnowledgeNode();
knowledgeNode.setKnowledgeId(data.get("knowledgeId")); if (data.containsKey("knowledgeIds")) {
Object rawIds = data.get("knowledgeIds");
if (!(rawIds instanceof JSONArray)) {
throw new IllegalArgumentException("knowledgeIds 必须为数组");
}
JSONArray ids = (JSONArray) rawIds;
if (ids.isEmpty()) {
throw new IllegalArgumentException("knowledgeIds 不能为空");
}
java.util.LinkedHashSet<String> normalized =
new java.util.LinkedHashSet<>();
for (Object id : ids) {
String value = id == null ? null : String.valueOf(id).trim();
if (!com.easyagents.flow.core.util.StringUtil.hasText(value)) {
throw new IllegalArgumentException("knowledgeIds 不能包含空值");
}
if (!normalized.add(value)) {
throw new IllegalArgumentException("knowledgeIds 不能包含重复值");
}
}
knowledgeNode.setKnowledgeIds(new ArrayList<>(normalized));
} else {
knowledgeNode.setKnowledgeId(data.get("knowledgeId"));
}
knowledgeNode.setLimit(data.getString("limit")); knowledgeNode.setLimit(data.getString("limit"));
knowledgeNode.setKeyword(data.getString("keyword")); knowledgeNode.setKeyword(data.getString("keyword"));
knowledgeNode.setRetrievalMode(data.getString("retrievalMode")); knowledgeNode.setRetrievalMode(data.getString("retrievalMode"));

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

@@ -15,10 +15,15 @@
*/ */
package com.easyagents.flow.core.test; package com.easyagents.flow.core.test;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.easyagents.flow.core.chain.Chain; import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.ChainDefinition; import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.chain.ChainStatus; import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.DataType;
import com.easyagents.flow.core.chain.Edge; import com.easyagents.flow.core.chain.Edge;
import com.easyagents.flow.core.chain.Parameter;
import com.easyagents.flow.core.chain.RefType;
import com.easyagents.flow.core.chain.ChainState; import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.chain.event.ChainEndEvent; import com.easyagents.flow.core.chain.event.ChainEndEvent;
import com.easyagents.flow.core.chain.repository.ChainDefinitionSnapshotRepository; import com.easyagents.flow.core.chain.repository.ChainDefinitionSnapshotRepository;
@@ -33,7 +38,9 @@ import com.easyagents.flow.core.chain.runtime.Trigger;
import com.easyagents.flow.core.chain.runtime.TriggerScheduler; import com.easyagents.flow.core.chain.runtime.TriggerScheduler;
import com.easyagents.flow.core.node.EndNode; import com.easyagents.flow.core.node.EndNode;
import com.easyagents.flow.core.node.BaseNode; import com.easyagents.flow.core.node.BaseNode;
import com.easyagents.flow.core.node.ConfirmNode;
import com.easyagents.flow.core.node.StartNode; import com.easyagents.flow.core.node.StartNode;
import com.easyagents.flow.core.parser.ChainParser;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Test; import org.junit.Test;
@@ -46,6 +53,7 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.Future; import java.util.concurrent.Future;
@@ -59,6 +67,164 @@ import java.util.concurrent.atomic.AtomicReference;
*/ */
public class ChainExecutorConcurrencyTest { public class ChainExecutorConcurrencyTest {
/**
* 验证启动变量不能伪造确认节点的恢复参数并绕过人工确认。
*/
@Test
public void shouldSuspendConfirmNodeDespitePrefilledStartVariable()
throws Exception {
ScheduledExecutorService schedulerPool =
Executors.newSingleThreadScheduledExecutor();
ExecutorService workerPool = Executors.newFixedThreadPool(2);
TriggerScheduler triggerScheduler = new TriggerScheduler(
new InMemoryTriggerStore(), schedulerPool, workerPool, 10L);
ChainDefinition definition = createConfirmDefinition();
InMemoryChainStateRepository stateRepository =
new InMemoryChainStateRepository();
ChainExecutor executor = new ChainExecutor(
ignored -> definition,
stateRepository,
new InMemoryNodeStateRepository(),
triggerScheduler);
try {
String instanceId = executor.executeAsync(
definition.getId(),
Map.of("selection__confirm", "未配置值"));
ChainState state = awaitStatus(
stateRepository, instanceId, ChainStatus.SUSPEND);
Assert.assertFalse(
state.getMemory().containsKey("selection__confirm"));
Assert.assertEquals(
"selection__confirm",
state.getSuspendForParameters().get(0).getName());
} finally {
triggerScheduler.shutdown();
}
}
/**
* 验证设计器最终契约可解析、挂起、恢复,并把用户选择按配置名称交给结束节点。
*/
@Test
public void shouldFlowConfiguredConfirmOutputToEndNode()
throws Exception {
ScheduledExecutorService schedulerPool =
Executors.newSingleThreadScheduledExecutor();
ExecutorService workerPool = Executors.newFixedThreadPool(2);
TriggerScheduler triggerScheduler = new TriggerScheduler(
new InMemoryTriggerStore(), schedulerPool, workerPool, 10L);
ChainDefinition definition = createParsedConfirmDefinition();
InMemoryChainStateRepository stateRepository =
new InMemoryChainStateRepository();
ChainExecutor executor = new ChainExecutor(
ignored -> definition,
stateRepository,
new InMemoryNodeStateRepository(),
triggerScheduler);
try {
String instanceId = executor.executeAsync(
definition.getId(), Collections.emptyMap());
ChainState suspended = awaitStatus(
stateRepository, instanceId, ChainStatus.SUSPEND);
Assert.assertEquals(
"selection__confirm",
suspended.getSuspendForParameters().get(0).getName());
Assert.assertTrue(executor.resumeAsyncIfSuspended(
instanceId,
Map.of("selection__confirm", "继续")));
ChainState completed = awaitStatus(
stateRepository, instanceId, ChainStatus.SUCCEEDED);
Assert.assertEquals("继续", completed.getExecuteResult().get("result"));
} finally {
triggerScheduler.shutdown();
}
}
/**
* 验证多选确认结果以字符串数组形式流转到结束节点。
*/
@Test
public void shouldFlowMultipleConfirmOutputToEndNode()
throws Exception {
ScheduledExecutorService schedulerPool =
Executors.newSingleThreadScheduledExecutor();
ExecutorService workerPool = Executors.newFixedThreadPool(2);
TriggerScheduler triggerScheduler = new TriggerScheduler(
new InMemoryTriggerStore(), schedulerPool, workerPool, 10L);
ChainDefinition definition = createParsedConfirmDefinition(true);
InMemoryChainStateRepository stateRepository =
new InMemoryChainStateRepository();
ChainExecutor executor = new ChainExecutor(
ignored -> definition,
stateRepository,
new InMemoryNodeStateRepository(),
triggerScheduler);
try {
String instanceId = executor.executeAsync(
definition.getId(), Collections.emptyMap());
ChainState suspended = awaitStatus(
stateRepository, instanceId, ChainStatus.SUSPEND);
Assert.assertEquals(
DataType.Array_String,
suspended.getSuspendForParameters().get(0).getDataType());
List<String> selection = List.of("继续", "停止");
Assert.assertTrue(executor.resumeAsyncIfSuspended(
instanceId,
Map.of("selection__confirm", selection)));
ChainState completed = awaitStatus(
stateRepository, instanceId, ChainStatus.SUCCEEDED);
Assert.assertEquals(
selection, completed.getExecuteResult().get("result"));
} finally {
triggerScheduler.shutdown();
}
}
/**
* 验证同步 Tool 入口遇到人工挂起会快速失败,不会无限占用调用线程。
*
* @throws Exception 异步测试执行失败时抛出
*/
@Test
public void shouldFailFastWhenNonSuspendingExecutionIsSuspended()
throws Exception {
ScheduledExecutorService schedulerPool = Executors.newSingleThreadScheduledExecutor();
ExecutorService workerPool = Executors.newFixedThreadPool(2);
TriggerScheduler triggerScheduler = new TriggerScheduler(
new InMemoryTriggerStore(), schedulerPool, workerPool, 10L);
ChainDefinition definition = createConfirmDefinition();
ChainExecutor executor = new ChainExecutor(
ignored -> definition,
new InMemoryChainStateRepository(),
new InMemoryNodeStateRepository(),
triggerScheduler);
ExecutorService caller = Executors.newSingleThreadExecutor();
try {
Future<Map<String, Object>> result = caller.submit(
() -> executor.executeWithoutSuspension(
definition.getId(), Collections.emptyMap()));
try {
result.get(3, TimeUnit.SECONDS);
Assert.fail("suspended workflow must fail");
} catch (ExecutionException exception) {
Assert.assertTrue(
String.valueOf(exception.getCause().getMessage())
.contains("Execution failed"));
}
} finally {
caller.shutdownNow();
triggerScheduler.shutdown();
}
}
/** /**
* 验证实例初始化会在入口触发器创建前持久化工作流定义 ID。 * 验证实例初始化会在入口触发器创建前持久化工作流定义 ID。
* *
@@ -512,6 +678,127 @@ public class ChainExecutorConcurrencyTest {
return definition; return definition;
} }
/**
* 创建包含内部确认节点的测试 Workflow。
*
* @return 会进入挂起状态的 Workflow 定义
*/
private ChainDefinition createConfirmDefinition() {
ChainDefinition definition = new ChainDefinition();
definition.setId("non-suspending-confirm-test");
StartNode start = new StartNode();
start.setId("start");
ConfirmNode confirm = new ConfirmNode();
confirm.setId("confirm");
confirm.setMessage("请选择是否继续");
confirm.setOptions(List.of("继续", "停止"));
confirm.setOutputDefs(Collections.singletonList(
new Parameter(
ConfirmNode.DEFAULT_OUTPUT_NAME,
DataType.String)));
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;
}
private ChainDefinition createParsedConfirmDefinition() {
return createParsedConfirmDefinition(false);
}
private ChainDefinition createParsedConfirmDefinition(boolean multiple) {
JSONArray nodes = new JSONArray();
nodes.add(nodeJson("start", "startNode", new JSONObject()));
JSONObject confirmData = new JSONObject();
confirmData.put("message", "请选择是否继续");
confirmData.put("multiple", multiple);
confirmData.put("options", new JSONArray(List.of("继续", "停止")));
String outputType = multiple ? "Array<String>" : "String";
confirmData.put("outputDefs", new JSONArray(List.of(
parameterJson("templateType", outputType, null))));
nodes.add(nodeJson("confirm", "confirmNode", confirmData));
JSONObject endData = new JSONObject();
endData.put("outputDefs", new JSONArray(List.of(
parameterJson(
"result", outputType, "confirm.templateType"))));
nodes.add(nodeJson("end", "endNode", endData));
JSONArray edges = new JSONArray();
edges.add(edgeJson("start-to-confirm", "start", "confirm"));
edges.add(edgeJson("confirm-to-end", "confirm", "end"));
JSONObject flow = new JSONObject();
flow.put("nodes", nodes);
flow.put("edges", edges);
ChainDefinition definition = ChainParser.builder()
.withDefaultParsers(true)
.build()
.parse(flow.toJSONString());
definition.setId("confirm-output-flow-test");
return definition;
}
private JSONObject nodeJson(
String id, String type, JSONObject data) {
JSONObject node = new JSONObject();
node.put("id", id);
node.put("type", type);
node.put("data", data);
return node;
}
private JSONObject edgeJson(
String id, String source, String target) {
JSONObject edge = new JSONObject();
edge.put("id", id);
edge.put("source", source);
edge.put("target", target);
return edge;
}
private JSONObject parameterJson(
String name, String dataType, String ref) {
JSONObject parameter = new JSONObject();
parameter.put("name", name);
parameter.put("dataType", dataType);
if (ref != null) {
parameter.put("ref", ref);
parameter.put("refType", RefType.REF.toString());
}
return parameter;
}
private ChainState awaitStatus(
InMemoryChainStateRepository repository,
String instanceId,
ChainStatus expected) throws InterruptedException {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
ChainState state;
do {
state = repository.load(instanceId);
if (state != null && state.getStatus() == expected) {
return state;
}
Thread.sleep(10L);
} while (System.nanoTime() < deadline);
Assert.fail("workflow did not reach status " + expected);
return state;
}
/** /**
* 创建用于取消传播验证的工作流。 * 创建用于取消传播验证的工作流。
* *

Some files were not shown because too many files have changed in this diff Show More