Compare commits

56 Commits

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

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

- 补充确认节点契约与恢复测试
2026-09-04 14:48:48 +08:00
368b90b211 perf: 复用 Milvus 客户端连接池 2026-09-04 11:22:13 +08:00
1ea7b7527f fix: 暴露本地文档解析任务丢失状态 2026-09-04 11:21:46 +08:00
68fd303656 fix: 拦截非标准 XLSX 文件
- 在 POI 解析前识别旧版 XLS 与异常容器

- 返回可操作提示并补充回归测试
2026-09-02 19:15:00 +08:00
c1fe64cefa perf: 优化多源联邦分片调度
- 增加 Engine 级有界并行调度与资源收口

- 补齐调度饱和、失败快速传播及六源基准测试
2026-09-01 17:02:08 +08:00
1b36067e6c fix: 修复 Lucene 特殊字符查询失败
- 将用户关键词按普通文本转义后再解析

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

- 补充特殊字符与空查询回归测试
2026-09-01 15:11:55 +08:00
876517f821 feat: M28 支持工作流多入边汇聚模式 2026-08-31 15:54:44 +08:00
2d50f7de15 fix: 完善分布式调度恢复与批量触发
- 持久化 Quartz refire 状态并收口运行时启动关闭顺序

- 增加批量 Trigger 获取配置、校验与回归测试
2026-08-31 14:56:41 +08:00
93296eb810 feat: 增强 Agentic RAG 主动检索引导
- 统一组合用户、知识库与异步工具系统提示词

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

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

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

- 补充失败和推理中断场景的上下文恢复测试
2026-08-26 22:55:20 +08:00
880f810ba2 chore: 清理过期示例与测试资源
- 移除未接入聚合构建的旧 Hello World 示例

- 删除历史文档与图片测试资源
2026-08-26 18:15:24 +08:00
b4bdc392ee feat: 新增嵌入式分布式调度底座
- 提供通用调度 API、Quartz JDBC Provider 与独立 Starter

- 补充 MySQL、PostgreSQL、H2 建表脚本与接入校验

- 同步完善 Federation 与 Scheduler 模块说明
2026-08-26 18:15:13 +08:00
02b8fdd3ae feat: 新增 SQL 联邦查询与数据库适配底座
- 提供统一编译、逻辑表映射与单源/联邦自动路由

- 增加有界执行、查询生命周期、统计成本优化与执行分析

- 内置 MySQL 与 PostgreSQL JDBC 适配和统计采集
2026-08-25 01:00:49 +08:00
2f67d90144 chore: 进入 v1.2.0 版本开发 2026-08-20 11:41:18 +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
f13e24751a fix: 修复知识库索引并发与向量化边界
- 复用 Lucene 和 Elasticsearch 客户端并支持有界批量写入与删除

- 记录脱敏后的 Embedding 失败请求与完整响应

- 为 BGE-M3 分块统一增加上下文硬上限
2026-08-07 12:38:09 +08:00
bdb69a2250 feat: 支持代码节点 main 命名参数调用
- 按节点参数顺序向 JavaScript main 传入独立参数值

- 保留历史单对象参数与显式 _result 输出兼容

- 补充参数模式解析和运行时回归测试
2026-08-03 16:58:11 +08:00
5fd4d845af fix: 兼容 Milvus 字符串主键删除
- 删除前统一规范化主键值

- 补充字符串主键回归测试
2026-08-03 14:55:32 +08:00
15adcfff42 fix: 修复 MinerU 异步任务提交探测
- 使用健康接口预检服务可用性并缓存短期故障

- 限制任务提交总时长并快速处理服务端错误

- 补齐 Starter 配置传递与核心回归测试
2026-08-03 11:32:25 +08:00
f0a5aacc92 perf: 优化 JavaScript 代码节点执行与错误定位
- 复用 Graal Engine 与分段有界 Source 缓存,保持执行 Context 隔离

- 增加并发超时取消、脚本行列错误及参数解析复用
2026-08-03 11:17:44 +08:00
fd3d9ad419 fix: 脱敏多模态图片请求日志
- 隐去图片 Data URI 编码正文并保留 MIME 与长度

- 补充日志脱敏和 image_url 序列化测试
2026-07-31 14:52:45 +08:00
4af2d7cd34 feat: 支持循环数组输出扁平聚合
- 按输出参数保存并解析一层扁平聚合策略

- 覆盖解析、顺序聚合与异常类型校验
2026-07-31 14:45:11 +08:00
fcc36dc699 feat(XL13): 支持工作流大模型流式输出
- 增加文本与思考增量事件及取消终态收口

- 支持图片输入解析并修复死信定义查找空值

- 补充并发与模型流式回归测试
2026-07-31 09:40:38 +08:00
e74d229de2 feat: 支持代码节点自动调用 main
- 将节点输入作为对象参数传入 main,并映射对象返回值

- 保留 _result 兼容并补充异常与回归测试
2026-07-30 17:34:53 +08:00
851dd1be01 feat: 增强工作流循环双输入运行语义
- 支持次数、数组及二者组合的循环执行计划

- 保持旧循环定义和上游数组读取兼容

- 补充解析、边界与前缀遍历测试
2026-07-29 18:05:23 +08:00
12491b3724 feat: 增强常规文档轻量读取能力
- 支持 PDF、Office、表格、TXT 与 Markdown 的结构化轻量读取

- 增加结构上限、取消信号、稳定定位与读取错误分类

- 使用事件流读取表格并补充核心读取测试
2026-07-29 01:03:42 +08:00
c72a167633 perf: 优化工作流执行引擎与循环调度
- 引入增量状态、定义快照和持久化触发器

- 收敛循环结果、模板条件和高 IO 执行开销

- 统一循环 1 至 300 次约束并补充并发回归测试
2026-07-29 00:47:41 +08:00
a7e89cee3d fix: 统一 OpenAI 消息内容块数组格式
- 将 system、user、assistant、tool 及历史上下文 content 统一为数组

- 保留 DeepSeek、GLM 专用格式规则并补充多模态与工具消息测试
2026-07-28 12:24:38 +08:00
c48d9a9da6 feat: 完善标准 Skill 包底座
- 标准化 SKILL.md、资源模型、校验规则与安全限额

- 支持流式内容存储和单、多 Skill ZIP 双向编解码
2026-07-27 18:53:24 +08:00
6fa93bd671 feat: 支持配置 system 消息内容格式
- 为 OpenAI 兼容模型增加字符串与文本数组两种 system content 策略

- 在 AgentScope 模型工厂统一安装兼容 formatter 并补充测试
2026-07-27 15:41:31 +08:00
e995088d79 build: 优化多模块发布打包流程
- 升级源码、Javadoc 与 Central 发布插件并消除链接误报

- 将发布附件收敛到 release profile 并更新构建说明
2026-07-23 20:19:23 +08:00
5b6b2db5d8 fix: 保留流式输出连续重复字符
- 按 AgentScope 增量协议原样保留普通文本分片

- 仅在终态完整快照中去除已发送前缀

- 补充连续端口字符回归测试
2026-07-23 19:46:07 +08:00
fbeece2d89 fix: 完善工具审批调用绑定
- 以 toolCallId、工具名称和入参绑定一次性执行授权

- 支持批次审批、重复调用去重及拒绝过期处理

- 补充多工具审批与授权消费回归测试
2026-07-23 19:45:49 +08:00
7e59f0e638 fix: 修复工作流同步执行并发监听异常
- 使用写时复制集合保证事件分发期间安全增删监听器

- 按工作流实例路由同步结果并补充并发回归测试
2026-07-23 11:52:50 +08:00
f057900f7a feat: 打通智能体图片媒体运行链路
- 增加稳定媒体引用解析与模型图片能力校验

- 修复图片 Data URI 与模型完整文本读取

- 补充媒体解析和消息兼容测试
2026-07-17 19:45:37 +08:00
66da0c9039 feat: 增加智能体模型 HTTP 传输兼容策略
- 按模型地址自动选择 HTTP/1.1 或 HTTP/2 优先策略

- 复用并托管 AgentScope HTTP Transport 生命周期

- 补充协议解析、复用与 Provider 边界测试
2026-07-17 19:43:55 +08:00
9d0d148415 fix: 合并 FAQ 向量化异常修复
- 合入 OpenAI-compatible embedding 维度参数适配

- 合入缺失 embedding 响应的明确异常与单测
2026-06-14 16:22:33 +08:00
3bd346ea77 fix: 修复增加 FAQ 报错的 bug 2026-06-14 16:09:07 +08:00
848197b556 chore: 调整开发分支为试验性版本 2026-06-08 17:22:48 +08:00
13e848ddf4 feat: 增加技能管理模块试验性功能,等待优化 2026-06-08 16:52:41 +08:00
55434466d4 chore: 发布 easy-agents 1.0.0
- 将 Maven revision 升级为 1.0.0

- 同步示例项目与 easy-agents-bom 依赖版本
2026-06-05 14:05:58 +08:00
7cac558b6c feat: 归档 XL08 异步工具协议
- 新增 AsyncToolSpec 与 AsyncSubTools 五子工具展开能力

- 增加异步工具事件、上下文事件发射和模型可见结果裁剪

- 补充 AgentScope 异步工具协议提示与 runtime 单元测试
2026-06-04 15:23:04 +08:00
43f45956ff feat: 对接 Agent MCP 能力
- 新增 runtime MCP 声明、ClientFactory、Toolkit 适配与工具别名映射

- 增加 MCP 环境检测与 stdio 环境变量透传

- 补齐 MCP 工具事件、审批与生命周期释放测试
2026-05-29 11:08:39 +08:00
2bc525c16e feat: 增加内置read、write、shell 工具支持 2026-05-28 11:16:41 +08:00
f324acb83c perf: 优化向量库连接性能 2026-05-28 11:16:09 +08:00
551 changed files with 86223 additions and 1956 deletions

View File

@@ -10,6 +10,8 @@ Easy-Agents 是一个轻量、可扩展的 Java AI 应用开发框架,覆盖
- MCP 客户端能力(调用、拦截、缓存与管理)
- 文档读取与切分、向量存储与检索
- 工作流执行引擎Flow与 Easy-Agents 适配支持
- 基于 Calcite 的 SQL 编译、方言适配与流式 JDBC 查询
- 基于 Quartz JDBC JobStore 的嵌入式分布式定时调度
## 模块说明
@@ -24,7 +26,10 @@ Easy-Agents 是一个轻量、可扩展的 Java AI 应用开发框架,覆盖
- `easy-agents-search-engine`:检索引擎实现。
- `easy-agents-tool`:工具调用能力。
- `easy-agents-mcp`MCP 集成。
- `easy-agents-skill`:标准 Agent Skills 包模型、安全校验、资源存储与 ZIP 双向编解码。
- `easy-agents-flow`:流程编排核心引擎。
- `easy-agents-federation-sql`:高性能 SQL 联邦查询内核与可扩展数据库 Adapter。
- `easy-agents-scheduler`:业务无关的调度 API、Quartz Provider 与独立 Spring Boot Starter。
- `easy-agents-support`Flow 与 Easy-Agents 适配模块。
- `easy-agents-spring-boot-starter`Spring Boot 自动配置支持。
- `easy-agents-samples`:示例工程。
@@ -39,11 +44,17 @@ Easy-Agents 是一个轻量、可扩展的 Java AI 应用开发框架,覆盖
在项目根目录执行:
```bash
mvn -DskipTests -Dmaven.javadoc.skip=true -Dgpg.skip=true clean install
mvn -DskipTests clean install
```
构建完成后,相关构件会安装到本地 Maven 仓库,可供 `easyflow` 等项目直接依赖。
发布时启用 `release` profile生成源码包和 Javadoc 包,并调用 Maven Central 发布插件:
```bash
mvn -Prelease -DskipTests deploy
```
## 快速示例
```java
@@ -69,7 +80,9 @@ public static void main(String[] args) {
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-bom</artifactId>
<version>0.0.1</version>
<version>1.2.0-RC</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
@@ -85,3 +98,37 @@ public static void main(String[] args) {
</dependency>
</dependencies>
```
## 嵌入式分布式调度
Spring Boot 项目可直接引入独立 Starter
```xml
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-scheduler-spring-boot-starter</artifactId>
</dependency>
```
引用方需要先从 `easy-agents-scheduler-quartz` 构件的 `quartz-schema/` 目录选择 MySQL、PostgreSQL 或 H2 脚本,并纳入自己的 Flyway、Liquibase 或初始化流程。Starter 不会自动创建、删除或修改 Quartz 表。
最小配置:
```yaml
easy-agents:
scheduler:
enabled: true
# 多 DataSource 时必须指定 Bean 名称
data-source-bean-name: dataSource
quartz:
scheduler-name: easyAgentsScheduler
instance-id: AUTO
clustered: true
table-prefix: QRTZ_
thread-count: 8
shutdown-wait-timeout-millis: 30000
```
业务方将 `ScheduleHandler` 注册为 Spring Bean并通过 `ScheduleService` 创建 Cron 或一次性任务。调度触发采用至少一次语义Handler 需要使用 `scheduleId + scheduledFireTime` 或立即触发的 `invocationId` 实现业务幂等。应用关闭超过等待上限后会向 Handler 线程发送协作式中断;长耗时 Handler 必须正确响应线程中断,忽略中断的业务代码仍可能继续占用 Quartz Worker。完整建表说明见 `easy-agents-scheduler/easy-agents-scheduler-quartz/SCHEMA.md`
当前 Provider 固定使用 Quartz `2.5.2``easy-agents-bom` 已同步管理该传递依赖。若业务项目还引入了其他 BOM 或显式 Quartz 版本,接入时应执行 `mvn dependency:tree -Dincludes=org.quartz-scheduler:quartz`,确认最终解析版本仍为 `2.5.2`

View File

@@ -23,6 +23,16 @@
<artifactId>agentscope</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
</dependency>
<dependency>
<groupId>com.anthropic</groupId>
<artifactId>anthropic-java</artifactId>

View File

@@ -2,6 +2,7 @@ package com.easyagents.agent.runtime;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
import com.easyagents.agent.runtime.memory.AgentMemoryPolicy;
import com.easyagents.agent.runtime.mcp.McpSpec;
import com.easyagents.agent.runtime.model.AgentGenerationOptions;
import com.easyagents.agent.runtime.model.AgentModelSpec;
import com.easyagents.agent.runtime.persistence.AgentPersistencePolicy;
@@ -28,6 +29,7 @@ public class AgentDefinition {
private AgentGenerationOptions generationOptions = new AgentGenerationOptions();
private AgentExecutionOptions executionOptions = new AgentExecutionOptions();
private List<AgentToolSpec> toolSpecs = new ArrayList<>();
private List<McpSpec> mcpSpecs = new ArrayList<>();
private List<AgentOperateToolSpec> operateToolSpecs = new ArrayList<>();
private List<AgentKnowledgeSpec> knowledgeSpecs = new ArrayList<>();
private AgentMemoryPolicy memoryPolicy = AgentMemoryPolicy.autoContext();
@@ -179,6 +181,24 @@ public class AgentDefinition {
this.toolSpecs = toolSpecs == null ? new ArrayList<>() : new ArrayList<>(toolSpecs);
}
/**
* 获取 MCP 声明。
*
* @return MCP 声明
*/
public List<McpSpec> getMcpSpecs() {
return mcpSpecs;
}
/**
* 设置 MCP 声明。
*
* @param mcpSpecs MCP 声明
*/
public void setMcpSpecs(List<McpSpec> mcpSpecs) {
this.mcpSpecs = mcpSpecs == null ? new ArrayList<>() : new ArrayList<>(mcpSpecs);
}
/**
* 获取操作类工具定义。
*

View File

@@ -1,13 +1,17 @@
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.memory.AgentMemorySnapshot;
import com.easyagents.agent.runtime.persistence.conversation.AgentConversationRecorder;
import com.easyagents.agent.runtime.persistence.conversation.noop.NoopAgentConversationRecorder;
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore;
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@@ -43,7 +47,12 @@ public class AgentInitRequest {
/**
* 知识库集合实现AgentKnowledgeRetriever接口以进行知识检索动作。
*/
private Map<String, AgentKnowledgeRetriever> knowledgeRetrievers = new LinkedHashMap<>();
private List<AgentKnowledgeRegistration> knowledgeRegistrations = new ArrayList<>();
/**
* 首次构建 Agent 时装载的对话历史快照。
*/
private AgentMemorySnapshot memorySnapshot = new AgentMemorySnapshot();
/**
* 对话事件记录器,用于记录运行时事件流。
@@ -55,6 +64,11 @@ public class AgentInitRequest {
*/
private Map<String, Object> metadata = new LinkedHashMap<>();
/**
* 媒体引用解析器,仅在模型调用前解析稳定引用。
*/
private AgentMediaResolver mediaResolver;
/**
* 获取会话ID。
*
@@ -150,17 +164,37 @@ public class AgentInitRequest {
*
* @return 知识库检索器
*/
public Map<String, AgentKnowledgeRetriever> getKnowledgeRetrievers() {
return knowledgeRetrievers;
public List<AgentKnowledgeRegistration> getKnowledgeRegistrations() {
return knowledgeRegistrations;
}
/**
* 设置知识库检索器。
*
* @param knowledgeRetrievers 知识库检索器
* @param knowledgeRegistrations 知识库运行时绑定
*/
public void setKnowledgeRetrievers(Map<String, AgentKnowledgeRetriever> knowledgeRetrievers) {
this.knowledgeRetrievers = knowledgeRetrievers == null ? new LinkedHashMap<>() : knowledgeRetrievers;
public void setKnowledgeRegistrations(List<AgentKnowledgeRegistration> knowledgeRegistrations) {
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;
}
/**
@@ -200,4 +234,22 @@ public class AgentInitRequest {
public void setMetadata(Map<String, Object> metadata) {
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
}
/**
* 获取媒体引用解析器。
*
* @return 媒体引用解析器
*/
public AgentMediaResolver getMediaResolver() {
return mediaResolver;
}
/**
* 设置媒体引用解析器。
*
* @param mediaResolver 媒体引用解析器
*/
public void setMediaResolver(AgentMediaResolver mediaResolver) {
this.mediaResolver = mediaResolver;
}
}

View File

@@ -35,7 +35,9 @@ public class AgentResumeRequest {
*
* <p>该字段仅供服务端集成层使用。普通调用方不应设置该标记;设置后 runtime 会跳过
* 当前进程内 {@code AgentToolApprovalCoordinator} 的 token 存在性校验,用于服务重启或跨节点后
* 从 AgentScope session 中继续 pending tool。</p>
* 从 AgentScope session 中继续 pending tool。批准请求必须在 metadata 中提供
* {@code toolCallId/toolName/toolInput},多个调用使用 {@code approvedToolCalls} 列表,
* 以便 runtime 将持久化审批结果绑定到实际工具调用。</p>
*/
private boolean trusted;

View File

@@ -31,4 +31,10 @@ public interface AgentRuntime {
* @return 运行事件流
*/
Flux<AgentRuntimeEvent> resume(AgentResumeRequest request);
/**
* 关闭运行器并释放底层资源。
*/
default void close() {
}
}

View File

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

View File

@@ -0,0 +1,24 @@
package com.easyagents.agent.runtime.agentscope;
import io.agentscope.core.formatter.openai.DeepSeekFormatter;
import io.agentscope.core.formatter.openai.dto.OpenAIMessage;
import io.agentscope.core.message.Msg;
import java.util.List;
/**
* 保留 DeepSeek 专用规则并将全部消息 content 规范为内容块数组。
*/
public final class AgentDeepSeekChatFormatter extends DeepSeekFormatter {
/**
* 转换 DeepSeek 消息并在供应商规则之后统一 content 格式。
*
* @param messages AgentScope 消息
* @return OpenAI 请求消息
*/
@Override
protected List<OpenAIMessage> doFormat(List<Msg> messages) {
return AgentOpenAIChatFormatter.normalizeContent(super.doFormat(messages));
}
}

View File

@@ -0,0 +1,24 @@
package com.easyagents.agent.runtime.agentscope;
import io.agentscope.core.formatter.openai.GLMFormatter;
import io.agentscope.core.formatter.openai.dto.OpenAIMessage;
import io.agentscope.core.message.Msg;
import java.util.List;
/**
* 保留 GLM 专用规则并将全部消息 content 规范为内容块数组。
*/
public final class AgentGLMChatFormatter extends GLMFormatter {
/**
* 转换 GLM 消息并在供应商规则之后统一 content 格式。
*
* @param messages AgentScope 消息
* @return OpenAI 请求消息
*/
@Override
protected List<OpenAIMessage> doFormat(List<Msg> messages) {
return AgentOpenAIChatFormatter.normalizeContent(super.doFormat(messages));
}
}

View File

@@ -0,0 +1,132 @@
package com.easyagents.agent.runtime.agentscope;
import com.easyagents.agent.runtime.model.AgentHttpVersionPolicy;
import io.agentscope.core.model.transport.HttpTransport;
import io.agentscope.core.model.transport.HttpTransportConfig;
import io.agentscope.core.model.transport.HttpTransportFactory;
import io.agentscope.core.model.transport.HttpVersion;
import io.agentscope.core.model.transport.JdkHttpTransport;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.net.http.HttpClient;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 按 HTTP 版本策略提供进程级共享的 AgentScope Transport。
*/
public final class AgentHttpTransportProvider {
private static final Logger LOG = LoggerFactory.getLogger(AgentHttpTransportProvider.class);
private static final AgentHttpTransportProvider SHARED = new AgentHttpTransportProvider();
private final Map<AgentHttpVersionPolicy, HttpTransport> transports = new ConcurrentHashMap<>();
/**
* 创建 Transport 提供器。
*/
private AgentHttpTransportProvider() {
}
/**
* 获取进程级共享提供器。
*
* @return 共享提供器
*/
public static AgentHttpTransportProvider shared() {
return SHARED;
}
/**
* 获取指定策略与基础 URL 对应的共享 Transport。
*
* @param policy HTTP 版本策略
* @param baseUrl 最终生效的模型基础 URL
* @return 共享 Transport
*/
public HttpTransport getTransport(AgentHttpVersionPolicy policy, String baseUrl) {
AgentHttpVersionPolicy effectivePolicy = resolveEffectivePolicy(policy, baseUrl);
if (effectivePolicy == AgentHttpVersionPolicy.AUTO) {
return HttpTransportFactory.getDefault();
}
return transports.computeIfAbsent(effectivePolicy, this::createTransport);
}
/**
* 解析请求实际使用的 HTTP 策略。
*
* <p>明文 HTTP 固定使用 HTTP/1.1,避免 JDK 客户端发起 h2c UpgradeHTTPS
* 保持 HTTP/2 优先并允许底层通过 ALPN 回退。显式策略始终覆盖 URL 判断。</p>
*
* @param policy 配置的 HTTP 版本策略
* @param baseUrl 最终生效的模型基础 URL
* @return 实际生效策略;无法识别 URL 时返回 AUTO
*/
public static AgentHttpVersionPolicy resolveEffectivePolicy(AgentHttpVersionPolicy policy, String baseUrl) {
AgentHttpVersionPolicy safePolicy = policy == null ? AgentHttpVersionPolicy.AUTO : policy;
if (safePolicy != AgentHttpVersionPolicy.AUTO) {
return safePolicy;
}
if (baseUrl == null || baseUrl.isBlank()) {
LOG.warn("Agent HTTP AUTO policy cannot infer protocol because base URL is missing; fallback to default transport");
return AgentHttpVersionPolicy.AUTO;
}
try {
String scheme = URI.create(baseUrl.trim()).getScheme();
if (scheme == null) {
return AgentHttpVersionPolicy.AUTO;
}
String normalizedScheme = scheme.toLowerCase(Locale.ROOT);
if ("http".equals(normalizedScheme)) {
return AgentHttpVersionPolicy.HTTP_1_1;
}
if ("https".equals(normalizedScheme)) {
return AgentHttpVersionPolicy.HTTP_2_PREFERRED;
}
LOG.warn("Agent HTTP AUTO policy does not support URL scheme '{}'; fallback to default transport",
normalizedScheme);
return AgentHttpVersionPolicy.AUTO;
} catch (IllegalArgumentException exception) {
LOG.warn("Agent HTTP AUTO policy cannot parse base URL; fallback to default transport");
return AgentHttpVersionPolicy.AUTO;
}
}
/**
* 创建并注册受 AgentScope 生命周期管理的 JDK Transport。
*
* @param policy HTTP 版本策略
* @return 新 Transport
*/
private HttpTransport createTransport(AgentHttpVersionPolicy policy) {
HttpVersion httpVersion = resolveHttpVersion(policy);
HttpTransportConfig config = HttpTransportConfig.builder()
.httpVersion(httpVersion)
.build();
HttpClient httpClient = HttpClient.newBuilder()
.version(httpVersion.toJdkHttpVersion())
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(config.getConnectTimeout())
.build();
HttpTransport transport = new JdkHttpTransport(httpClient, config);
// 注册后由 AgentScope JVM shutdown hook 统一关闭,避免每次 Agent 运行创建连接池。
HttpTransportFactory.register(transport);
return transport;
}
/**
* 将中立策略映射为 AgentScope HTTP 版本。
*
* @param policy HTTP 版本策略
* @return AgentScope HTTP 版本
*/
static HttpVersion resolveHttpVersion(AgentHttpVersionPolicy policy) {
if (policy == AgentHttpVersionPolicy.HTTP_1_1) {
return HttpVersion.HTTP_1_1;
}
return HttpVersion.HTTP_2;
}
}

View File

@@ -0,0 +1,46 @@
package com.easyagents.agent.runtime.agentscope;
import io.agentscope.core.formatter.openai.OpenAIChatFormatter;
import io.agentscope.core.formatter.openai.dto.OpenAIContentPart;
import io.agentscope.core.formatter.openai.dto.OpenAIMessage;
import io.agentscope.core.message.Msg;
import java.util.List;
/**
* 为 OpenAI-compatible 模型补充全部消息 content 内容块数组兼容能力。
*/
public final class AgentOpenAIChatFormatter extends OpenAIChatFormatter {
/**
* 将 AgentScope 消息转换为 OpenAI 消息,并规范全部角色的 content 格式。
*
* @param messages AgentScope 消息
* @return OpenAI 请求消息
*/
@Override
protected List<OpenAIMessage> doFormat(List<Msg> messages) {
return normalizeContent(super.doFormat(messages));
}
/**
* 将 OpenAI 消息中的 content 统一规范为内容块数组。
*
* @param formattedMessages 已完成供应商规则转换的 OpenAI 消息
* @return content 已规范为数组的原消息列表
*/
static List<OpenAIMessage> normalizeContent(List<OpenAIMessage> formattedMessages) {
for (OpenAIMessage message : formattedMessages) {
Object content = message.getContent();
if (content instanceof String text) {
message.setContent(List.of(OpenAIContentPart.text(text)));
} else if (content == null) {
message.setContent(List.of(OpenAIContentPart.text("")));
} else if (!(content instanceof List<?>)) {
throw new IllegalStateException(
"Unsupported OpenAI message content type: " + content.getClass().getName());
}
}
return formattedMessages;
}
}

View File

@@ -2,309 +2,452 @@ package com.easyagents.agent.runtime.agentscope;
import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.AgentRuntimeExecutionContext;
import com.easyagents.agent.runtime.event.*;
import com.easyagents.agent.runtime.knowledge.*;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.rag.Knowledge;
import io.agentscope.core.rag.model.Document;
import io.agentscope.core.rag.model.DocumentMetadata;
import io.agentscope.core.rag.model.RetrieveConfig;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
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.AgentKnowledgeRetrievalRequest;
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.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 {
/**
* 创建聚合 Knowledge
* 根据 Agent 知识库声明创建模型可见工具定义
*
* @param request 运行请求
* @return 聚合 Knowledge未配置知识库时返回 null
* @param context 运行时上下文
* @return 知识库工具定义
* @throws AgentRuntimeException 声明、运行名或 Retriever 绑定不合法时抛出
*/
public Knowledge createAggregateKnowledge(AgentRuntimeExecutionContext request) {
return createAggregateKnowledge(request, (Sinks.Many<AgentRuntimeEvent>) null);
}
/**
* 创建带事件 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;
public List<AgentToolSpec> createToolSpecs(AgentRuntimeExecutionContext context) {
if (context == null || context.getAgentDefinition() == null) {
throw new AgentRuntimeException("Agent runtime context and definition are required for knowledge tools.");
}
return new AggregateKnowledge(request, turnContextHolder);
}
private AgentRuntimeTurnContextHolder fixedHolder(AgentRuntimeExecutionContext request,
Sinks.Many<AgentRuntimeEvent> eventSink) {
AgentRuntimeTurnContextHolder holder = new AgentRuntimeTurnContextHolder();
AgentRuntimeEventBridge bridge = new AgentRuntimeEventBridge(request, holder);
holder.set(new AgentRuntimeTurnContext(null, eventSink, bridge));
return holder;
List<AgentKnowledgeSpec> knowledgeSpecs = context.getAgentDefinition().getKnowledgeSpecs();
List<AgentKnowledgeRegistration> registrations = context.getKnowledgeRegistrations();
if (knowledgeSpecs == null || knowledgeSpecs.isEmpty()) {
if (registrations != null && !registrations.isEmpty()) {
throw new AgentRuntimeException("Knowledge registrations require matching knowledge specs.");
}
return List.of();
}
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 运行时文
* @return AgentScope 文档
* @param context 运行时上下
* @param toolSpecs 知识库工具定义
* @param toolkit AgentScope Toolkit
* @param toolAdapter 中立工具适配器
* @param approvalCoordinator 工具审批协调器
* @param turnContextHolder 当前运行轮次上下文持有器
*/
public List<Document> toDocuments(List<AgentKnowledgeDocument> documents) {
List<Document> converted = new ArrayList<>();
public void registerTools(AgentRuntimeExecutionContext context,
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) {
return converted;
return finalDocuments;
}
for (AgentKnowledgeDocument document : documents) {
converted.add(toDocument(document));
if (document == null || !passesThreshold(document, knowledgeSpec.getScoreThreshold())) {
continue;
}
preserveKnowledgeMetadata(knowledgeSpec, document);
finalDocuments.add(document);
}
return converted;
}
private Document toDocument(AgentKnowledgeDocument document) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("documentId", document.getDocumentId());
payload.put("documentName", document.getDocumentName());
payload.put("chunkId", document.getChunkId());
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;
finalDocuments.sort(Comparator.comparing(
AgentKnowledgeDocument::getScore,
Comparator.nullsLast(Comparator.reverseOrder())));
int limit = Math.max(knowledgeSpec.getLimit(), 1);
if (finalDocuments.size() > limit) {
return new ArrayList<>(finalDocuments.subList(0, limit));
}
return finalDocuments;
}
/**
* 获取 AgentScope 要求的非空文档 ID
* 判断文档最终分数是否达到绑定阈值
*
* @param document 知识文档
* @return 非空文档 ID
* @param document 检索文档
* @param scoreThreshold 分数阈值
* @return 达到阈值时为 true
*/
private String safeDocumentId(AgentKnowledgeDocument document) {
if (document.getDocumentId() != null && !document.getDocumentId().isBlank()) {
return document.getDocumentId();
private boolean passesThreshold(AgentKnowledgeDocument document, double scoreThreshold) {
if (scoreThreshold <= 0D) {
return true;
}
if (document.getChunkId() != null && !document.getChunkId().isBlank()) {
return document.getChunkId();
}
return "knowledge-document";
return document.getScore() != null && document.getScore() >= scoreThreshold;
}
/**
* 获取 AgentScope 要求的非空分片 ID
* 将知识库归属信息合并到文档元数据中
*
* @param document 知识文档
* @return 非空分片 ID
* @param knowledgeSpec 知识库声明
* @param document 检索文档
*/
private String safeChunkId(AgentKnowledgeDocument document) {
if (document.getChunkId() != null && !document.getChunkId().isBlank()) {
return document.getChunkId();
}
if (document.getDocumentId() != null && !document.getDocumentId().isBlank()) {
return document.getDocumentId();
}
return "0";
private void preserveKnowledgeMetadata(AgentKnowledgeSpec knowledgeSpec,
AgentKnowledgeDocument document) {
Map<String, Object> knowledgeMetadata = new LinkedHashMap<>(knowledgeSpec.getMetadata());
knowledgeMetadata.put("knowledgeId", knowledgeSpec.getKnowledgeId());
knowledgeMetadata.put("knowledgeName", knowledgeSpec.getName());
knowledgeMetadata.put("knowledgeRuntimeName", knowledgeSpec.getRuntimeName());
knowledgeMetadata.putAll(document.getKnowledgeMetadata());
document.setKnowledgeMetadata(knowledgeMetadata);
document.getMetadata().putIfAbsent("knowledgeId", knowledgeSpec.getKnowledgeId());
document.getMetadata().putIfAbsent("knowledgeName", knowledgeSpec.getName());
}
/**
* 获取 AgentScope 要求的非空文档内容
* 创建与模型最终证据一致的知识库检索事件
*
* @param document 知识文档
* @return 文档内容
* @param toolContext 工具执行上下文
* @param knowledgeSpec 知识库声明
* @param retrievalRequest 检索请求
* @param documents 最终文档
* @return 检索旁路事件
*/
private String safeContent(AgentKnowledgeDocument document) {
return document.getContent() == null ? "" : document.getContent();
private AgentRuntimeEvent retrievalEvent(AgentToolContext toolContext,
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 final AgentRuntimeExecutionContext request;
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);
}
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;
}
/**
* 格式化模型可见的结构化检索证据。
*
* @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

@@ -5,7 +5,9 @@ import io.agentscope.core.message.*;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -15,6 +17,9 @@ import java.util.Map;
*/
public class AgentScopeMessageAdapter {
/** AgentScope URLSource 中用于持久化业务媒体引用的私有协议。 */
public static final String MEDIA_REFERENCE_SCHEME = "easyagents-media://";
/**
* 将运行时消息转换为 AgentScope 消息。
*
@@ -276,6 +281,11 @@ public class AgentScopeMessageAdapter {
.data(block.getData())
.build();
}
if (block.getReference() != null && !block.getReference().isBlank()) {
String encodedReference = Base64.getUrlEncoder().withoutPadding()
.encodeToString(block.getReference().getBytes(StandardCharsets.UTF_8));
return URLSource.builder().url(MEDIA_REFERENCE_SCHEME + encodedReference).build();
}
return URLSource.builder().url(block.getUrl()).build();
}
@@ -286,7 +296,30 @@ public class AgentScopeMessageAdapter {
return;
}
if (source instanceof URLSource urlSource) {
block.setUrl(urlSource.getUrl());
String url = urlSource.getUrl();
if (url != null && url.startsWith(MEDIA_REFERENCE_SCHEME)) {
block.setReference(decodeReference(url));
} else {
block.setUrl(url);
}
}
}
/**
* 从 AgentScope 私有 URLSource 解码业务媒体引用。
*
* @param url 私有协议 URL
* @return 原始业务媒体引用
*/
public static String decodeReference(String url) {
if (url == null || !url.startsWith(MEDIA_REFERENCE_SCHEME)) {
return null;
}
String encoded = url.substring(MEDIA_REFERENCE_SCHEME.length());
try {
return new String(Base64.getUrlDecoder().decode(encoded), StandardCharsets.UTF_8);
} catch (IllegalArgumentException error) {
throw new IllegalArgumentException("Invalid agent media reference.", error);
}
}

View File

@@ -2,9 +2,11 @@ package com.easyagents.agent.runtime.agentscope;
import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.model.AgentGenerationOptions;
import com.easyagents.agent.runtime.model.AgentHttpVersionPolicy;
import com.easyagents.agent.runtime.model.AgentModelFactory;
import com.easyagents.agent.runtime.model.AgentModelProviderType;
import com.easyagents.agent.runtime.model.AgentModelSpec;
import com.easyagents.agent.runtime.model.AgentMessageContentFormat;
import io.agentscope.core.formatter.openai.DeepSeekFormatter;
import io.agentscope.core.formatter.openai.GLMFormatter;
import io.agentscope.core.model.*;
@@ -24,6 +26,24 @@ public class AgentScopeModelFactory implements AgentModelFactory<Model> {
private static final String ARK_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
private static final String SILICONFLOW_BASE_URL = "https://api.siliconflow.cn/v1";
private final AgentHttpTransportProvider httpTransportProvider;
/**
* 使用进程级共享 Transport 提供器创建模型工厂。
*/
public AgentScopeModelFactory() {
this(AgentHttpTransportProvider.shared());
}
/**
* 使用指定 Transport 提供器创建模型工厂。
*
* @param httpTransportProvider Transport 提供器
*/
AgentScopeModelFactory(AgentHttpTransportProvider httpTransportProvider) {
this.httpTransportProvider = httpTransportProvider;
}
@Override
public Model create(AgentModelSpec modelSpec, AgentGenerationOptions generationOptions) {
if (modelSpec == null) {
@@ -31,6 +51,7 @@ public class AgentScopeModelFactory implements AgentModelFactory<Model> {
}
GenerateOptions options = toGenerateOptions(modelSpec, generationOptions);
AgentModelProviderType providerType = modelSpec.getProviderType();
validateHttpTransportSupport(providerType, modelSpec.getHttpVersionPolicy());
if (providerType == AgentModelProviderType.OLLAMA) {
return buildOllama(modelSpec, options);
}
@@ -124,13 +145,18 @@ public class AgentScopeModelFactory implements AgentModelFactory<Model> {
* @return 模型
*/
private Model buildOpenAiCompatible(AgentModelSpec modelSpec, GenerateOptions options, String defaultBaseUrl) {
String baseUrl = resolveBaseUrl(modelSpec, defaultBaseUrl);
OpenAIChatModel.Builder builder = OpenAIChatModel.builder()
.apiKey(modelSpec.getApiKey())
.modelName(modelSpec.getModelName())
.baseUrl(resolveBaseUrl(modelSpec, defaultBaseUrl))
.baseUrl(baseUrl)
.endpointPath(modelSpec.getEndpointPath())
.stream(Boolean.TRUE.equals(options.getStream()))
.httpTransport(httpTransportProvider.getTransport(modelSpec.getHttpVersionPolicy(), baseUrl))
.generateOptions(options);
if (modelSpec.getMessageContentFormat() == AgentMessageContentFormat.TEXT_PARTS) {
builder.formatter(new AgentOpenAIChatFormatter());
}
return builder.build();
}
@@ -176,13 +202,17 @@ public class AgentScopeModelFactory implements AgentModelFactory<Model> {
* @return 模型
*/
private Model buildDeepSeek(AgentModelSpec modelSpec, GenerateOptions options) {
String baseUrl = resolveBaseUrl(modelSpec, DEEPSEEK_BASE_URL);
OpenAIChatModel.Builder builder = OpenAIChatModel.builder()
.apiKey(modelSpec.getApiKey())
.modelName(modelSpec.getModelName())
.baseUrl(resolveBaseUrl(modelSpec, DEEPSEEK_BASE_URL))
.baseUrl(baseUrl)
.endpointPath(modelSpec.getEndpointPath())
.stream(Boolean.TRUE.equals(options.getStream()))
.formatter(new DeepSeekFormatter())
.httpTransport(httpTransportProvider.getTransport(modelSpec.getHttpVersionPolicy(), baseUrl))
.formatter(modelSpec.getMessageContentFormat() == AgentMessageContentFormat.TEXT_PARTS
? new AgentDeepSeekChatFormatter()
: new DeepSeekFormatter())
.generateOptions(options);
return builder.build();
}
@@ -195,13 +225,17 @@ public class AgentScopeModelFactory implements AgentModelFactory<Model> {
* @return 模型
*/
private Model buildGlm(AgentModelSpec modelSpec, GenerateOptions options) {
String baseUrl = resolveBaseUrl(modelSpec, GLM_BASE_URL);
OpenAIChatModel.Builder builder = OpenAIChatModel.builder()
.apiKey(modelSpec.getApiKey())
.modelName(modelSpec.getModelName())
.baseUrl(resolveBaseUrl(modelSpec, GLM_BASE_URL))
.baseUrl(baseUrl)
.endpointPath(modelSpec.getEndpointPath())
.stream(Boolean.TRUE.equals(options.getStream()))
.formatter(new GLMFormatter())
.httpTransport(httpTransportProvider.getTransport(modelSpec.getHttpVersionPolicy(), baseUrl))
.formatter(modelSpec.getMessageContentFormat() == AgentMessageContentFormat.TEXT_PARTS
? new AgentGLMChatFormatter()
: new GLMFormatter())
.generateOptions(options);
return builder.build();
}
@@ -231,16 +265,39 @@ public class AgentScopeModelFactory implements AgentModelFactory<Model> {
*/
private Model buildDashScope(AgentModelSpec modelSpec, AgentGenerationOptions generationOptions, GenerateOptions options) {
Boolean thinkingEnabled = generationOptions == null ? null : generationOptions.getThinkingEnabled();
String baseUrl = modelSpec.getBaseUrl();
return DashScopeChatModel.builder()
.apiKey(modelSpec.getApiKey())
.modelName(modelSpec.getModelName())
.baseUrl(modelSpec.getBaseUrl())
.baseUrl(baseUrl)
.stream(Boolean.TRUE.equals(options.getStream()))
.enableThinking(thinkingEnabled)
.httpTransport(httpTransportProvider.getTransport(modelSpec.getHttpVersionPolicy(), baseUrl))
.defaultOptions(options)
.build();
}
/**
* 校验当前 Provider 是否支持显式 Agent HTTP Transport。
*
* @param providerType 模型供应商类型
* @param policy HTTP 版本策略
*/
private void validateHttpTransportSupport(AgentModelProviderType providerType,
AgentHttpVersionPolicy policy) {
if (policy == null || policy == AgentHttpVersionPolicy.AUTO) {
return;
}
if (providerType == AgentModelProviderType.ANTHROPIC
|| providerType == AgentModelProviderType.GEMINI
|| providerType == AgentModelProviderType.OLLAMA) {
throw new AgentRuntimeException(
"Agent HTTP transport policy " + policy
+ " is not supported by provider " + providerType
+ "; use AUTO for this provider.");
}
}
/**
* 优先使用调用方传入的基础 URL未传入时使用供应商默认地址。
*

View File

@@ -3,18 +3,25 @@ package com.easyagents.agent.runtime.agentscope;
import com.easyagents.agent.runtime.*;
import com.easyagents.agent.runtime.event.*;
import com.easyagents.agent.runtime.event.interceptor.AutoContextInterceptor;
import com.easyagents.agent.runtime.event.interceptor.MediaReferenceInterceptor;
import com.easyagents.agent.runtime.event.interceptor.ToolHitlInterceptor;
import com.easyagents.agent.runtime.event.observer.AgentRuntimeErrorObserver;
import com.easyagents.agent.runtime.event.observer.ReasoningLifecycleObserver;
import com.easyagents.agent.runtime.event.observer.SkillExecutionObserver;
import com.easyagents.agent.runtime.event.observer.ToolExecutionObserver;
import com.easyagents.agent.runtime.hitl.AgentPendingState;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalCoordinator;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalResolution;
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.HeuristicKnowledgeCitationMatcher;
import com.easyagents.agent.runtime.message.*;
import com.easyagents.agent.runtime.mcp.McpRegistration;
import com.easyagents.agent.runtime.mcp.McpSkillRegistration;
import com.easyagents.agent.runtime.mcp.McpSpecValidator;
import com.easyagents.agent.runtime.mcp.McpToolkitAdapter;
import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore;
import com.easyagents.agent.runtime.prompt.SystemPromptComposer;
import com.easyagents.agent.runtime.skill.AgentSkillBinding;
import com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext;
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
@@ -28,14 +35,12 @@ import io.agentscope.core.memory.Memory;
import io.agentscope.core.memory.autocontext.AutoContextMemory;
import io.agentscope.core.message.*;
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.skill.SkillBox;
import io.agentscope.core.state.SessionKey;
import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.Toolkit;
import io.agentscope.core.tool.mcp.McpClientWrapper;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Sinks;
@@ -57,6 +62,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
private final AgentScopeSkillAdapter skillAdapter;
private final AgentScopeMessageAdapter messageAdapter;
private final AgentOperateToolAdapter operateToolAdapter = new AgentOperateToolAdapter();
private final McpToolkitAdapter mcpToolkitAdapter = new McpToolkitAdapter();
private final AgentKnowledgeCitationMatcher citationMatcher = new HeuristicKnowledgeCitationMatcher();
private final AtomicBoolean initialized = new AtomicBoolean(false);
private final AtomicBoolean running = new AtomicBoolean(false);
@@ -68,6 +74,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
private Session session;
private SessionKey sessionKey;
private ReActAgent agent;
private final List<McpClientWrapper> mcpClients = new CopyOnWriteArrayList<>();
/**
* 使用默认适配器创建运行时。
@@ -112,15 +119,33 @@ public class AgentScopeReActRuntime implements AgentRuntime {
if (!initialized.compareAndSet(false, true)) {
throw new AgentRuntimeException("Agent runtime has already been initialized.");
}
this.initRequest = request;
this.runtimeContext = createRuntimeContext(request);
this.skillContext = AgentSkillRuntimeContext.from(request.getAgentDefinition().getSkillBoxSpec());
this.approvalCoordinator = AgentToolApprovalCoordinator.enabled();
this.turnContextHolder = new AgentRuntimeTurnContextHolder();
this.session = new AgentScopeSessionAdapter(request.getSessionStore());
this.sessionKey = AgentScopeSessionAdapter.sessionKey(request.getSessionId());
this.agent = buildAgent(runtimeContext);
this.agent.loadIfExists(session, sessionKey);
try {
this.initRequest = request;
this.runtimeContext = createRuntimeContext(request);
this.skillContext = AgentSkillRuntimeContext.from(request.getAgentDefinition().getSkillBoxSpec());
this.approvalCoordinator = AgentToolApprovalCoordinator.enabled();
this.turnContextHolder = new AgentRuntimeTurnContextHolder();
this.session = new AgentScopeSessionAdapter(request.getSessionStore());
this.sessionKey = AgentScopeSessionAdapter.sessionKey(request.getSessionId());
this.agent = buildAgent(runtimeContext);
this.agent.loadIfExists(session, sessionKey);
} catch (RuntimeException error) {
closeMcpClients();
initialized.set(false);
throw error;
}
}
/**
* 关闭运行时并释放 MCP client。
*/
@Override
public void close() {
if (approvalCoordinator != null) {
approvalCoordinator.cancelAll("Agent runtime has been closed.");
}
closeMcpClients();
initialized.set(false);
}
/**
@@ -154,22 +179,32 @@ public class AgentScopeReActRuntime implements AgentRuntime {
return Flux.error(new AgentRuntimeException("Agent runtime is already streaming."));
}
AgentRuntimeExecutionContext executionContext = createResumeExecutionContext(request);
AgentToolApprovalResolution resolution = null;
try {
if (!request.isTrusted()) {
approvalCoordinator.consume(request);
if (request.isTrusted()) {
approvalCoordinator.authorizeTrustedExecution(request);
} else {
resolution = approvalCoordinator.resolve(request);
}
} catch (RuntimeException error) {
running.set(false);
throw error;
}
// 审批拒绝
if (!request.isApproved()) {
executionContext.setCancelReason(request.getRejectReason());
if (resolution != null
&& resolution.getStatus() == AgentToolApprovalResolution.Status.WAITING) {
return waitingForRemainingApprovals(executionContext, resolution);
}
if (!request.isApproved()
|| resolution != null
&& (resolution.getStatus() == AgentToolApprovalResolution.Status.REJECTED
|| resolution.getStatus() == AgentToolApprovalResolution.Status.EXPIRED)) {
String cancelReason = resolution == null ? request.getRejectReason() : resolution.getReason();
executionContext.setCancelReason(cancelReason);
return Flux.defer(() -> {
saveSession();
return Flux.just(started(executionContext), cancelled(executionContext));
}).doOnNext(event -> executionContext.getConversationRecorder().record(executionContext, event))
.doFinally(signalType -> cleanupTurn());
.doFinally(signalType -> cleanupStreamSegment(false));
}
return runAgentStreamAfterLock(executionContext, List::of);
});
@@ -189,6 +224,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
if (!running.compareAndSet(false, true)) {
return Flux.error(new AgentRuntimeException("Agent runtime is already streaming."));
}
// 新用户消息建立新的 Turn上一 Turn 的 MCP 级批准不能跨轮复用。
approvalCoordinator.clearReusableApprovalScopes();
return runAgentStreamAfterLock(executionContext, inputSupplier);
});
}
@@ -206,18 +243,15 @@ public class AgentScopeReActRuntime implements AgentRuntime {
AtomicReference<AgentMessage> finalMessage = new AtomicReference<>();
// HITL 暂停事件。被设置后,本轮以 SUSPENDED 挂起而不是 COMPLETED 结束。
AtomicReference<AgentRuntimeEvent> suspendedEvent = new AtomicReference<>();
// 本轮 HITL 待审批项来自旁路交互事件,最终会合并进 SUSPENDED 挂起事件。
List<Map<String, Object>> pendingApprovals = new CopyOnWriteArrayList<>();
// 知识库引注。
Map<String, AgentKnowledgeReference> knowledgeReferences = new LinkedHashMap<>();
// 流式输出归一化,防止出现累计快照的重复输出
// 按 AgentScope 增量协议累计正文,并仅在终态快照到达时消除已发送前缀
StreamDeltaNormalizer deltaNormalizer = new StreamDeltaNormalizer();
// 取消输出标记。
AtomicBoolean cancelled = new AtomicBoolean(false);
// 旁线路监察事件流式输出。
Flux<AgentRuntimeEvent> sideEventFlux = sideEvents.asFlux()
.doOnNext(event -> updateKnowledgeReferences(knowledgeReferences, event))
.doOnNext(event -> updatePendingApprovals(pendingApprovals, event));
.doOnNext(event -> updateKnowledgeReferences(knowledgeReferences, event));
// 主线路 agent 交互。resume 场景会传入空列表,让 AgentScope 从 pending tool 继续执行。
Flux<AgentRuntimeEvent> mainEventFlux = agent.stream(inputSupplier.get(), streamOptions())
.timeout(executionContext.getAgentDefinition().getExecutionOptions().getTimeout())
@@ -237,9 +271,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
.concatWith(Flux.defer(() -> {
AgentRuntimeEvent suspended = suspendedEvent.get();
if (suspended != null) {
// 触发 hitl 审批事件,暂时挂起
suspended.getPayload().put("pendingApprovals", pendingApprovals);
return Flux.just(suspended);
// SUSPENDED 已在主线路中输出,结束阶段不再重复发送
return Flux.empty();
}
return Flux.just(completed(executionContext, finalText.toString(),
finalMessage.get(), knowledgeReferences));
@@ -251,8 +284,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
.doOnNext(event -> executionContext.getConversationRecorder().record(executionContext, event))
// 处理中断请求
.doOnCancel(() -> cancelInternal(executionContext, sideEvents, finalText, finalMessage, cancelled))
// 释放运行锁并清掉 turn context
.doFinally(signalType -> cleanupTurn());
// HITL 挂起时保留当前 Turn 的 MCP 批准,其余终态完整清理
.doFinally(signalType -> cleanupStreamSegment(suspendedEvent.get() != null));
}
/**
@@ -270,6 +303,12 @@ public class AgentScopeReActRuntime implements AgentRuntime {
if (userMessage.getContentBlocks() == null || userMessage.getContentBlocks().isEmpty()) {
throw new AgentRuntimeException("Agent user message content is required.");
}
boolean containsImage = userMessage.getContentBlocks().stream()
.anyMatch(block -> block instanceof AgentMediaBlock mediaBlock
&& "image".equalsIgnoreCase(mediaBlock.getMediaKind()));
if (containsImage && !initRequest.getAgentDefinition().getModelSpec().isSupportImage()) {
throw new AgentRuntimeException("The configured model does not support image input.");
}
}
/**
@@ -306,7 +345,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
context.setRuntimeContext(runtimeContext.getRuntimeContext());
context.setUserMessage(userMessage);
context.setToolInvokers(runtimeContext.getToolInvokers());
context.setKnowledgeRetrievers(runtimeContext.getKnowledgeRetrievers());
context.setKnowledgeRegistrations(runtimeContext.getKnowledgeRegistrations());
context.setSessionStore(runtimeContext.getSessionStore());
context.setConversationRecorder(runtimeContext.getConversationRecorder());
context.setMetadata(runtimeContext.getMetadata());
@@ -327,7 +366,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
context.setAgentDefinition(runtimeContext.getAgentDefinition());
context.setRuntimeContext(runtimeContext.getRuntimeContext());
context.setToolInvokers(runtimeContext.getToolInvokers());
context.setKnowledgeRetrievers(runtimeContext.getKnowledgeRetrievers());
context.setKnowledgeRegistrations(runtimeContext.getKnowledgeRegistrations());
context.setSessionStore(runtimeContext.getSessionStore());
context.setConversationRecorder(runtimeContext.getConversationRecorder());
Map<String, Object> metadata = new LinkedHashMap<>(runtimeContext.getMetadata());
@@ -502,9 +541,10 @@ public class AgentScopeReActRuntime implements AgentRuntime {
if (sourceEvent.getMessage() != null) {
event.setMessage(messageAdapter.toAgentMessage(sourceEvent.getMessage()));
}
event.getPayload().put("reason", context.getMetadata().getOrDefault("hitlSuspendReason", "TOOL_APPROVAL_REQUIRED"));
Object pendingApprovals = context.getMetadata().get("hitlPendingApprovals");
event.getPayload().put("pendingApprovals", pendingApprovals instanceof List<?> list ? list : List.of());
event.getPayload().put("reason", "TOOL_APPROVAL_REQUIRED");
event.getPayload().put("pendingApprovals", approvalCoordinator.pendingStates(context.getSessionId()).stream()
.map(this::pendingApprovalPayload)
.toList());
event.getMetadata().put("source", "AGENTSCOPE_STREAM");
event.getMetadata().put("generateReason", sourceEvent.getMessage() == null
? GenerateReason.REASONING_STOP_REQUESTED.name()
@@ -512,6 +552,46 @@ public class AgentScopeReActRuntime implements AgentRuntime {
return event;
}
/**
* 在同一审批批次仍有未决工具时返回挂起事件,并保持 AgentScope pending tools 不执行。
*
* @param context 本轮恢复上下文
* @param resolution 审批批次决议
* @return 开始与挂起事件流
*/
private Flux<AgentRuntimeEvent> waitingForRemainingApprovals(AgentRuntimeExecutionContext context,
AgentToolApprovalResolution resolution) {
AgentRuntimeEvent suspended = base(context, AgentRuntimeEventType.SUSPENDED);
suspended.getPayload().put("reason", "TOOL_APPROVAL_REQUIRED");
suspended.getPayload().put("pendingApprovals", resolution.getRemainingStates().stream()
.map(this::pendingApprovalPayload)
.toList());
suspended.getMetadata().put("source", "APPROVAL_COORDINATOR");
suspended.getMetadata().put("approvalStatus", AgentToolApprovalResolution.Status.WAITING.name());
return Flux.just(started(context), suspended)
.doOnNext(event -> context.getConversationRecorder().record(context, event))
.doFinally(signalType -> cleanupStreamSegment(true));
}
/**
* 将待审批状态转换为前端可消费的稳定字段。
*
* @param state 待审批状态
* @return 待审批载荷
*/
private Map<String, Object> pendingApprovalPayload(AgentPendingState state) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("resumeToken", state.getResumeToken().getValue());
payload.put("toolCallId", state.getToolCallId());
payload.put("toolName", state.getToolName());
payload.put("toolInput", state.getToolInput());
payload.put("input", state.getToolInput());
payload.put("approvalPrompt", state.getApprovalPrompt());
payload.put("approvalMetadata", state.getMetadata());
payload.put("expiresAt", state.getExpiresAt() == null ? null : state.getExpiresAt().toString());
return payload;
}
/**
* 生成开始事件。
*
@@ -594,6 +674,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
saveSession();
return Flux.just(cancelled(context));
}
saveSession();
return Flux.just(failed(context, error));
}
@@ -638,7 +719,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
* 将取消前已输出的助手内容补写入 AgentScope memory 并保存 session。
*
* <p>AgentScope 的正常完成路径会自行把最终助手消息写入 memory。取消订阅时不会触发
* 完成路径,因此这里仅在已有非空助手内容时补写一次,确保下一轮对话能拿到中断前上下文。</p>
* 完成路径,因此这里仅在已有非空助手内容时补写一次,并始终保存已经进入 memory 的
* 用户消息,确保下一轮对话能拿到中断前上下文。</p>
*
* @param finalText 当前已累计的助手文本
* @param finalMessage 当前已捕获的结构化助手消息
@@ -646,10 +728,9 @@ public class AgentScopeReActRuntime implements AgentRuntime {
private void persistPartialAssistantOnCancel(StringBuilder finalText,
AtomicReference<AgentMessage> finalMessage) {
AgentMessage partialMessage = partialAssistantMessage(finalText, finalMessage);
if (partialMessage == null) {
return;
if (partialMessage != null) {
agent.getMemory().addMessage(messageAdapter.toMsg(partialMessage));
}
agent.getMemory().addMessage(messageAdapter.toMsg(partialMessage));
saveSession();
}
@@ -699,9 +780,15 @@ public class AgentScopeReActRuntime implements AgentRuntime {
}
/**
* 清理本轮状态。
* 清理一次 stream/resume 片段状态。
*
* @param preserveReusableApprovalScopes 是否因 HITL 挂起而保留当前 Turn 的 MCP 批准
*/
private void cleanupTurn() {
private void cleanupStreamSegment(boolean preserveReusableApprovalScopes) {
approvalCoordinator.clearExecutionAuthorizations();
if (!preserveReusableApprovalScopes) {
approvalCoordinator.clearReusableApprovalScopes();
}
turnContextHolder.clear();
running.set(false);
}
@@ -782,26 +869,6 @@ public class AgentScopeReActRuntime implements AgentRuntime {
}
}
/**
* 从工具审批旁路事件中收集本轮待审批项。
*
* @param pendingApprovals 待审批项集合
* @param event 运行时事件
*/
private void updatePendingApprovals(List<Map<String, Object>> pendingApprovals, AgentRuntimeEvent event) {
if (event == null || event.getEventType() != AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) {
return;
}
Map<String, Object> approval = new LinkedHashMap<>();
approval.put("resumeToken", event.getPayload().get("resumeToken"));
approval.put("toolCallId", event.getPayload().get("toolCallId"));
approval.put("toolName", event.getPayload().get("toolName"));
approval.put("toolInput", event.getPayload().get("toolInput"));
approval.put("expiresAt", event.getPayload().get("expiresAt"));
approval.put("approvalPrompt", event.getPayload().get("approvalPrompt"));
pendingApprovals.add(approval);
}
/**
* 从知识库旁路事件中收集本轮候选引用。
*
@@ -925,12 +992,12 @@ public class AgentScopeReActRuntime implements AgentRuntime {
/**
* 将 AgentScope 可能输出的累计快照归一化为增量。
*
* <p>主线路 mapper 要尽量保持 AgentScope 原始顺序,但不同模型或底层适配器可能
* 输出累计文本。该归一化器只修正同一 message/block 的文本增量,不触碰旁路事件。</p>
* <p>当前运行时显式使用 {@code incremental(true)}。普通事件携带新增文本,必须原样保留;
* {@code last=true} 的终态事件才可能携带完整快照,此时只发送尚未输出的尾部。</p>
*/
private static final class StreamDeltaNormalizer {
private final Map<String, String> previousValues = new LinkedHashMap<>();
private final Map<String, StringBuilder> emittedValues = new LinkedHashMap<>();
/**
* 归一化流式事件。
@@ -957,10 +1024,14 @@ public class AgentScopeReActRuntime implements AgentRuntime {
return;
}
String key = streamKey(event, payloadKey);
String previousText = previousValues.get(key);
previousValues.put(key, currentText);
if (previousText != null && !previousText.isEmpty() && currentText.startsWith(previousText)) {
event.getPayload().put(payloadKey, currentText.substring(previousText.length()));
boolean last = Boolean.TRUE.equals(event.getPayload().get("last"));
if (!last) {
emittedValues.computeIfAbsent(key, ignored -> new StringBuilder()).append(currentText);
return;
}
StringBuilder emitted = emittedValues.remove(key);
if (emitted != null && currentText.startsWith(emitted.toString())) {
event.getPayload().put(payloadKey, currentText.substring(emitted.length()));
}
}
@@ -1024,7 +1095,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
context.setAgentDefinition(request.getAgentDefinition());
context.setRuntimeContext(request.getRuntimeContext());
context.setToolInvokers(request.getToolInvokers());
context.setKnowledgeRetrievers(request.getKnowledgeRetrievers());
context.setKnowledgeRegistrations(request.getKnowledgeRegistrations());
context.setMemorySnapshot(request.getMemorySnapshot());
context.setSessionStore(request.getSessionStore());
context.setConversationRecorder(request.getConversationRecorder());
context.setMetadata(request.getMetadata());
@@ -1043,10 +1115,11 @@ public class AgentScopeReActRuntime implements AgentRuntime {
Toolkit toolkit = new Toolkit();
AgentScopeToolkitBuildResult toolkitBuildResult = buildToolkit(context, toolkit);
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();
Knowledge knowledge = knowledgeAdapter.createAggregateKnowledge(context, turnContextHolder);
SkillBox skillBox = skillAdapter.createSkillBox(definition.getSkillBoxSpec(), toolkit, skillTools);
SkillBox skillBox = skillAdapter.createSkillBox(definition.getSkillBoxSpec(), toolkit, skillTools,
toolkitBuildResult.skillMcpRegistrations());
// AutoContextInterceptor 是官方 AutoContextHook 的替代实现。这里仍只注册统一 runtime hook
// 避免官方 hook 与 Easy-Agents interceptor 同时触发压缩和 inputMessages 改写。
AgentRuntimeEventBridge eventBridge = new AgentRuntimeEventBridge(context, turnContextHolder);
@@ -1054,12 +1127,18 @@ public class AgentScopeReActRuntime implements AgentRuntime {
if (memory instanceof AutoContextMemory) {
interceptors.add(new AutoContextInterceptor(eventBridge, memoryResult.getAutoContextConfig()));
}
interceptors.add(new MediaReferenceInterceptor(initRequest.getMediaResolver()));
List<AgentToolSpec> runtimeToolSpecs = mergeToolSpecs(
definition.getToolSpecs(),
toolkitBuildResult.knowledgeToolSpecs(),
toolkitBuildResult.mcpToolSpecs(),
toolkitBuildResult.operateToolSpecs());
interceptors.add(new ToolHitlInterceptor(eventBridge, approvalCoordinator,
mergeToolSpecs(definition.getToolSpecs(), toolkitBuildResult.operateToolSpecs())));
runtimeToolSpecs));
// 注册旁路事件监听器与主线路干预器。观察器只发旁路事件,不修改 AgentScope HookEvent。
List<AgentRuntimeObserver> observers = new ArrayList<>();
observers.add(new SkillExecutionObserver(eventBridge, skillContext, skillBox));
observers.add(new ToolExecutionObserver(eventBridge, skillContext));
observers.add(new ToolExecutionObserver(eventBridge, skillContext, runtimeToolSpecs));
observers.add(new ReasoningLifecycleObserver(eventBridge));
observers.add(new AgentRuntimeErrorObserver(eventBridge));
AgentRuntimeObservationManager observationManager =
@@ -1067,7 +1146,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
ReActAgent.Builder builder = ReActAgent.builder()
.name(definition.getAgentName())
.description(definition.getDescription())
.sysPrompt(definition.getSystemPrompt())
.sysPrompt(SystemPromptComposer.compose(definition))
.model(model)
.toolkit(toolkit)
.memory(memory)
@@ -1076,11 +1155,6 @@ public class AgentScopeReActRuntime implements AgentRuntime {
.hook(new AgentScopeRuntimeHook(observationManager))
.enablePendingToolRecovery(true)
.statePersistence(AgentScopeSessionAdapter.toStatePersistence(definition.getPersistencePolicy()));
if (knowledge != null) {
builder.knowledge(knowledge)
.ragMode(RAGMode.AGENTIC)
.retrieveConfig(defaultRetrieveConfig(definition));
}
if (skillBox != null) {
builder.skillBox(skillBox);
}
@@ -1098,8 +1172,11 @@ public class AgentScopeReActRuntime implements AgentRuntime {
Toolkit toolkit) {
Map<String, List<AgentTool>> skillTools = new LinkedHashMap<>();
if (!context.getAgentDefinition().getExecutionOptions().isToolCallingEnabled()) {
return new AgentScopeToolkitBuildResult(skillTools, List.of());
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()) {
AgentToolInvoker invoker = context.getToolInvokers().get(toolSpec.getName());
AgentSkillBinding skillBinding = skillContext.getToolBinding(toolSpec.getName());
@@ -1111,16 +1188,44 @@ public class AgentScopeReActRuntime implements AgentRuntime {
skillTools.computeIfAbsent(skillBinding.getSkillId(), key -> new ArrayList<>()).add(agentTool);
}
}
knowledgeAdapter.registerTools(context, knowledgeToolSpecs, toolkit, toolAdapter,
approvalCoordinator, turnContextHolder);
McpRegistration mcpRegistration = mcpToolkitAdapter.register(
context.getAgentDefinition().getMcpSpecs(), toolkit);
mcpClients.addAll(mcpRegistration.getClients());
List<AgentToolSpec> operateToolSpecs = operateToolAdapter.register(
context.getAgentDefinition().getOperateToolSpecs(), toolkit);
return new AgentScopeToolkitBuildResult(skillTools, operateToolSpecs);
McpSpecValidator.validateToolConflicts(context.getAgentDefinition().getToolSpecs(),
mcpRegistration.getToolSpecs(), context.getAgentDefinition().getOperateToolSpecs());
validateRuntimeToolConflicts(context.getAgentDefinition().getToolSpecs(), knowledgeToolSpecs,
mcpRegistration.getToolSpecs(), operateToolSpecs);
return new AgentScopeToolkitBuildResult(skillTools, knowledgeToolSpecs,
mcpRegistration.getToolSpecs(), operateToolSpecs, mcpRegistration.getSkillRegistrations());
}
private List<AgentToolSpec> mergeToolSpecs(List<AgentToolSpec> toolSpecs, List<AgentToolSpec> operateToolSpecs) {
/**
* 合并所有运行时工具声明供统一治理与事件展示使用。
*
* @param toolSpecs 普通工具声明
* @param knowledgeToolSpecs 知识库工具声明
* @param mcpToolSpecs MCP 工具声明
* @param operateToolSpecs 操作工具声明
* @return 保持注册顺序的工具声明列表
*/
private List<AgentToolSpec> mergeToolSpecs(List<AgentToolSpec> toolSpecs,
List<AgentToolSpec> knowledgeToolSpecs,
List<AgentToolSpec> mcpToolSpecs,
List<AgentToolSpec> operateToolSpecs) {
List<AgentToolSpec> merged = new ArrayList<>();
if (toolSpecs != null) {
merged.addAll(toolSpecs);
}
if (knowledgeToolSpecs != null) {
merged.addAll(knowledgeToolSpecs);
}
if (mcpToolSpecs != null) {
merged.addAll(mcpToolSpecs);
}
if (operateToolSpecs != null) {
merged.addAll(operateToolSpecs);
}
@@ -1128,25 +1233,56 @@ public class AgentScopeReActRuntime implements AgentRuntime {
}
/**
* 构建聚合知识库的默认检索配置
* 校验不同来源的运行时工具名称没有冲突
*
* @param definition 智能体定义
* @return 检索配置
* @param toolSpecs 普通工具声明
* @param knowledgeToolSpecs 知识库工具声明
* @param mcpToolSpecs MCP 工具声明
* @param operateToolSpecs 操作工具声明
* @throws AgentRuntimeException 工具名称重复时抛出
*/
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();
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() {
for (McpClientWrapper client : mcpClients) {
if (client == null) {
continue;
}
try {
client.close();
} catch (Exception ignored) {
}
}
mcpClients.clear();
}
public AgentInitRequest getInitRequest() {
@@ -1163,6 +1299,9 @@ public class AgentScopeReActRuntime implements AgentRuntime {
}
private record AgentScopeToolkitBuildResult(Map<String, List<AgentTool>> skillTools,
List<AgentToolSpec> operateToolSpecs) {
List<AgentToolSpec> knowledgeToolSpecs,
List<AgentToolSpec> mcpToolSpecs,
List<AgentToolSpec> operateToolSpecs,
List<McpSkillRegistration> skillMcpRegistrations) {
}
}

View File

@@ -4,12 +4,14 @@ import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec;
import com.easyagents.agent.runtime.skill.AgentSkillCompiler;
import com.easyagents.agent.runtime.skill.AgentSkillSpec;
import com.easyagents.agent.runtime.mcp.McpSkillRegistration;
import io.agentscope.core.skill.AgentSkill;
import io.agentscope.core.skill.SkillBox;
import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.Toolkit;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
/**
@@ -67,6 +69,22 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler<AgentSkill> {
* @return SkillBox未配置 Skill 时返回 null
*/
public SkillBox createSkillBox(AgentSkillBoxSpec spec, Toolkit toolkit, Map<String, List<AgentTool>> skillTools) {
return createSkillBox(spec, toolkit, skillTools, List.of());
}
/**
* 创建并绑定静态工具及 MCP 工具的 AgentScope SkillBox。
*
* @param spec SkillBox 声明
* @param toolkit Toolkit 实例
* @param skillTools 按 Skill ID 分组的静态工具
* @param skillMcpRegistrations 按 Skill 延迟激活的 MCP client
* @return SkillBox未配置 Skill 时返回 null
*/
public SkillBox createSkillBox(AgentSkillBoxSpec spec,
Toolkit toolkit,
Map<String, List<AgentTool>> skillTools,
List<McpSkillRegistration> skillMcpRegistrations) {
if (spec == null || spec.getSkills().isEmpty()) {
return null;
}
@@ -74,10 +92,15 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler<AgentSkill> {
? new SkillBox(toolkit)
: new SkillBox(toolkit, spec.getSkillBoxId());
skillBox.setExposeAllSkillMetadata(spec.isExposeAllSkillMetadata());
Map<String, List<McpSkillRegistration>> mcpBySkill = groupMcpRegistrations(skillMcpRegistrations);
for (AgentSkillSpec skillSpec : spec.getSkills()) {
AgentSkill skill = compile(skillSpec);
List<AgentTool> tools = skillTools == null ? List.of() : skillTools.getOrDefault(skillSpec.getSkillId(), List.of());
if (tools.isEmpty()) {
List<McpSkillRegistration> mcpRegistrations = mcpBySkill.remove(skillSpec.getSkillId());
if (mcpRegistrations == null) {
mcpRegistrations = List.of();
}
if (tools.isEmpty() && mcpRegistrations.isEmpty()) {
skillBox.registration()
.skill(skill)
.toolkit(toolkit)
@@ -97,11 +120,42 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler<AgentSkill> {
.agentTool(tool)
.apply();
}
for (McpSkillRegistration mcpRegistration : mcpRegistrations) {
skillBox.registration()
.skill(skill)
.toolkit(toolkit)
.enableTools(mcpRegistration.getEnableTools())
.disableTools(mcpRegistration.getDisableTools())
.presetParameters(mcpRegistration.getPresetParameters())
.mcpClient(mcpRegistration.getClient())
.apply();
}
}
if (!mcpBySkill.isEmpty()) {
throw new AgentRuntimeException("Skill-bound MCP references unknown skill: "
+ mcpBySkill.keySet().iterator().next());
}
skillBox.syncToolGroupStates();
return skillBox;
}
private Map<String, List<McpSkillRegistration>> groupMcpRegistrations(
List<McpSkillRegistration> registrations) {
Map<String, List<McpSkillRegistration>> grouped = new LinkedHashMap<>();
if (registrations == null) {
return grouped;
}
for (McpSkillRegistration registration : registrations) {
if (registration == null || registration.getSkillId() == null
|| registration.getSkillId().isBlank()) {
throw new AgentRuntimeException("Skill-bound MCP skill id is required.");
}
grouped.computeIfAbsent(registration.getSkillId(), key -> new java.util.ArrayList<>())
.add(registration);
}
return grouped;
}
/**
* 校验 Skill 声明是否具备 AgentScope 注册和模型提示所需的必要信息。
*

View File

@@ -170,7 +170,8 @@ public class AgentScopeToolAdapter {
throw new AgentRuntimeException("Agent tool invoker is required: " + toolSpec.getName());
}
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());
}
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());
}
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,
@@ -258,7 +277,8 @@ public class AgentScopeToolAdapter {
AgentSkillBinding skillBinding,
boolean emitNormalToolResult,
boolean emitSkillStep,
boolean handleApprovalInTool) implements AgentTool {
boolean handleApprovalInTool,
ClassLoader invocationClassLoader) implements AgentTool {
/**
* 获取工具名称。
@@ -364,6 +384,7 @@ public class AgentScopeToolAdapter {
if (param != null && param.getToolUseBlock() != null) {
context.setToolCallId(param.getToolUseBlock().getId());
}
context.setEventEmitter(this::emit);
context.getMetadata().put("toolName", toolSpec.getName());
context.getMetadata().put("category", toolSpec.getCategory());
appendSkillPayload(context.getMetadata(), activeSkillBinding());
@@ -372,7 +393,7 @@ public class AgentScopeToolAdapter {
}
/**
* 将运行时结果转换为 AgentScope 结果块。
* 将 AgentToolResult 转换为 AgentScope 结果块。
*
* @param param 工具调用参数
* @param result 运行时结果
@@ -401,15 +422,28 @@ public class AgentScopeToolAdapter {
* @return 工具结果块
*/
private ToolResultBlock invokeTool(ToolCallParam param, Map<String, Object> input) {
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));
Thread currentThread = Thread.currentThread();
ClassLoader originalClassLoader = currentThread.getContextClassLoader();
boolean switchClassLoader = invocationClassLoader != null
&& invocationClassLoader != originalClassLoader;
if (switchClassLoader) {
currentThread.setContextClassLoader(invocationClassLoader);
}
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;
}
/**
@@ -560,6 +594,7 @@ public class AgentScopeToolAdapter {
}
target.put("skillId", binding.getSkillId());
target.put("skillName", binding.getSkillName());
target.put("skillDisplayName", binding.getSkillDisplayName());
target.put("skillBoxId", binding.getSkillBoxId());
}

View File

@@ -39,6 +39,36 @@ public enum AgentRuntimeEventType {
*/
TOOL_RESULT,
/**
* 异步工具已提交任务。
*/
ASYNC_TOOL_SUBMITTED,
/**
* 异步工具已观察任务状态。
*/
ASYNC_TOOL_OBSERVED,
/**
* 异步工具已读取任务结果。
*/
ASYNC_TOOL_RESULT,
/**
* 异步工具已请求取消任务。
*/
ASYNC_TOOL_CANCELLED,
/**
* 异步工具已查询任务列表。
*/
ASYNC_TOOL_LISTED,
/**
* 异步工具执行失败。
*/
ASYNC_TOOL_FAILED,
/**
* 知识库检索完成并返回文档摘要。
*/

View File

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

View File

@@ -0,0 +1,159 @@
package com.easyagents.agent.runtime.event.interceptor;
import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.agentscope.AgentScopeMessageAdapter;
import com.easyagents.agent.runtime.event.AgentRuntimeInterceptor;
import com.easyagents.agent.runtime.media.AgentMediaResolver;
import com.easyagents.agent.runtime.media.AgentMediaResource;
import io.agentscope.core.hook.HookEvent;
import io.agentscope.core.hook.PreReasoningEvent;
import io.agentscope.core.message.*;
import reactor.core.publisher.Mono;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
/**
* 在推理前将持久化的业务媒体引用解析为模型可用的 Base64 内容。
*
* <p>输入消息会被深拷贝AgentScope memory/session 中仍保留小体积稳定引用。</p>
*/
public class MediaReferenceInterceptor implements AgentRuntimeInterceptor {
private final AgentMediaResolver mediaResolver;
/**
* 创建媒体引用干预器。
*
* @param mediaResolver 媒体引用解析器
*/
public MediaReferenceInterceptor(AgentMediaResolver mediaResolver) {
this.mediaResolver = mediaResolver;
}
/**
* 解析推理输入中的内部媒体引用。
*
* @param event AgentScope Hook 事件
* @param <T> Hook 事件类型
* @return 处理后的事件
*/
@Override
public <T extends HookEvent> Mono<T> intercept(T event) {
if (event instanceof PreReasoningEvent preReasoningEvent) {
preReasoningEvent.setInputMessages(resolveMessages(preReasoningEvent.getInputMessages()));
}
return Mono.just(event);
}
/**
* 在 AutoContext 完成消息重写后执行媒体解析。
*
* @return 执行优先级
*/
@Override
public int priority() {
return 10;
}
private List<Msg> resolveMessages(List<Msg> messages) {
if (messages == null || messages.isEmpty()) {
return List.of();
}
List<Msg> resolved = new ArrayList<>(messages.size());
for (Msg message : messages) {
List<ContentBlock> content = resolveBlocks(message.getContent());
resolved.add(Msg.builder()
.id(message.getId())
.name(message.getName())
.role(message.getRole())
.content(content)
.metadata(message.getMetadata())
.timestamp(message.getTimestamp())
.build());
}
return resolved;
}
private List<ContentBlock> resolveBlocks(List<ContentBlock> blocks) {
if (blocks == null || blocks.isEmpty()) {
return List.of();
}
List<ContentBlock> resolved = new ArrayList<>(blocks.size());
for (ContentBlock block : blocks) {
if (block instanceof ImageBlock imageBlock) {
resolved.add(resolveImage(imageBlock));
} else if (block instanceof AudioBlock audioBlock) {
resolved.add(resolveAudio(audioBlock));
} else if (block instanceof VideoBlock videoBlock) {
resolved.add(resolveVideo(videoBlock));
} else {
resolved.add(block);
}
}
return resolved;
}
private ImageBlock resolveImage(ImageBlock block) {
AgentMediaResource resource = resolve(block.getSource());
if (resource == null) {
return block;
}
return ImageBlock.builder()
.source(base64Source(resource))
.minPixels(block.getMinPixels())
.maxPixels(block.getMaxPixels())
.build();
}
private AudioBlock resolveAudio(AudioBlock block) {
AgentMediaResource resource = resolve(block.getSource());
return resource == null ? block : AudioBlock.builder().source(base64Source(resource)).build();
}
private VideoBlock resolveVideo(VideoBlock block) {
AgentMediaResource resource = resolve(block.getSource());
if (resource == null) {
return block;
}
return VideoBlock.builder()
.source(base64Source(resource))
.fps(block.getFps())
.maxFrames(block.getMaxFrames())
.minPixels(block.getMinPixels())
.maxPixels(block.getMaxPixels())
.totalPixels(block.getTotalPixels())
.build();
}
private AgentMediaResource resolve(Source source) {
if (!(source instanceof URLSource urlSource)
|| urlSource.getUrl() == null
|| !urlSource.getUrl().startsWith(AgentScopeMessageAdapter.MEDIA_REFERENCE_SCHEME)) {
return null;
}
if (mediaResolver == null) {
throw new AgentRuntimeException("Agent media resolver is required for media references.");
}
String reference;
try {
reference = AgentScopeMessageAdapter.decodeReference(urlSource.getUrl());
} catch (IllegalArgumentException error) {
throw new AgentRuntimeException("Agent media reference is invalid.", error);
}
AgentMediaResource resource = mediaResolver.resolve(reference);
if (resource == null || resource.bytes().length == 0 || resource.mimeType() == null
|| resource.mimeType().isBlank()) {
throw new AgentRuntimeException("Agent media resource is empty.");
}
return resource;
}
private Base64Source base64Source(AgentMediaResource resource) {
return Base64Source.builder()
.mediaType(resource.mimeType())
.data(Base64.getEncoder().encodeToString(resource.bytes()))
.build();
}
}

View File

@@ -1,16 +1,20 @@
package com.easyagents.agent.runtime.event.interceptor;
import com.easyagents.agent.runtime.AgentRuntimeExecutionContext;
import com.easyagents.agent.runtime.AgentRuntimeException;
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.AgentRuntimeInterceptor;
import com.easyagents.agent.runtime.hitl.AgentPendingState;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalEvaluation;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalCoordinator;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import io.agentscope.core.hook.HookEvent;
import io.agentscope.core.hook.PostReasoningEvent;
import io.agentscope.core.hook.PreActingEvent;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.ToolUseBlock;
import reactor.core.publisher.Mono;
@@ -19,27 +23,32 @@ import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 工具 HITL 主线路干预器。
*
* <p>本 interceptor 专门处理“工具执行前人工审批”。监听 AgentScope 原生
* {@link PostReasoningEvent}</p>
* <p>本 interceptor 专门处理“工具执行前人工审批”。通过 AgentScope 原生
* {@link PostReasoningEvent} 建立审批批次,并在 {@link PreActingEvent} 消费一次性执行授权。</p>
*
* <p>这里包含类动作:
* <p>这里包含类动作:
* <ul>
* <li>主线路干预:发现待审批工具后调用 {@link PostReasoningEvent#stopAgent()}
* 让 AgentScope 返回当前带 ToolUseBlock 的消息并暂停工具执行。</li>
* <li>执行前校验:按工具调用身份消费一次性执行授权,阻止未批准或被篡改的调用。</li>
* <li>旁路交互事件:通过 {@link AgentRuntimeEventBridge} 发出
* {@link AgentRuntimeEventType#TOOL_APPROVAL_REQUIRED},通知调用方展示审批交互。</li>
* </ul>
*
* <p>注意:本 interceptor 不执行工具、不写入 AgentScope memory/session也不实现恢复。
* 后续 resume 流程应基于 AgentScope pending tool 状态继续调用 agent stream/call。</p>
* <p>注意:本 interceptor 不执行工具。后续 resume 流程应基于 AgentScope pending tool
* 状态继续调用 agent stream/call,实际工具执行仍由 AgentScope Toolkit 完成。</p>
*/
public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
@@ -76,6 +85,8 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
public <T extends HookEvent> Mono<T> intercept(T event) {
if (event instanceof PostReasoningEvent postReasoningEvent) {
interceptPostReasoning(postReasoningEvent);
} else if (event instanceof PreActingEvent preActingEvent) {
interceptPreActing(preActingEvent);
}
return Mono.just(event);
}
@@ -93,47 +104,327 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
return 50;
}
/**
* 归一化待审批调用、创建审批批次并暂停 AgentScope。
*
* @param event 推理完成事件
*/
private void interceptPostReasoning(PostReasoningEvent event) {
Msg reasoningMessage = event.getReasoningMessage();
Msg reasoningMessage = normalizeApprovalToolUses(event.getReasoningMessage());
if (reasoningMessage == null) {
return;
}
if (reasoningMessage != event.getReasoningMessage()) {
event.setReasoningMessage(reasoningMessage);
}
List<ToolUseBlock> approvalRequiredTools = approvalRequiredTools(reasoningMessage);
if (approvalRequiredTools.isEmpty()) {
return;
}
List<Map<String, Object>> pendingApprovals = new ArrayList<>();
String approvalBatchId = approvalBatchId(reasoningMessage);
for (ToolUseBlock toolUse : approvalRequiredTools) {
AgentToolSpec toolSpec = toolSpecs.get(toolUse.getName());
AgentPendingState pendingState = registerPendingState(toolSpec, toolUse);
AgentPendingState pendingState = registerPendingState(toolSpec, toolUse, approvalBatchId);
if (pendingState.getEventId() != null && !pendingState.getEventId().isBlank()) {
continue;
}
AgentRuntimeEvent approvalEvent = toolApprovalRequiredEvent(toolSpec, toolUse, pendingState);
pendingState.setEventId(approvalEvent.getEventId());
pendingApprovals.add(pendingApprovalPayload(pendingState, toolUse));
eventBridge.emit(approvalEvent);
}
AgentRuntimeExecutionContext context = eventBridge.executionContext();
if (context != null) {
context.getMetadata().put("hitlSuspended", true);
context.getMetadata().put("hitlSuspendReason", "TOOL_APPROVAL_REQUIRED");
context.getMetadata().put("hitlPendingApprovals", pendingApprovals);
}
event.stopAgent();
}
/**
* 在工具实际执行前消费与调用身份绑定的一次性授权。
*
* @param event 工具执行前事件
*/
private void interceptPreActing(PreActingEvent event) {
ToolUseBlock toolUse = event.getToolUse();
AgentToolSpec toolSpec = toolUse == null ? null : toolSpecs.get(toolUse.getName());
if (!requiresApproval(toolSpec, toolUse)) {
return;
}
Map<String, Object> approvalMetadata = approvalMetadata(toolSpec, toolUse);
if (!requiresForcedApproval(toolSpec, toolUse)
&& approvalCoordinator.isReusableApprovalGranted(approvalMetadata)) {
return;
}
// 执行授权与 toolCallId、工具名称及入参同时绑定并且只能消费一次。
approvalCoordinator.consumeExecutionAuthorization(
toolUse.getId(),
toolUse.getName(),
toolUse.getInput());
}
/**
* 为缺少ID的审批调用补充稳定ID并按 toolCallId 去除同轮重放调用。
*
* @param reasoningMessage 原始推理消息
* @return 归一化后的推理消息
*/
private Msg normalizeApprovalToolUses(Msg reasoningMessage) {
if (reasoningMessage == null || reasoningMessage.getContent() == null
|| reasoningMessage.getContent().isEmpty()) {
return reasoningMessage;
}
List<ContentBlock> normalizedContent = new ArrayList<>(reasoningMessage.getContent().size());
Map<String, ToolCallSignature> seenApprovalCalls = new LinkedHashMap<>();
boolean changed = false;
for (int contentIndex = 0; contentIndex < reasoningMessage.getContent().size(); contentIndex++) {
ContentBlock block = reasoningMessage.getContent().get(contentIndex);
if (!(block instanceof ToolUseBlock toolUse) || !isApprovalRequired(toolUse)) {
normalizedContent.add(block);
continue;
}
ToolUseBlock normalizedToolUse = toolUse;
if (toolUse.getId() == null || toolUse.getId().isBlank()) {
normalizedToolUse = copyWithId(toolUse, stableToolCallId(reasoningMessage, contentIndex));
changed = true;
}
ToolCallSignature signature = new ToolCallSignature(
normalizedToolUse.getName(), normalizedToolUse.getInput());
ToolCallSignature existing = seenApprovalCalls.putIfAbsent(normalizedToolUse.getId(), signature);
if (existing != null) {
if (!existing.equals(signature)) {
throw new AgentRuntimeException(
"Duplicate toolCallId is bound to a different tool call: " + normalizedToolUse.getId());
}
// 相同 toolCallId 表示同一协议调用被重复返回,只保留第一次出现。
changed = true;
continue;
}
normalizedContent.add(normalizedToolUse);
}
if (!changed) {
return reasoningMessage;
}
return Msg.builder()
.id(reasoningMessage.getId())
.name(reasoningMessage.getName())
.role(reasoningMessage.getRole())
.content(normalizedContent)
.metadata(reasoningMessage.getMetadata())
.timestamp(reasoningMessage.getTimestamp())
.build();
}
/**
* 为缺少调用ID的工具生成跨同一推理消息重放稳定的调用ID。
*
* @param reasoningMessage 推理消息
* @param contentIndex 工具块在消息内容中的位置
* @return 稳定工具调用ID
*/
private String stableToolCallId(Msg reasoningMessage, int contentIndex) {
String messageId = reasoningMessage.getId();
if (messageId == null || messageId.isBlank()) {
return "hitl-" + UUID.randomUUID();
}
return "hitl-" + messageId + "-" + contentIndex;
}
/**
* 为同一推理消息生成稳定审批批次ID。
*
* @param reasoningMessage 推理消息
* @return 审批批次ID
*/
private String approvalBatchId(Msg reasoningMessage) {
String messageId = reasoningMessage == null ? null : reasoningMessage.getId();
if (messageId == null || messageId.isBlank()) {
return "hitl-batch-" + UUID.randomUUID();
}
return "hitl-batch-" + messageId;
}
/**
* 判断工具调用是否要求人工审批。
*
* @param toolUse 工具调用
* @return 要求审批时为 true
*/
private boolean isApprovalRequired(ToolUseBlock toolUse) {
AgentToolSpec toolSpec = toolUse == null ? null : toolSpecs.get(toolUse.getName());
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();
}
/**
* 复制工具调用并替换调用ID。
*
* @param toolUse 原始工具调用
* @param toolCallId 新工具调用ID
* @return 新工具调用
*/
private ToolUseBlock copyWithId(ToolUseBlock toolUse, String toolCallId) {
return ToolUseBlock.builder()
.id(toolCallId)
.name(toolUse.getName())
.input(toolUse.getInput())
.content(toolUse.getContent())
.metadata(toolUse.getMetadata())
.build();
}
/**
* 获取推理消息中要求审批的工具调用。
*
* @param reasoningMessage 推理消息
* @return 待审批工具调用
*/
private List<ToolUseBlock> approvalRequiredTools(Msg reasoningMessage) {
List<ToolUseBlock> toolUses = reasoningMessage.getContentBlocks(ToolUseBlock.class);
if (toolUses == null || toolUses.isEmpty()) {
return List.of();
}
return toolUses.stream()
.filter(toolUse -> {
AgentToolSpec toolSpec = toolUse == null ? null : toolSpecs.get(toolUse.getName());
return toolSpec != null && toolSpec.isApprovalRequired();
})
.toList();
List<ToolUseBlock> approvalTools = new ArrayList<>();
Set<String> pendingReusableScopes = new LinkedHashSet<>();
for (ToolUseBlock toolUse : toolUses) {
AgentToolSpec toolSpec = toolUse == null ? null : toolSpecs.get(toolUse.getName());
if (!requiresApproval(toolSpec, toolUse)) {
continue;
}
Map<String, Object> approvalMetadata = approvalMetadata(toolSpec, toolUse);
if (!requiresForcedApproval(toolSpec, toolUse)
&& approvalCoordinator.isReusableApprovalGranted(approvalMetadata)) {
continue;
}
String reusableScope = approvalCoordinator.reusableApprovalScope(approvalMetadata);
if (reusableScope != null && !pendingReusableScopes.add(reusableScope)) {
// 同一推理消息中同一 MCP 的多个工具共享一个审批请求。
continue;
}
approvalTools.add(toolUse);
}
return approvalTools;
}
private AgentPendingState registerPendingState(AgentToolSpec toolSpec, ToolUseBlock toolUse) {
/**
* 注册审批批次中的待审批状态。
*
* @param toolSpec 工具声明
* @param toolUse 工具调用
* @param approvalBatchId 审批批次ID
* @return 待审批状态
*/
private AgentPendingState registerPendingState(AgentToolSpec toolSpec,
ToolUseBlock toolUse,
String approvalBatchId) {
AgentRuntimeExecutionContext context = eventBridge.executionContext();
AgentToolApprovalRequest approvalRequest = toolSpec.getApprovalRequest();
Duration timeout = approvalRequest == null || approvalRequest.getTimeout() == null
@@ -142,9 +433,18 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
Map<String, Object> metadata = approvalRequest == null
? new LinkedHashMap<>()
: new LinkedHashMap<>(approvalRequest.getMetadata());
metadata.putAll(toolUse.getMetadata() == null ? Map.of() : toolUse.getMetadata());
if (toolSpec.getMetadata() != null && !toolSpec.getMetadata().isEmpty()) {
// ToolSpec 由工具编译阶段生成,必须覆盖模型返回的同名治理字段(例如 toolType、mcpId
metadata.putAll(toolSpec.getMetadata());
}
AgentToolApprovalEvaluation evaluation = approvalEvaluation(toolSpec, toolUse);
if (evaluation != null && evaluation.metadata() != null) {
// 动态策略由受信任工具实例计算,必须覆盖模型与静态声明中的同名字段。
metadata.putAll(evaluation.metadata());
}
metadata.put("phase", "POST_REASONING");
metadata.put("source", "TOOL_HITL_INTERCEPTOR");
metadata.putAll(toolUse.getMetadata() == null ? Map.of() : toolUse.getMetadata());
return approvalCoordinator.register(
context == null ? null : context.getSessionId(),
context == null || context.getAgentDefinition() == null ? null : context.getAgentDefinition().getAgentId(),
@@ -153,9 +453,52 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
approvalPrompt(approvalRequest),
toolUse.getInput(),
metadata,
Instant.now().plus(timeout));
Instant.now().plus(timeout),
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;
}
/**
* 构建工具审批请求事件。
*
* @param toolSpec 工具声明
* @param toolUse 工具调用
* @param pendingState 待审批状态
* @return 审批请求事件
*/
private AgentRuntimeEvent toolApprovalRequiredEvent(AgentToolSpec toolSpec,
ToolUseBlock toolUse,
AgentPendingState pendingState) {
@@ -170,11 +513,19 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
event.getPayload().put("approvalPrompt", approvalPrompt(toolSpec.getApprovalRequest()));
event.getPayload().put("approvalMetadata", pendingState.getMetadata());
event.getPayload().put("toolDescription", toolSpec.getDescription());
enrichToolPayload(event.getPayload(), toolSpec);
event.getMetadata().put("source", "TOOL_HITL_INTERCEPTOR");
event.getMetadata().put("phase", "POST_REASONING");
return event;
}
/**
* 构建待审批工具的稳定事件载荷。
*
* @param pendingState 待审批状态
* @param toolUse 工具调用
* @return 待审批载荷
*/
private Map<String, Object> pendingApprovalPayload(AgentPendingState pendingState, ToolUseBlock toolUse) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("resumeToken", pendingState.getResumeToken().getValue());
@@ -187,6 +538,44 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
return payload;
}
/**
* 将工具展示元数据补充到审批事件载荷。
*
* @param payload 审批事件载荷
* @param toolSpec 工具声明
*/
private void enrichToolPayload(Map<String, Object> payload, AgentToolSpec toolSpec) {
if (toolSpec == null || toolSpec.getMetadata() == null || toolSpec.getMetadata().isEmpty()) {
return;
}
Map<String, Object> metadata = toolSpec.getMetadata();
putIfPresent(payload, metadata, "toolDisplayName");
putIfPresent(payload, metadata, "rawMcpToolName");
putIfPresent(payload, metadata, "mcpToolName");
putIfPresent(payload, metadata, "mcpId");
putIfPresent(payload, metadata, "mcpName");
putIfPresent(payload, metadata, "mcpTitle");
}
/**
* 在元数据包含指定字段时复制到事件载荷。
*
* @param payload 事件载荷
* @param metadata 工具元数据
* @param key 字段名
*/
private void putIfPresent(Map<String, Object> payload, Map<String, Object> metadata, String key) {
if (metadata.containsKey(key)) {
payload.put(key, metadata.get(key));
}
}
/**
* 获取审批提示文案。
*
* @param approvalRequest 审批配置
* @return 审批提示文案
*/
private String approvalPrompt(AgentToolApprovalRequest approvalRequest) {
if (approvalRequest != null
&& approvalRequest.getApprovalPrompt() != null
@@ -195,4 +584,51 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
}
return "是否批准执行该工具?";
}
/**
* 工具名称与输入组成的调用身份校验值。
*/
private static final class ToolCallSignature {
private final String toolName;
private final Map<String, Object> toolInput;
/**
* 创建工具调用身份校验值。
*
* @param toolName 工具名称
* @param toolInput 工具入参
*/
private ToolCallSignature(String toolName, Map<String, Object> toolInput) {
this.toolName = toolName;
this.toolInput = toolInput == null ? Map.of() : new LinkedHashMap<>(toolInput);
}
/**
* 比较工具调用语义是否一致。
*
* @param object 待比较对象
* @return 语义一致时为 true
*/
@Override
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (!(object instanceof ToolCallSignature that)) {
return false;
}
return Objects.equals(toolName, that.toolName)
&& Objects.equals(toolInput, that.toolInput);
}
/**
* 计算工具调用语义哈希。
*
* @return 哈希值
*/
@Override
public int hashCode() {
return Objects.hash(toolName, toolInput);
}
}
}

View File

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

View File

@@ -5,17 +5,21 @@ import com.easyagents.agent.runtime.event.AgentRuntimeEventBridge;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.event.AgentRuntimeObserver;
import com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext;
import com.easyagents.agent.runtime.tool.AgentToolCategory;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import io.agentscope.core.hook.HookEvent;
import io.agentscope.core.hook.PostActingEvent;
import io.agentscope.core.hook.PreActingEvent;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock;
import reactor.core.publisher.Mono;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 监听 AgentScope 原生工具执行生命周期,并发射工具状态旁路事件。
@@ -28,6 +32,7 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
private final AgentRuntimeEventBridge eventBridge;
private final AgentSkillRuntimeContext skillContext;
private final Map<String, AgentToolSpec> toolSpecs;
/**
* 创建工具执行观察器。
@@ -35,7 +40,7 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
* @param eventBridge 旁路事件桥
*/
public ToolExecutionObserver(AgentRuntimeEventBridge eventBridge) {
this(eventBridge, null);
this(eventBridge, null, List.of());
}
/**
@@ -46,8 +51,25 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
*/
public ToolExecutionObserver(AgentRuntimeEventBridge eventBridge,
AgentSkillRuntimeContext skillContext) {
this(eventBridge, skillContext, List.of());
}
/**
* 创建工具执行观察器。
*
* @param eventBridge 旁路事件桥
* @param skillContext Skill 上下文,用于跳过由 SkillExecutionObserver 处理的工具
* @param toolSpecs 工具声明列表,用于补齐展示名称和治理元数据
*/
public ToolExecutionObserver(AgentRuntimeEventBridge eventBridge,
AgentSkillRuntimeContext skillContext,
List<AgentToolSpec> toolSpecs) {
this.eventBridge = eventBridge;
this.skillContext = skillContext;
this.toolSpecs = (toolSpecs == null ? List.<AgentToolSpec>of() : toolSpecs).stream()
.filter(spec -> spec != null && spec.getName() != null && !spec.getName().isBlank())
.collect(Collectors.toMap(AgentToolSpec::getName, Function.identity(), (left, right) -> left,
LinkedHashMap::new));
}
/**
@@ -81,12 +103,8 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
runtimeEvent.getPayload().put("toolCallId", toolUse.getId());
runtimeEvent.getPayload().put("name", toolUse.getName());
runtimeEvent.getPayload().put("toolName", toolUse.getName());
runtimeEvent.getPayload().put("input", toolUse.getInput());
runtimeEvent.getPayload().put("content", toolUse.getContent());
runtimeEvent.getPayload().put("status", "RUNNING");
runtimeEvent.getPayload().put("source", "HOOK");
runtimeEvent.getPayload().put("phase", "PRE_ACTING");
runtimeEvent.getMetadata().putAll(nullToEmpty(toolUse.getMetadata()));
enrichToolPayload(runtimeEvent, toolUse.getName());
eventBridge.emit(runtimeEvent);
}
@@ -106,43 +124,51 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
runtimeEvent.getPayload().put("toolCallId", toolCallId);
runtimeEvent.getPayload().put("name", toolName);
runtimeEvent.getPayload().put("toolName", toolName);
runtimeEvent.getPayload().put("text", resultText(result));
runtimeEvent.getPayload().put("suspended", result != null && result.isSuspended());
runtimeEvent.getPayload().put("status", success(result) ? "SUCCESS" : "FAILED");
runtimeEvent.getPayload().put("success", success(result));
runtimeEvent.getPayload().put("source", "HOOK");
runtimeEvent.getPayload().put("phase", "POST_ACTING");
if (result != null) {
runtimeEvent.getMetadata().putAll(nullToEmpty(result.getMetadata()));
}
enrichToolPayload(runtimeEvent, toolName);
eventBridge.emit(runtimeEvent);
}
private void enrichToolPayload(AgentRuntimeEvent runtimeEvent, String toolName) {
AgentToolSpec toolSpec = toolSpecs.get(toolName);
if (toolSpec == null) {
return;
}
runtimeEvent.getPayload().put("toolCategory", toolSpec.getCategory().name());
if (toolSpec.getMetadata() == null || toolSpec.getMetadata().isEmpty()) {
return;
}
Map<String, Object> metadata = toolSpec.getMetadata();
putIfPresent(runtimeEvent.getPayload(), metadata, "toolDisplayName");
putIfPresent(runtimeEvent.getPayload(), metadata, "skillId");
if (toolSpec.getCategory() == AgentToolCategory.KNOWLEDGE) {
putIfPresent(runtimeEvent.getPayload(), metadata, "knowledgeId");
putIfPresent(runtimeEvent.getPayload(), metadata, "knowledgeName");
putIfPresent(runtimeEvent.getPayload(), metadata, "knowledgeRuntimeName");
}
}
private void putIfPresent(Map<String, Object> payload, Map<String, Object> metadata, String key) {
if (metadata.containsKey(key)) {
payload.put(key, metadata.get(key));
}
}
private boolean success(ToolResultBlock result) {
if (result == null) {
return false;
}
Object success = result.getMetadata() == null ? null : result.getMetadata().get("success");
return !(success instanceof Boolean) || Boolean.TRUE.equals(success);
}
private String resultText(ToolResultBlock result) {
if (result == null || result.getOutput() == null || result.getOutput().isEmpty()) {
return "";
if (success instanceof Boolean) {
return Boolean.TRUE.equals(success);
}
StringBuilder builder = new StringBuilder();
for (ContentBlock block : result.getOutput()) {
if (block instanceof TextBlock textBlock) {
builder.append(textBlock.getText());
} else {
builder.append(block);
}
}
return builder.toString();
}
private Map<String, Object> nullToEmpty(Map<String, Object> map) {
return map == null ? new LinkedHashMap<>() : map;
// AgentScope 1.x 将工具异常转换为不带 success metadata 的 "Error: ..." 文本结果。
return result.getOutput().stream()
.filter(TextBlock.class::isInstance)
.map(TextBlock.class::cast)
.map(TextBlock::getText)
.noneMatch(text -> text != null && text.startsWith("Error: "));
}
private boolean isSkillTool(String toolName) {

View File

@@ -5,18 +5,35 @@ import com.easyagents.agent.runtime.AgentRuntimeException;
import reactor.core.publisher.Mono;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
/**
* 工具审批协调器。
*/
public class AgentToolApprovalCoordinator {
/** MCP 工具类型。 */
private static final String MCP_TOOL_TYPE = "MCP";
/** 是否启用内存审批协调。 */
private final boolean enabled;
private final Map<String, PendingApproval> approvals = new ConcurrentHashMap<>();
/** 恢复令牌到待审批项的索引。 */
private final Map<String, PendingApproval> approvals = new LinkedHashMap<>();
/** 审批批次ID到批次状态的索引。 */
private final Map<String, ApprovalBatch> approvalBatches = new LinkedHashMap<>();
/** 工具调用ID到恢复令牌的唯一索引。 */
private final Map<String, String> tokensByToolCallId = new LinkedHashMap<>();
/** 工具调用ID到一次性执行授权的索引。 */
private final Map<String, ExecutionAuthorization> executionAuthorizations = new LinkedHashMap<>();
/** 当前 Turn 已批准的可复用工具作用域。 */
private final Set<String> reusableApprovalScopes = new LinkedHashSet<>();
/**
* 创建已启用的协调器。
@@ -66,6 +83,54 @@ public class AgentToolApprovalCoordinator {
Map<String, Object> toolInput,
Map<String, Object> metadata,
Instant expiresAt) {
return register(sessionId, agentId, toolCallId, toolName, approvalPrompt, toolInput,
metadata, expiresAt, null);
}
/**
* 注册一个属于指定审批批次的待审批请求。
*
* <p>同一批次内的全部工具调用均批准后,协调器才会签发执行授权。任意一项拒绝或
* 过期都会关闭整个批次,避免未批准调用跟随已批准调用一起恢复执行。</p>
*
* @param sessionId 会话ID
* @param agentId 智能体ID
* @param toolCallId 工具调用ID
* @param toolName 工具名称
* @param approvalPrompt 审批文案
* @param toolInput 工具入参
* @param metadata 元数据
* @param expiresAt 过期时间
* @param approvalBatchId 审批批次ID为空时创建单调用批次
* @return 待审批状态
*/
public synchronized AgentPendingState register(String sessionId,
String agentId,
String toolCallId,
String toolName,
String approvalPrompt,
Map<String, Object> toolInput,
Map<String, Object> metadata,
Instant expiresAt,
String approvalBatchId) {
if (enabled && (toolCallId == null || toolCallId.isBlank())) {
throw new AgentRuntimeException("Approval-required tool call must include toolCallId.");
}
if (enabled && toolCallId != null && !toolCallId.isBlank()) {
String existingToken = tokensByToolCallId.get(toolCallId);
PendingApproval existing = existingToken == null ? null : approvals.get(existingToken);
if (existing != null && !isExpired(existing.state)) {
if (!Objects.equals(existing.state.getToolName(), toolName)
|| !Objects.equals(existing.state.getToolInput(), toolInput)) {
throw new AgentRuntimeException(
"Duplicate toolCallId is bound to a different tool call: " + toolCallId);
}
return existing.state;
}
if (existing != null) {
closeBatch(existing.batchId, "审批请求已过期。");
}
}
AgentPendingState state = new AgentPendingState();
state.setSessionId(sessionId);
state.setAgentId(agentId);
@@ -73,13 +138,26 @@ public class AgentToolApprovalCoordinator {
state.setToolName(toolName);
state.setApprovalPrompt(approvalPrompt);
state.setToolInput(toolInput);
state.setMetadata(metadata);
state.setExpiresAt(expiresAt);
String token = state.getResumeToken().getValue();
String effectiveBatchId = approvalBatchId == null || approvalBatchId.isBlank()
? token
: approvalBatchId;
Map<String, Object> effectiveMetadata = metadata == null
? new LinkedHashMap<>()
: new LinkedHashMap<>(metadata);
effectiveMetadata.put("approvalBatchId", effectiveBatchId);
state.setMetadata(effectiveMetadata);
if (enabled) {
String token = state.getResumeToken().getValue();
PendingApproval pendingApproval = new PendingApproval(state, new CompletableFuture<>());
PendingApproval pendingApproval = new PendingApproval(
state, effectiveBatchId, new CompletableFuture<>());
approvals.put(token, pendingApproval);
pendingApproval.future.whenComplete((response, error) -> approvals.remove(token));
ApprovalBatch batch = approvalBatches.computeIfAbsent(effectiveBatchId, ApprovalBatch::new);
batch.tokens.add(token);
batch.members.put(token, pendingApproval);
if (toolCallId != null && !toolCallId.isBlank()) {
tokensByToolCallId.put(toolCallId, token);
}
}
return state;
}
@@ -90,7 +168,7 @@ public class AgentToolApprovalCoordinator {
* @param resumeToken 恢复令牌
* @return 恢复请求
*/
public Mono<AgentResumeRequest> await(AgentResumeToken resumeToken) {
public synchronized Mono<AgentResumeRequest> await(AgentResumeToken resumeToken) {
if (!enabled) {
AgentResumeRequest request = new AgentResumeRequest();
request.setResumeToken(resumeToken);
@@ -104,35 +182,260 @@ public class AgentToolApprovalCoordinator {
if (pendingApproval == null) {
return Mono.error(new AgentToolApprovalRejectedException("审批请求已失效。"));
}
if (isExpired(pendingApproval.state)) {
closeBatch(pendingApproval.batchId, "审批请求已过期。");
return Mono.error(new AgentToolApprovalRejectedException("审批请求已过期。"));
}
return Mono.fromFuture(pendingApproval.future);
}
/**
* 处理恢复请求并生成批次级审批决议。
*
* @param request 恢复请求
* @return 审批决议
*/
public synchronized AgentToolApprovalResolution resolve(AgentResumeRequest request) {
validateResumeRequest(request);
if (!enabled) {
AgentPendingState state = new AgentPendingState();
state.setResumeToken(request.getResumeToken());
return new AgentToolApprovalResolution(
AgentToolApprovalResolution.Status.READY, state, List.of(), null);
}
String token = request.getResumeToken().getValue();
PendingApproval pendingApproval = approvals.get(token);
if (pendingApproval == null || pendingApproval.decision != ApprovalDecision.PENDING) {
throw new AgentRuntimeException("Agent resume token is invalid, expired, or already consumed.");
}
if (isExpired(pendingApproval.state)) {
closeBatch(pendingApproval.batchId, "审批请求已过期。");
return new AgentToolApprovalResolution(
AgentToolApprovalResolution.Status.EXPIRED,
pendingApproval.state,
List.of(),
"审批请求已过期。");
}
pendingApproval.decision = request.isApproved()
? ApprovalDecision.APPROVED
: ApprovalDecision.REJECTED;
approvals.remove(token);
removeToolCallIndex(pendingApproval.state, token);
pendingApproval.future.complete(request);
if (!request.isApproved()) {
String reason = request.getRejectReason() == null || request.getRejectReason().isBlank()
? "工具执行已被拒绝。"
: request.getRejectReason();
closeBatch(pendingApproval.batchId, reason);
return new AgentToolApprovalResolution(
AgentToolApprovalResolution.Status.REJECTED,
pendingApproval.state,
List.of(),
reason);
}
ApprovalBatch batch = approvalBatches.get(pendingApproval.batchId);
if (batch == null) {
authorize(pendingApproval);
return new AgentToolApprovalResolution(
AgentToolApprovalResolution.Status.READY,
pendingApproval.state,
List.of(),
null);
}
List<AgentPendingState> remainingStates = pendingStatesInBatch(batch);
AgentPendingState expiredState = remainingStates.stream()
.filter(this::isExpired)
.findFirst()
.orElse(null);
if (expiredState != null) {
closeBatch(batch.batchId, "审批请求已过期。");
return new AgentToolApprovalResolution(
AgentToolApprovalResolution.Status.EXPIRED,
pendingApproval.state,
List.of(),
"审批请求已过期。");
}
if (!remainingStates.isEmpty()) {
return new AgentToolApprovalResolution(
AgentToolApprovalResolution.Status.WAITING,
pendingApproval.state,
remainingStates,
null);
}
for (String batchToken : batch.tokens) {
PendingApproval member = batch.members.get(batchToken);
if (member != null && member.decision == ApprovalDecision.APPROVED) {
authorize(member);
}
}
approvalBatches.remove(batch.batchId);
return new AgentToolApprovalResolution(
AgentToolApprovalResolution.Status.READY,
pendingApproval.state,
List.of(),
null);
}
/**
* 消费恢复请求对应的待审批状态。
*
* <p>该方法用于有状态 runtime 的 HITL resume。第一版 pending state 仅保存在
* 当前进程内存中,因此消费成功后会立即移除 token避免重复恢复。</p>
* <p>该兼容入口供工具适配器内部的 await/consume 流程使用。批次中仍有未决调用时
* 会拒绝提前消费;单调用批准后会移除无需经过 PreActing 的执行凭证。</p>
*
* @param request 恢复请求
* @return 待审批状态
*/
public AgentPendingState consume(AgentResumeRequest request) {
if (request == null || request.getResumeToken() == null
|| request.getResumeToken().getValue() == null
|| request.getResumeToken().getValue().isBlank()) {
throw new AgentRuntimeException("Agent resume token is required.");
public synchronized AgentPendingState consume(AgentResumeRequest request) {
AgentToolApprovalResolution resolution = resolve(request);
if (resolution.getStatus() == AgentToolApprovalResolution.Status.WAITING) {
throw new AgentRuntimeException("Approval batch still has pending tool calls.");
}
AgentPendingState state = resolution.getResolvedState();
if (resolution.getStatus() == AgentToolApprovalResolution.Status.READY
&& state != null
&& state.getToolCallId() != null) {
// 兼容工具适配器内部 await/consume 流程,该流程会直接调用工具,不经过 PreActing 二次校验。
executionAuthorizations.remove(state.getToolCallId());
}
return state;
}
/**
* 根据服务端持久化审批结果签发受信任的执行授权。
*
* <p>恢复元数据应提供单个 {@code toolCallId/toolName/toolInput},或通过
* {@code approvedToolCalls} 提供上述字段组成的列表。MCP 调用可额外携带受信任的
* {@code toolType/mcpId},用于签发当前 Turn 的复用作用域。该入口仅供已经完成
* 持久化令牌校验和一次性消费的服务端集成层使用。</p>
*
* @param request 受信任恢复请求
*/
public synchronized void authorizeTrustedExecution(AgentResumeRequest request) {
validateResumeRequest(request);
if (!enabled || !request.isApproved()) {
return;
}
Map<String, Object> metadata = request.getMetadata() == null
? Map.of()
: request.getMetadata();
Object approvedToolCalls = metadata.get("approvedToolCalls");
Map<String, ExecutionAuthorization> trustedAuthorizations = new LinkedHashMap<>();
Set<String> trustedReusableScopes = new LinkedHashSet<>();
int authorizationCount = 0;
if (approvedToolCalls instanceof List<?> calls) {
for (Object call : calls) {
if (call instanceof Map<?, ?> callMap) {
authorizeTrustedCall(callMap, trustedAuthorizations, trustedReusableScopes);
authorizationCount++;
}
}
} else if (metadata.containsKey("toolCallId")) {
authorizeTrustedCall(metadata, trustedAuthorizations, trustedReusableScopes);
authorizationCount++;
}
if (authorizationCount == 0) {
throw new AgentRuntimeException(
"Trusted resume metadata must include approved toolCallId, toolName, and toolInput.");
}
executionAuthorizations.putAll(trustedAuthorizations);
reusableApprovalScopes.addAll(trustedReusableScopes);
}
/**
* 消费指定工具调用的一次性执行授权。
*
* @param toolCallId 工具调用ID
* @param toolName 工具名称
* @param toolInput 工具入参
*/
public synchronized void consumeExecutionAuthorization(String toolCallId,
String toolName,
Map<String, Object> toolInput) {
if (!enabled) {
AgentPendingState state = new AgentPendingState();
state.setResumeToken(request.getResumeToken());
return state;
return;
}
PendingApproval pendingApproval = approvals.remove(request.getResumeToken().getValue());
if (pendingApproval == null) {
throw new AgentRuntimeException("Agent resume token is invalid or expired.");
if (toolCallId == null || toolCallId.isBlank()) {
throw new AgentToolApprovalRejectedException("待执行工具缺少 toolCallId无法校验审批结果。");
}
pendingApproval.future.complete(request);
return pendingApproval.state;
ExecutionAuthorization authorization = executionAuthorizations.remove(toolCallId);
if (authorization == null) {
throw new AgentToolApprovalRejectedException("工具调用未获得批准或批准已被消费。");
}
if (!Objects.equals(authorization.toolName, toolName)
|| !Objects.equals(authorization.toolInput, toolInput)) {
throw new AgentToolApprovalRejectedException("工具调用与已批准内容不一致。");
}
}
/**
* 判断工具元数据对应的复用作用域是否已在当前 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;
}
/**
* 清理尚未消费的一次性执行授权。
*/
public synchronized void clearExecutionAuthorizations() {
executionAuthorizations.clear();
}
/**
* 清理当前 Turn 的可复用工具审批作用域。
*/
public synchronized void clearReusableApprovalScopes() {
reusableApprovalScopes.clear();
}
/**
* 获取指定会话当前仍待处理的审批状态。
*
* @param sessionId 会话ID
* @return 待审批状态快照
*/
public synchronized List<AgentPendingState> pendingStates(String sessionId) {
return approvals.values().stream()
.filter(pending -> pending.decision == ApprovalDecision.PENDING)
.map(pending -> pending.state)
.filter(state -> sessionId == null || Objects.equals(sessionId, state.getSessionId()))
.toList();
}
/**
@@ -140,18 +443,17 @@ public class AgentToolApprovalCoordinator {
*
* @param reason 取消原因
*/
public void cancelAll(String reason) {
public synchronized void cancelAll(String reason) {
if (!enabled) {
return;
}
for (PendingApproval pendingApproval : approvals.values()) {
AgentResumeRequest request = new AgentResumeRequest();
request.setResumeToken(pendingApproval.state.getResumeToken());
request.setApproved(false);
request.setRejectReason(reason);
pendingApproval.future.complete(request);
for (String batchId : new ArrayList<>(approvalBatches.keySet())) {
closeBatch(batchId, reason);
}
approvals.clear();
tokensByToolCallId.clear();
executionAuthorizations.clear();
reusableApprovalScopes.clear();
}
/**
@@ -163,13 +465,250 @@ public class AgentToolApprovalCoordinator {
return enabled;
}
/**
* 校验恢复请求中的令牌字段。
*
* @param request 恢复请求
*/
private void validateResumeRequest(AgentResumeRequest request) {
if (request == null || request.getResumeToken() == null
|| request.getResumeToken().getValue() == null
|| request.getResumeToken().getValue().isBlank()) {
throw new AgentRuntimeException("Agent resume token is required.");
}
}
/**
* 判断待审批状态是否已经过期。
*
* @param state 待审批状态
* @return 已过期时为 true
*/
private boolean isExpired(AgentPendingState state) {
return state != null
&& state.getExpiresAt() != null
&& !state.getExpiresAt().isAfter(Instant.now());
}
/**
* 获取批次内尚未决策的审批状态。
*
* @param batch 审批批次
* @return 未决审批状态
*/
private List<AgentPendingState> pendingStatesInBatch(ApprovalBatch batch) {
List<AgentPendingState> states = new ArrayList<>();
for (String token : batch.tokens) {
PendingApproval member = batch.members.get(token);
if (member != null && member.decision == ApprovalDecision.PENDING) {
states.add(member.state);
}
}
return states;
}
/**
* 为已批准状态签发一次性执行授权。
*
* @param pendingApproval 已批准状态
*/
private void authorize(PendingApproval pendingApproval) {
AgentPendingState state = pendingApproval.state;
String approvalScope = reusableApprovalScope(state.getMetadata());
if (approvalScope != null) {
reusableApprovalScopes.add(approvalScope);
return;
}
if (state.getToolCallId() == null || state.getToolCallId().isBlank()) {
throw new AgentRuntimeException("Approved tool call is missing toolCallId.");
}
executionAuthorizations.put(state.getToolCallId(), new ExecutionAuthorization(
state.getToolName(),
state.getToolInput()));
}
/**
* 为服务端持久化审批结果签发一次性执行授权。
*
* @param callMap 已批准调用元数据
* @param trustedAuthorizations 本次恢复待签发的一次性授权集合
* @param trustedReusableScopes 本次恢复待签发的可复用作用域集合
*/
private void authorizeTrustedCall(Map<?, ?> callMap,
Map<String, ExecutionAuthorization> trustedAuthorizations,
Set<String> trustedReusableScopes) {
String toolCallId = stringValue(callMap.get("toolCallId"));
String toolName = stringValue(callMap.get("toolName"));
if (toolCallId == null || toolName == null) {
throw new AgentRuntimeException(
"Trusted resume metadata must include non-empty toolCallId and toolName.");
}
Map<String, Object> toolInput = stringKeyMap(callMap.get("toolInput"));
Map<String, Object> scopeMetadata = new LinkedHashMap<>();
scopeMetadata.put("toolType", callMap.get("toolType"));
scopeMetadata.put("mcpId", callMap.get("mcpId"));
scopeMetadata.put("operateTool", callMap.get("operateTool"));
scopeMetadata.put("operateToolType", callMap.get("operateToolType"));
scopeMetadata.put("approvalScope", callMap.get("approvalScope"));
String reusableScope = reusableApprovalScope(scopeMetadata);
if (reusableScope != null) {
trustedReusableScopes.add(reusableScope);
return;
}
ExecutionAuthorization authorization = new ExecutionAuthorization(toolName, toolInput);
ExecutionAuthorization previous = trustedAuthorizations.put(toolCallId, authorization);
if (previous != null
&& (!Objects.equals(previous.toolName, toolName)
|| !Objects.equals(previous.toolInput, toolInput))) {
throw new AgentRuntimeException(
"Trusted resume contains conflicting tool calls for toolCallId: " + toolCallId);
}
}
/**
* 将值转换为非空字符串。
*
* @param value 原始值
* @return 非空字符串;无法转换时返回 null
*/
private String stringValue(Object value) {
if (value == null || String.valueOf(value).isBlank()) {
return null;
}
return String.valueOf(value);
}
/**
* 将任意键 Map 转换为字符串键 Map。
*
* @param value 原始值
* @return 字符串键 Map
*/
private Map<String, Object> stringKeyMap(Object value) {
if (value == null) {
return Map.of();
}
if (!(value instanceof Map<?, ?> source)) {
throw new AgentRuntimeException("Trusted resume toolInput must be a map.");
}
Map<String, Object> result = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : source.entrySet()) {
if (entry.getKey() != null) {
result.put(String.valueOf(entry.getKey()), entry.getValue());
}
}
return result;
}
/**
* 移除工具调用ID到审批令牌的索引。
*
* @param state 待审批状态
* @param token 审批令牌
*/
private void removeToolCallIndex(AgentPendingState state, String token) {
if (state.getToolCallId() != null && !state.getToolCallId().isBlank()) {
tokensByToolCallId.remove(state.getToolCallId(), token);
}
}
/**
* 关闭审批批次并拒绝尚未决策的审批项。
*
* @param batchId 审批批次ID
* @param reason 关闭原因
*/
private void closeBatch(String batchId, String reason) {
ApprovalBatch batch = approvalBatches.remove(batchId);
if (batch == null) {
return;
}
for (String token : batch.tokens) {
PendingApproval member = batch.members.get(token);
if (member == null) {
continue;
}
approvals.remove(token);
removeToolCallIndex(member.state, token);
if (member.decision == ApprovalDecision.PENDING) {
member.decision = ApprovalDecision.REJECTED;
AgentResumeRequest rejection = new AgentResumeRequest();
rejection.setResumeToken(member.state.getResumeToken());
rejection.setApproved(false);
rejection.setRejectReason(reason);
member.future.complete(rejection);
}
}
}
/**
* 单个审批项的内部决策状态。
*/
private enum ApprovalDecision {
PENDING,
APPROVED,
REJECTED
}
/**
* 待审批项及其异步等待句柄。
*/
private static class PendingApproval {
private final AgentPendingState state;
private final String batchId;
private final CompletableFuture<AgentResumeRequest> future;
private ApprovalDecision decision = ApprovalDecision.PENDING;
private PendingApproval(AgentPendingState state, CompletableFuture<AgentResumeRequest> future) {
/**
* 创建待审批项。
*
* @param state 待审批状态
* @param batchId 审批批次ID
* @param future 审批响应等待句柄
*/
private PendingApproval(AgentPendingState state,
String batchId,
CompletableFuture<AgentResumeRequest> future) {
this.state = Objects.requireNonNull(state, "state");
this.batchId = Objects.requireNonNull(batchId, "batchId");
this.future = Objects.requireNonNull(future, "future");
}
}
/**
* 同一轮推理产生的审批批次。
*/
private static class ApprovalBatch {
private final String batchId;
private final Set<String> tokens = new LinkedHashSet<>();
private final Map<String, PendingApproval> members = new LinkedHashMap<>();
/**
* 创建审批批次。
*
* @param batchId 审批批次ID
*/
private ApprovalBatch(String batchId) {
this.batchId = Objects.requireNonNull(batchId, "batchId");
}
}
/**
* 已批准工具调用的一次性执行凭证。
*/
private static class ExecutionAuthorization {
private final String toolName;
private final Map<String, Object> toolInput;
/**
* 创建一次性执行授权。
*
* @param toolName 工具名称
* @param toolInput 工具入参
*/
private ExecutionAuthorization(String toolName, Map<String, Object> toolInput) {
this.toolName = toolName;
this.toolInput = toolInput == null ? Map.of() : new LinkedHashMap<>(toolInput);
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,93 @@
package com.easyagents.agent.runtime.hitl;
import java.util.List;
/**
* 工具审批决议。
*/
public final class AgentToolApprovalResolution {
/**
* 审批决议状态。
*/
public enum Status {
/**
* 当前审批批次仍有待处理调用。
*/
WAITING,
/**
* 当前审批批次已全部批准,可以恢复执行。
*/
READY,
/**
* 当前审批批次已被拒绝。
*/
REJECTED,
/**
* 当前审批批次已过期。
*/
EXPIRED
}
private final Status status;
private final AgentPendingState resolvedState;
private final List<AgentPendingState> remainingStates;
private final String reason;
/**
* 创建工具审批决议。
*
* @param status 决议状态
* @param resolvedState 本次处理的待审批状态
* @param remainingStates 同一批次剩余的待审批状态
* @param reason 拒绝或过期原因
*/
public AgentToolApprovalResolution(Status status,
AgentPendingState resolvedState,
List<AgentPendingState> remainingStates,
String reason) {
this.status = status;
this.resolvedState = resolvedState;
this.remainingStates = remainingStates == null ? List.of() : List.copyOf(remainingStates);
this.reason = reason;
}
/**
* 获取决议状态。
*
* @return 决议状态
*/
public Status getStatus() {
return status;
}
/**
* 获取本次处理的待审批状态。
*
* @return 待审批状态
*/
public AgentPendingState getResolvedState() {
return resolvedState;
}
/**
* 获取同一批次剩余的待审批状态。
*
* @return 剩余待审批状态
*/
public List<AgentPendingState> getRemainingStates() {
return remainingStates;
}
/**
* 获取拒绝或过期原因。
*
* @return 原因
*/
public String getReason() {
return reason;
}
}

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 {
private String knowledgeId;
private String runtimeName;
private String name;
private String description;
private AgentKnowledgePolicy retrievalMode = AgentKnowledgePolicy.AGENTIC;
private int limit = 5;
private double scoreThreshold = 0D;
private Map<String, Object> metadata = new LinkedHashMap<>();
@@ -34,6 +34,24 @@ public class AgentKnowledgeSpec {
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;
}
/**
* 获取检索模式。
*
* @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) {
return List.of();
}
List<String> normalizedSegments = normalizeSegments(answerText);
List<ScoredKnowledgeReference> scoredReferences = new ArrayList<>();
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) {
scoredReferences.add(new ScoredKnowledgeReference(candidate, supportScore));
}
@@ -48,6 +55,22 @@ public class HeuristicKnowledgeCitationMatcher implements AgentKnowledgeCitation
.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,172 @@
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 client 增加运行时工具别名。
*/
class AliasedMcpClientWrapper extends McpClientWrapper {
static final String RAW_TOOL_NAME_META_KEY = "easyagentsRawMcpToolName";
private final McpClientWrapper delegate;
private final Map<String, String> rawToAlias;
private final Map<String, String> aliasToRaw;
private final String toolNamePrefix;
/**
* 创建 MCP client 别名包装器。
*
* @param delegate 原始 MCP client
* @param rawToAlias 原始工具名到运行时工具名的映射
*/
AliasedMcpClientWrapper(McpClientWrapper delegate, Map<String, String> rawToAlias) {
this(delegate, rawToAlias, null);
}
/**
* 创建 MCP client 别名包装器。
*
* @param delegate 原始 MCP client
* @param rawToAlias 原始工具名到运行时工具名的映射
* @param toolNamePrefix 动态工具名前缀
*/
AliasedMcpClientWrapper(McpClientWrapper delegate, Map<String, String> rawToAlias, String toolNamePrefix) {
super(delegate == null ? "mcp" : delegate.getName());
this.delegate = delegate;
this.rawToAlias = rawToAlias == null ? Map.of() : new LinkedHashMap<>(rawToAlias);
this.aliasToRaw = new LinkedHashMap<>();
this.toolNamePrefix = toolNamePrefix == null || toolNamePrefix.isBlank() ? null : toolNamePrefix.trim();
this.rawToAlias.forEach((rawName, aliasName) -> {
if (rawName != null && aliasName != null && !aliasName.isBlank()) {
this.aliasToRaw.put(aliasName, rawName);
}
});
}
/**
* 初始化底层 MCP client。
*
* @return 初始化完成信号
*/
@Override
public Mono<Void> initialize() {
return delegate.initialize().doOnSuccess(ignored -> initialized = delegate.isInitialized());
}
/**
* 返回已替换为运行时别名的工具列表。
*
* @return 工具列表
*/
@Override
public Mono<List<McpSchema.Tool>> listTools() {
return delegate.listTools().map(this::aliasTools);
}
/**
* 调用 MCP 工具,运行时别名会映射回原始工具名。
*
* @param toolName 运行时工具名
* @param arguments 工具参数
* @return 工具调用结果
*/
@Override
public Mono<McpSchema.CallToolResult> callTool(String toolName, Map<String, Object> arguments) {
return delegate.callTool(rawToolName(toolName), arguments);
}
/**
* 关闭底层 MCP client。
*/
@Override
public void close() {
delegate.close();
initialized = false;
}
private List<McpSchema.Tool> aliasTools(List<McpSchema.Tool> tools) {
if (tools == null || tools.isEmpty()) {
cachedTools.clear();
return List.of();
}
List<McpSchema.Tool> aliased = new ArrayList<>();
cachedTools.clear();
Map<String, String> usedAliases = new LinkedHashMap<>();
for (McpSchema.Tool tool : tools) {
if (tool == null) {
continue;
}
McpSchema.Tool aliasTool = aliasTool(tool, usedAliases);
cachedTools.put(aliasTool.name(), aliasTool);
aliased.add(aliasTool);
}
return aliased;
}
private McpSchema.Tool aliasTool(McpSchema.Tool tool, Map<String, String> usedAliases) {
String aliasName = uniqueAliasName(aliasName(tool.name()), tool.name(), usedAliases);
if (aliasName == null || aliasName.isBlank() || aliasName.equals(tool.name())) {
return tool;
}
aliasToRaw.put(aliasName, tool.name());
Map<String, Object> meta = new LinkedHashMap<>();
if (tool.meta() != null) {
meta.putAll(tool.meta());
}
meta.put(RAW_TOOL_NAME_META_KEY, tool.name());
return new McpSchema.Tool(aliasName, tool.title(), tool.description(), tool.inputSchema(),
tool.outputSchema(), tool.annotations(), meta);
}
private String uniqueAliasName(String aliasName, String rawName, Map<String, String> usedAliases) {
if (aliasName == null || aliasName.isBlank()) {
return aliasName;
}
String existingRawName = usedAliases.get(aliasName);
if (existingRawName == null || existingRawName.equals(rawName)) {
usedAliases.put(aliasName, rawName);
return aliasName;
}
int suffix = 2;
String candidate = aliasName + "_" + suffix;
while (usedAliases.containsKey(candidate)) {
suffix++;
candidate = aliasName + "_" + suffix;
}
usedAliases.put(candidate, rawName);
return candidate;
}
private String aliasName(String rawName) {
String explicitAlias = rawToAlias.get(rawName);
if (explicitAlias != null && !explicitAlias.isBlank()) {
return explicitAlias;
}
if (toolNamePrefix == null) {
return rawName;
}
return toolNamePrefix + safeToolNameSegment(rawName);
}
private String safeToolNameSegment(String value) {
String normalized = String.valueOf(value == null ? "" : value).trim()
.replaceAll("[^A-Za-z0-9_-]", "_")
.replaceAll("_+", "_");
if (normalized.isBlank()) {
return "tool";
}
return normalized;
}
private String rawToolName(String toolName) {
return aliasToRaw.getOrDefault(toolName, toolName);
}
}

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

@@ -0,0 +1,41 @@
package com.easyagents.agent.runtime.mcp;
import com.easyagents.agent.runtime.AgentRuntimeException;
import io.agentscope.core.tool.mcp.McpClientBuilder;
import io.agentscope.core.tool.mcp.McpClientWrapper;
import java.time.Duration;
/**
* 创建 AgentScope MCP client。
*/
public class McpClientFactory {
/**
* 根据 MCP 运行时声明创建 AgentScope MCP client。
*
* @param spec MCP 运行时声明
* @return AgentScope MCP client
*/
public McpClientWrapper create(McpSpec spec) {
McpSpecValidator.validateConnection(spec);
McpClientBuilder builder = McpClientBuilder.create(spec.getName())
.timeout(timeout(spec.getTimeout(), Duration.ofSeconds(120)))
.initializationTimeout(timeout(spec.getInitializationTimeout(), Duration.ofSeconds(30)));
switch (spec.getTransportType()) {
case STDIO -> builder.stdioTransport(spec.getCommand(), spec.getArgs(), spec.getEnv());
case SSE -> builder.sseTransport(spec.getUrl())
.headers(spec.getHeaders())
.queryParams(spec.getQueryParams());
case HTTP -> builder.streamableHttpTransport(spec.getUrl())
.headers(spec.getHeaders())
.queryParams(spec.getQueryParams());
default -> throw new AgentRuntimeException("Unsupported MCP transport type: " + spec.getTransportType());
}
return builder.buildAsync().block();
}
private Duration timeout(Duration value, Duration defaultValue) {
return value == null || value.isZero() || value.isNegative() ? defaultValue : value;
}
}

View File

@@ -0,0 +1,80 @@
package com.easyagents.agent.runtime.mcp;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import io.agentscope.core.tool.mcp.McpClientWrapper;
import java.util.ArrayList;
import java.util.List;
/**
* MCP 注册结果。
*/
public class McpRegistration {
private final List<McpClientWrapper> clients;
private final List<AgentToolSpec> toolSpecs;
private final List<McpSkillRegistration> skillRegistrations;
/**
* 创建 MCP 注册结果。
*
* @param clients 已创建 MCP client
* @param toolSpecs 已注册工具声明
*/
public McpRegistration(List<McpClientWrapper> clients, List<AgentToolSpec> toolSpecs) {
this(clients, toolSpecs, List.of());
}
/**
* 创建 MCP 注册结果。
*
* @param clients 已创建 MCP client
* @param toolSpecs 已发现工具声明
* @param skillRegistrations 等待注册到 Skill 的 MCP client
*/
public McpRegistration(List<McpClientWrapper> clients,
List<AgentToolSpec> toolSpecs,
List<McpSkillRegistration> skillRegistrations) {
this.clients = clients == null ? List.of() : new ArrayList<>(clients);
this.toolSpecs = toolSpecs == null ? List.of() : new ArrayList<>(toolSpecs);
this.skillRegistrations = skillRegistrations == null
? List.of()
: new ArrayList<>(skillRegistrations);
}
/**
* 创建空注册结果。
*
* @return 空注册结果
*/
public static McpRegistration empty() {
return new McpRegistration(List.of(), List.of());
}
/**
* 获取已创建 MCP client。
*
* @return 已创建 MCP client
*/
public List<McpClientWrapper> getClients() {
return clients;
}
/**
* 获取已注册工具声明。
*
* @return 已注册工具声明
*/
public List<AgentToolSpec> getToolSpecs() {
return toolSpecs;
}
/**
* 获取等待注册到 Skill 的 MCP client。
*
* @return Skill MCP 注册声明
*/
public List<McpSkillRegistration> getSkillRegistrations() {
return skillRegistrations;
}
}

View File

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

View File

@@ -0,0 +1,485 @@
package com.easyagents.agent.runtime.mcp;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* MCP 运行时声明。
*/
public class McpSpec {
private String name;
private String description;
private McpTransportType transportType = McpTransportType.STDIO;
private String command;
private List<String> args = new ArrayList<>();
private Map<String, String> env = new LinkedHashMap<>();
private String url;
private Map<String, String> headers = new LinkedHashMap<>();
private Map<String, String> queryParams = new LinkedHashMap<>();
private Duration timeout = Duration.ofSeconds(120);
private Duration initializationTimeout = Duration.ofSeconds(30);
private List<String> enableTools = new ArrayList<>();
private List<String> disableTools = new ArrayList<>();
private String groupName;
private Map<String, Map<String, Object>> presetParameters = new LinkedHashMap<>();
private Map<String, String> toolAliases = new LinkedHashMap<>();
private String toolNamePrefix;
private boolean approvalRequired;
private AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest();
private Map<String, AgentToolApprovalRequest> toolApprovalRequests = new LinkedHashMap<>();
private String skillId;
private List<McpToolManifestEntry> frozenToolManifest = new ArrayList<>();
private String frozenToolManifestHash;
private Map<String, Object> metadata = new LinkedHashMap<>();
/**
* 获取 MCP client 名称。
*
* @return MCP client 名称
*/
public String getName() {
return name;
}
/**
* 设置 MCP client 名称。
*
* @param name MCP client 名称
*/
public void setName(String name) {
this.name = name;
}
/**
* 获取 MCP 描述。
*
* @return MCP 描述
*/
public String getDescription() {
return description;
}
/**
* 设置 MCP 描述。
*
* @param description MCP 描述
*/
public void setDescription(String description) {
this.description = description;
}
/**
* 获取连接方式。
*
* @return 连接方式
*/
public McpTransportType getTransportType() {
return transportType;
}
/**
* 设置连接方式。
*
* @param transportType 连接方式
*/
public void setTransportType(McpTransportType transportType) {
this.transportType = transportType == null ? McpTransportType.STDIO : transportType;
}
/**
* 通过字符串设置连接方式。
*
* @param transportType 连接方式文本
*/
public void setTransportType(String transportType) {
this.transportType = McpTransportType.from(transportType);
}
/**
* 获取 stdio 命令。
*
* @return stdio 命令
*/
public String getCommand() {
return command;
}
/**
* 设置 stdio 命令。
*
* @param command stdio 命令
*/
public void setCommand(String command) {
this.command = command;
}
/**
* 获取 stdio 参数。
*
* @return stdio 参数
*/
public List<String> getArgs() {
return args;
}
/**
* 设置 stdio 参数。
*
* @param args stdio 参数
*/
public void setArgs(List<String> args) {
this.args = args == null ? new ArrayList<>() : new ArrayList<>(args);
}
/**
* 获取 stdio 环境变量。
*
* @return stdio 环境变量
*/
public Map<String, String> getEnv() {
return env;
}
/**
* 设置 stdio 环境变量。
*
* @param env stdio 环境变量
*/
public void setEnv(Map<String, String> env) {
this.env = env == null ? new LinkedHashMap<>() : new LinkedHashMap<>(env);
}
/**
* 获取 HTTP 地址。
*
* @return HTTP 地址
*/
public String getUrl() {
return url;
}
/**
* 设置 HTTP 地址。
*
* @param url HTTP 地址
*/
public void setUrl(String url) {
this.url = url;
}
/**
* 获取 HTTP 请求头。
*
* @return HTTP 请求头
*/
public Map<String, String> getHeaders() {
return headers;
}
/**
* 设置 HTTP 请求头。
*
* @param headers HTTP 请求头
*/
public void setHeaders(Map<String, String> headers) {
this.headers = headers == null ? new LinkedHashMap<>() : new LinkedHashMap<>(headers);
}
/**
* 获取 HTTP 查询参数。
*
* @return HTTP 查询参数
*/
public Map<String, String> getQueryParams() {
return queryParams;
}
/**
* 设置 HTTP 查询参数。
*
* @param queryParams HTTP 查询参数
*/
public void setQueryParams(Map<String, String> queryParams) {
this.queryParams = queryParams == null ? new LinkedHashMap<>() : new LinkedHashMap<>(queryParams);
}
/**
* 获取请求超时时间。
*
* @return 请求超时时间
*/
public Duration getTimeout() {
return timeout;
}
/**
* 设置请求超时时间。
*
* @param timeout 请求超时时间
*/
public void setTimeout(Duration timeout) {
this.timeout = timeout == null ? Duration.ofSeconds(120) : timeout;
}
/**
* 获取初始化超时时间。
*
* @return 初始化超时时间
*/
public Duration getInitializationTimeout() {
return initializationTimeout;
}
/**
* 设置初始化超时时间。
*
* @param initializationTimeout 初始化超时时间
*/
public void setInitializationTimeout(Duration initializationTimeout) {
this.initializationTimeout = initializationTimeout == null ? Duration.ofSeconds(30) : initializationTimeout;
}
/**
* 获取启用工具白名单。
*
* @return 启用工具白名单
*/
public List<String> getEnableTools() {
return enableTools;
}
/**
* 设置启用工具白名单。
*
* @param enableTools 启用工具白名单
*/
public void setEnableTools(List<String> enableTools) {
this.enableTools = enableTools == null ? new ArrayList<>() : new ArrayList<>(enableTools);
}
/**
* 获取禁用工具黑名单。
*
* @return 禁用工具黑名单
*/
public List<String> getDisableTools() {
return disableTools;
}
/**
* 设置禁用工具黑名单。
*
* @param disableTools 禁用工具黑名单
*/
public void setDisableTools(List<String> disableTools) {
this.disableTools = disableTools == null ? new ArrayList<>() : new ArrayList<>(disableTools);
}
/**
* 获取工具分组名。
*
* @return 工具分组名
*/
public String getGroupName() {
return groupName;
}
/**
* 设置工具分组名。
*
* @param groupName 工具分组名
*/
public void setGroupName(String groupName) {
this.groupName = groupName;
}
/**
* 获取预置参数。
*
* @return 预置参数
*/
public Map<String, Map<String, Object>> getPresetParameters() {
return presetParameters;
}
/**
* 设置预置参数。
*
* @param presetParameters 预置参数
*/
public void setPresetParameters(Map<String, Map<String, Object>> presetParameters) {
this.presetParameters = presetParameters == null ? new LinkedHashMap<>() : new LinkedHashMap<>(presetParameters);
}
/**
* 获取 MCP 原始工具名到运行时工具名的别名映射。
*
* @return 工具别名映射
*/
public Map<String, String> getToolAliases() {
return toolAliases;
}
/**
* 设置 MCP 原始工具名到运行时工具名的别名映射。
*
* @param toolAliases 工具别名映射
*/
public void setToolAliases(Map<String, String> toolAliases) {
this.toolAliases = toolAliases == null ? new LinkedHashMap<>() : new LinkedHashMap<>(toolAliases);
}
/**
* 获取动态工具名前缀。
*
* @return 动态工具名前缀
*/
public String getToolNamePrefix() {
return toolNamePrefix;
}
/**
* 设置动态工具名前缀。
*
* @param toolNamePrefix 动态工具名前缀
*/
public void setToolNamePrefix(String toolNamePrefix) {
this.toolNamePrefix = toolNamePrefix;
}
/**
* 返回 MCP 工具是否默认需要人工审批。
*
* @return 需要审批时为 true
*/
public boolean isApprovalRequired() {
return approvalRequired;
}
/**
* 设置 MCP 工具是否默认需要人工审批。
*
* @param approvalRequired 审批标记
*/
public void setApprovalRequired(boolean approvalRequired) {
this.approvalRequired = approvalRequired;
}
/**
* 获取审批请求。
*
* @return 审批请求
*/
public AgentToolApprovalRequest getApprovalRequest() {
return approvalRequest;
}
/**
* 设置审批请求。
*
* @param approvalRequest 审批请求
*/
public void setApprovalRequest(AgentToolApprovalRequest approvalRequest) {
this.approvalRequest = approvalRequest == null ? new AgentToolApprovalRequest() : approvalRequest;
}
/**
* 获取运行时工具名到审批请求的映射。
*
* @return 工具审批请求映射
*/
public Map<String, AgentToolApprovalRequest> getToolApprovalRequests() {
return toolApprovalRequests;
}
/**
* 设置运行时工具名到审批请求的映射。
*
* @param toolApprovalRequests 工具审批请求映射
*/
public void setToolApprovalRequests(Map<String, AgentToolApprovalRequest> toolApprovalRequests) {
this.toolApprovalRequests = toolApprovalRequests == null
? new LinkedHashMap<>()
: 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;
}
/**
* 获取元数据。
*
* @return 元数据
*/
public Map<String, Object> getMetadata() {
return metadata;
}
/**
* 设置元数据。
*
* @param metadata 元数据
*/
public void setMetadata(Map<String, Object> metadata) {
this.metadata = metadata == null ? new LinkedHashMap<>() : new LinkedHashMap<>(metadata);
}
}

View File

@@ -0,0 +1,107 @@
package com.easyagents.agent.runtime.mcp;
import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolAdapter;
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolSpec;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* MCP 声明校验器。
*/
public final class McpSpecValidator {
private McpSpecValidator() {
}
/**
* 校验 MCP 连接配置。
*
* @param spec MCP 运行时声明
*/
public static void validateConnection(McpSpec spec) {
if (spec == null) {
throw new AgentRuntimeException("MCP spec is required.");
}
if (spec.getName() == null || spec.getName().isBlank()) {
throw new AgentRuntimeException("MCP name is required.");
}
if (spec.getTransportType() == null) {
throw new AgentRuntimeException("MCP transport type is required: " + spec.getName());
}
validateToolAliases(spec);
switch (spec.getTransportType()) {
case STDIO -> {
if (spec.getCommand() == null || spec.getCommand().isBlank()) {
throw new AgentRuntimeException("MCP stdio command is required: " + spec.getName());
}
}
case SSE, HTTP -> {
if (spec.getUrl() == null || spec.getUrl().isBlank()) {
throw new AgentRuntimeException("MCP url is required: " + spec.getName());
}
}
default -> throw new AgentRuntimeException("Unsupported MCP transport type: " + spec.getTransportType());
}
}
private static void validateToolAliases(McpSpec spec) {
Map<String, String> aliases = spec.getToolAliases();
if (aliases == null || aliases.isEmpty()) {
return;
}
Set<String> runtimeNames = new HashSet<>();
for (Map.Entry<String, String> entry : aliases.entrySet()) {
String rawName = entry.getKey();
String runtimeName = entry.getValue();
if (rawName == null || rawName.isBlank()) {
throw new AgentRuntimeException("MCP raw tool name is required: " + spec.getName());
}
if (runtimeName == null || runtimeName.isBlank()) {
throw new AgentRuntimeException("MCP runtime tool name is required: " + spec.getName());
}
if (!runtimeNames.add(runtimeName)) {
throw new AgentRuntimeException("MCP runtime tool alias conflicts: " + runtimeName);
}
}
}
/**
* 校验 MCP 工具与既有工具名冲突。
*
* @param businessToolSpecs 普通工具声明
* @param mcpToolSpecs MCP 工具声明
* @param operateToolSpecs 操作工具声明
*/
public static void validateToolConflicts(List<AgentToolSpec> businessToolSpecs,
List<AgentToolSpec> mcpToolSpecs,
List<AgentOperateToolSpec> operateToolSpecs) {
Set<String> names = new HashSet<>();
addToolNames(names, businessToolSpecs, "Agent tool conflicts with existing tool: ");
addToolNames(names, mcpToolSpecs, "MCP tool conflicts with existing tool: ");
Set<String> operateToolNames = new AgentOperateToolAdapter().enabledToolNames(operateToolSpecs);
for (String operateToolName : operateToolNames) {
if (!names.add(operateToolName)) {
throw new AgentRuntimeException("Agent operate tool conflicts with existing tool: " + operateToolName);
}
}
}
private static void addToolNames(Set<String> names, List<AgentToolSpec> toolSpecs, String messagePrefix) {
if (toolSpecs == null || toolSpecs.isEmpty()) {
return;
}
for (AgentToolSpec toolSpec : toolSpecs) {
if (toolSpec == null || toolSpec.getName() == null || toolSpec.getName().isBlank()) {
continue;
}
if (!names.add(toolSpec.getName())) {
throw new AgentRuntimeException(messagePrefix + toolSpec.getName());
}
}
}
}

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

@@ -0,0 +1,312 @@
package com.easyagents.agent.runtime.mcp;
import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
import com.easyagents.agent.runtime.tool.AgentToolCategory;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import com.easyagents.agent.runtime.tool.AgentToolVisibility;
import io.agentscope.core.tool.Toolkit;
import io.agentscope.core.tool.mcp.McpClientWrapper;
import io.agentscope.core.tool.mcp.McpTool;
import io.modelcontextprotocol.spec.McpSchema;
import java.util.*;
import java.util.Locale;
/**
* 将 MCP 运行时声明注册到 AgentScope Toolkit。
*/
public class McpToolkitAdapter {
private final McpClientFactory clientFactory;
/**
* 使用默认 MCP client factory 创建适配器。
*/
public McpToolkitAdapter() {
this(new McpClientFactory());
}
/**
* 使用指定 MCP client factory 创建适配器。
*
* @param clientFactory MCP client factory
*/
public McpToolkitAdapter(McpClientFactory clientFactory) {
this.clientFactory = clientFactory == null ? new McpClientFactory() : clientFactory;
}
/**
* 将 MCP 声明注册到 Toolkit。
*
* @param specs MCP 声明
* @param toolkit AgentScope Toolkit
* @return MCP 注册结果
*/
public McpRegistration register(List<McpSpec> specs, Toolkit toolkit) {
if (specs == null || specs.isEmpty()) {
return McpRegistration.empty();
}
if (toolkit == null) {
throw new AgentRuntimeException("AgentScope toolkit is required for MCP registration.");
}
List<McpClientWrapper> clients = new ArrayList<>();
List<AgentToolSpec> toolSpecs = new ArrayList<>();
List<McpSkillRegistration> skillRegistrations = new ArrayList<>();
try {
for (McpSpec spec : specs) {
if (spec == null) {
continue;
}
McpSpecValidator.validateConnection(spec);
McpClientWrapper client = clientFactory.create(spec);
clients.add(client);
if (isSkillBound(spec)) {
List<McpSchema.Tool> actualTools = initializeAndListTools(client);
McpToolManifest.assertFrozenManifest(spec, actualTools);
client = new FrozenMcpClientWrapper(client, actualTools,
spec.getFrozenToolManifest(), spec.getToolAliases(), spec.getToolNamePrefix());
} else {
client = applyAliases(spec, client);
}
clients.set(clients.size() - 1, client);
if (isSkillBound(spec)) {
// Skill MCP 必须以冻结 manifest 派生白名单,调用方不能通过空列表放宽到远端全部 Tool。
spec.setEnableTools(frozenRuntimeToolNames(spec, client));
skillRegistrations.add(new McpSkillRegistration(
spec.getSkillId(), client, spec.getEnableTools(), spec.getDisableTools(),
spec.getPresetParameters()));
} else {
registerClient(spec, client, toolkit);
}
toolSpecs.addAll(toToolSpecs(spec, registeredTools(spec, client)));
}
} catch (RuntimeException error) {
closeQuietly(clients);
throw error;
}
return new McpRegistration(clients, toolSpecs, 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) {
boolean hasExplicitAliases = spec.getToolAliases() != null && !spec.getToolAliases().isEmpty();
boolean hasDynamicPrefix = spec.getToolNamePrefix() != null && !spec.getToolNamePrefix().isBlank();
if (!hasExplicitAliases && !hasDynamicPrefix) {
return client;
}
return new AliasedMcpClientWrapper(client, spec.getToolAliases(), spec.getToolNamePrefix());
}
private void registerClient(McpSpec spec, McpClientWrapper client, Toolkit toolkit) {
String groupName = blankToNull(spec.getGroupName());
if (groupName != null && toolkit.getToolGroup(groupName) == null) {
toolkit.createToolGroup(groupName, spec.getDescription(), true);
}
toolkit.registration()
.mcpClient(client)
.enableTools(emptyToNull(spec.getEnableTools()))
.disableTools(emptyToNull(spec.getDisableTools()))
.group(groupName)
.presetParameters(emptyToNull(spec.getPresetParameters()))
.apply();
}
private List<McpSchema.Tool> registeredTools(McpSpec spec, McpClientWrapper client) {
List<McpSchema.Tool> tools = listTools(client);
if (tools == null || tools.isEmpty()) {
return List.of();
}
List<McpSchema.Tool> filtered = new ArrayList<>();
for (McpSchema.Tool tool : tools) {
if (tool != null && shouldRegister(tool.name(), spec.getEnableTools(), spec.getDisableTools())) {
filtered.add(tool);
}
}
return filtered;
}
private List<McpSchema.Tool> listTools(McpClientWrapper client) {
List<McpSchema.Tool> tools = client.listTools().block();
return tools == null ? List.of() : tools;
}
/**
* 初始化 client 后读取一次远端 Tool 清单。
*
* @param client MCP client
* @return Tool 清单
*/
private List<McpSchema.Tool> initializeAndListTools(McpClientWrapper client) {
// AgentScope validates the initialized flag when listTools() is invoked. Build the
// second publisher only after initialization has completed, otherwise eager publisher
// assembly can fail even though the server initializes successfully moments later.
client.initialize().block();
List<McpSchema.Tool> tools = client.listTools().block();
return tools == null ? List.of() : tools;
}
private boolean isSkillBound(McpSpec spec) {
return spec.getSkillId() != null && !spec.getSkillId().isBlank();
}
private boolean shouldRegister(String toolName, List<String> enableTools, List<String> disableTools) {
if (enableTools != null && !enableTools.isEmpty()) {
return enableTools.contains(toolName);
}
return disableTools == null || disableTools.isEmpty() || !disableTools.contains(toolName);
}
private List<AgentToolSpec> toToolSpecs(McpSpec spec, List<McpSchema.Tool> tools) {
if (tools == null || tools.isEmpty()) {
return List.of();
}
List<AgentToolSpec> toolSpecs = new ArrayList<>();
for (McpSchema.Tool tool : tools) {
AgentToolSpec toolSpec = new AgentToolSpec();
Set<String> excludedPresetNames = Set.of();
Map<String, Object> toolPresetParameters = spec.getPresetParameters() == null
? null
: spec.getPresetParameters().get(tool.name());
if (toolPresetParameters != null) {
excludedPresetNames = toolPresetParameters.keySet();
}
toolSpec.setName(tool.name());
toolSpec.setDescription(tool.description());
toolSpec.setCategory(AgentToolCategory.MCP);
toolSpec.setVisibility(AgentToolVisibility.VISIBLE);
toolSpec.setParametersSchema(McpTool.convertMcpSchemaToParameters(tool.inputSchema(), excludedPresetNames));
toolSpec.setOutputSchema(tool.outputSchema());
AgentToolApprovalRequest toolApprovalRequest = toolApprovalRequest(spec, tool.name());
toolSpec.setApprovalRequired(spec.isApprovalRequired() || toolApprovalRequest != null);
toolSpec.setApprovalRequest(toolApprovalRequest == null ? spec.getApprovalRequest() : toolApprovalRequest);
toolSpec.setMetadata(metadata(spec, tool));
toolSpecs.add(toolSpec);
}
return toolSpecs;
}
private AgentToolApprovalRequest toolApprovalRequest(McpSpec spec, String toolName) {
if (spec.getToolApprovalRequests() == null || spec.getToolApprovalRequests().isEmpty()) {
return null;
}
return spec.getToolApprovalRequests().get(toolName);
}
private Map<String, Object> metadata(McpSpec spec, McpSchema.Tool tool) {
Map<String, Object> metadata = new LinkedHashMap<>();
if (spec.getMetadata() != null) {
spec.getMetadata().forEach((key, value) -> {
if (!isSensitiveMetadataKey(key)) {
metadata.put(key, value);
}
});
}
metadata.put("source", "MCP");
metadata.put("mcpName", spec.getName());
metadata.put("mcpToolName", tool.name());
metadata.put("rawMcpToolName", rawToolName(spec, tool));
metadata.put("toolDisplayName", toolDisplayName(spec, tool));
metadata.put("transportType", spec.getTransportType().configValue());
if (isSkillBound(spec)) {
metadata.put("skillId", spec.getSkillId());
}
return metadata;
}
private String rawToolName(McpSpec spec, McpSchema.Tool tool) {
if (tool != null && tool.meta() != null) {
Object rawName = tool.meta().get(AliasedMcpClientWrapper.RAW_TOOL_NAME_META_KEY);
if (rawName != null && !String.valueOf(rawName).isBlank()) {
return String.valueOf(rawName);
}
}
String toolName = tool == null ? null : tool.name();
if (spec.getToolAliases() != null && !spec.getToolAliases().isEmpty()) {
for (Map.Entry<String, String> entry : spec.getToolAliases().entrySet()) {
if (Objects.equals(entry.getValue(), toolName)) {
return entry.getKey();
}
}
}
return toolName;
}
private String toolDisplayName(McpSpec spec, McpSchema.Tool tool) {
String rawToolName = rawToolName(spec, tool);
String mcpName = spec == null ? null : spec.getDescription();
if (mcpName == null || mcpName.isBlank()) {
mcpName = spec == null ? null : spec.getName();
}
if (mcpName == null || mcpName.isBlank()) {
return rawToolName;
}
if (rawToolName == null || rawToolName.isBlank()) {
return mcpName;
}
return mcpName + " - " + rawToolName;
}
private boolean isSensitiveMetadataKey(String key) {
if (key == null || key.isBlank()) {
return false;
}
String normalized = key.toLowerCase(Locale.ROOT).replace("-", "").replace("_", "");
return normalized.contains("key")
|| normalized.contains("token")
|| normalized.contains("secret")
|| normalized.contains("password")
|| normalized.contains("authorization")
|| normalized.contains("credential");
}
private void closeQuietly(List<McpClientWrapper> clients) {
for (McpClientWrapper client : clients) {
if (client == null) {
continue;
}
try {
client.close();
} catch (Exception ignored) {
}
}
}
private <T> List<T> emptyToNull(List<T> values) {
return values == null || values.isEmpty() ? null : values;
}
private <K, V> Map<K, V> emptyToNull(Map<K, V> values) {
return values == null || values.isEmpty() ? null : values;
}
private String blankToNull(String value) {
return value == null || value.isBlank() ? null : value;
}
}

View File

@@ -0,0 +1,58 @@
package com.easyagents.agent.runtime.mcp;
import com.easyagents.agent.runtime.AgentRuntimeException;
import java.util.Locale;
/**
* MCP 连接方式。
*/
public enum McpTransportType {
/**
* 标准输入输出进程通信。
*/
STDIO,
/**
* HTTP Server-Sent Events 通信。
*/
SSE,
/**
* Streamable HTTP 通信。
*/
HTTP;
/**
* 解析 MCP 连接方式。
*
* @param value 连接方式文本
* @return MCP 连接方式
*/
public static McpTransportType from(String value) {
if (value == null || value.isBlank()) {
return STDIO;
}
String normalized = value.trim().toLowerCase(Locale.ROOT);
return switch (normalized) {
case "stdio" -> STDIO;
case "sse", "http-sse" -> SSE;
case "http", "streamable-http", "http-stream" -> HTTP;
default -> throw new AgentRuntimeException("Unsupported MCP transport type: " + value);
};
}
/**
* 转换为 Easy MCP 配置兼容值。
*
* @return transport 配置值
*/
public String configValue() {
return switch (this) {
case STDIO -> "stdio";
case SSE -> "http-sse";
case HTTP -> "http-stream";
};
}
}

View File

@@ -0,0 +1,17 @@
package com.easyagents.agent.runtime.media;
/**
* 在模型调用边界解析业务侧稳定媒体引用。
*/
@FunctionalInterface
public interface AgentMediaResolver {
/**
* 解析媒体引用。
*
* @param reference 业务侧稳定媒体引用
* @return 媒体资源
* @throws RuntimeException 引用无效、越权或资源读取失败时抛出
*/
AgentMediaResource resolve(String reference);
}

View File

@@ -0,0 +1,29 @@
package com.easyagents.agent.runtime.media;
import java.util.Arrays;
/**
* 模型调用前解析得到的媒体资源。
*
* @param mimeType MIME 类型
* @param bytes 媒体字节
*/
public record AgentMediaResource(String mimeType, byte[] bytes) {
/**
* 创建不可变媒体资源。
*/
public AgentMediaResource {
bytes = bytes == null ? new byte[0] : Arrays.copyOf(bytes, bytes.length);
}
/**
* 返回媒体字节副本。
*
* @return 媒体字节副本
*/
@Override
public byte[] bytes() {
return Arrays.copyOf(bytes, bytes.length);
}
}

View File

@@ -9,6 +9,7 @@ import java.util.Map;
public class AgentMediaBlock extends AgentContentBlock {
private String mimeType;
private String reference;
private String url;
private String data;
private Integer minPixels;
@@ -64,6 +65,24 @@ public class AgentMediaBlock extends AgentContentBlock {
this.mimeType = mimeType;
}
/**
* 获取由业务侧解析的稳定媒体引用。
*
* @return 媒体引用
*/
public String getReference() {
return reference;
}
/**
* 设置由业务侧解析的稳定媒体引用。
*
* @param reference 媒体引用
*/
public void setReference(String reference) {
this.reference = reference;
}
/**
* 获取 URL。
*
@@ -209,6 +228,7 @@ public class AgentMediaBlock extends AgentContentBlock {
Map<String, Object> metadata = new LinkedHashMap<>(getMetadata());
metadata.put("mediaKind", mediaKind);
metadata.put("mimeType", mimeType);
metadata.put("reference", reference);
metadata.put("url", url);
metadata.put("data", data);
metadata.put("minPixels", minPixels);

View File

@@ -0,0 +1,16 @@
package com.easyagents.agent.runtime.model;
/**
* Agent 模型调用使用的 HTTP 版本策略。
*/
public enum AgentHttpVersionPolicy {
/** 按基础 URL 协议自动选择HTTP 使用 1.1HTTPS 优先使用 2。 */
AUTO,
/** 强制使用 HTTP/1.1。 */
HTTP_1_1,
/** 优先使用 HTTP/2并允许 JDK 客户端按协议能力回退。 */
HTTP_2_PREFERRED
}

View File

@@ -0,0 +1,13 @@
package com.easyagents.agent.runtime.model;
/**
* OpenAI-compatible 请求中消息 content 的格式策略。
*/
public enum AgentMessageContentFormat {
/** 使用 AgentScope 默认格式,纯文本为字符串,多模态内容为数组。 */
STANDARD,
/** 将全部角色的 content 规范为内容块数组。 */
TEXT_PARTS
}

View File

@@ -13,6 +13,10 @@ public class AgentModelSpec {
private String baseUrl;
private String endpointPath;
private String apiKey;
private boolean supportImage;
private boolean supportImageBase64Only;
private AgentHttpVersionPolicy httpVersionPolicy = AgentHttpVersionPolicy.AUTO;
private AgentMessageContentFormat messageContentFormat = AgentMessageContentFormat.STANDARD;
private Map<String, Object> metadata = new LinkedHashMap<>();
/**
@@ -105,6 +109,80 @@ public class AgentModelSpec {
this.apiKey = apiKey;
}
/**
* 判断模型是否支持图片输入。
*
* @return 支持图片时返回 true
*/
public boolean isSupportImage() {
return supportImage;
}
/**
* 设置模型是否支持图片输入。
*
* @param supportImage 是否支持图片
*/
public void setSupportImage(boolean supportImage) {
this.supportImage = supportImage;
}
/**
* 判断模型是否只接受 Base64 图片。
*
* @return 仅接受 Base64 时返回 true
*/
public boolean isSupportImageBase64Only() {
return supportImageBase64Only;
}
/**
* 设置模型是否只接受 Base64 图片。
*
* @param supportImageBase64Only 是否只接受 Base64 图片
*/
public void setSupportImageBase64Only(boolean supportImageBase64Only) {
this.supportImageBase64Only = supportImageBase64Only;
}
/**
* 获取 Agent 模型调用的 HTTP 版本策略。
*
* @return HTTP 版本策略
*/
public AgentHttpVersionPolicy getHttpVersionPolicy() {
return httpVersionPolicy;
}
/**
* 设置 Agent 模型调用的 HTTP 版本策略。
*
* @param httpVersionPolicy HTTP 版本策略
*/
public void setHttpVersionPolicy(AgentHttpVersionPolicy httpVersionPolicy) {
this.httpVersionPolicy = httpVersionPolicy == null ? AgentHttpVersionPolicy.AUTO : httpVersionPolicy;
}
/**
* 获取 OpenAI-compatible 请求中的消息 content 格式。
*
* @return 消息 content 格式
*/
public AgentMessageContentFormat getMessageContentFormat() {
return messageContentFormat;
}
/**
* 设置 OpenAI-compatible 请求中的消息 content 格式。
*
* @param messageContentFormat 消息 content 格式
*/
public void setMessageContentFormat(AgentMessageContentFormat messageContentFormat) {
this.messageContentFormat = messageContentFormat == null
? AgentMessageContentFormat.STANDARD
: messageContentFormat;
}
/**
* 获取元数据。
*

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
package com.easyagents.agent.runtime.tool;
import com.easyagents.agent.runtime.AgentRuntimeContext;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Consumer;
/**
* 传递给动态工具调用的上下文。
@@ -17,6 +19,7 @@ public class AgentToolContext {
private String toolCallId;
private AgentRuntimeContext runtimeContext = new AgentRuntimeContext();
private Map<String, Object> metadata = new LinkedHashMap<>();
private Consumer<AgentRuntimeEvent> eventEmitter;
/**
* 获取请求ID。
@@ -143,4 +146,36 @@ public class AgentToolContext {
public void setMetadata(Map<String, Object> metadata) {
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
}
/**
* 发射运行时旁路事件。
*
* <p>该方法主要供 runtime 包装层发射业务无关事件,例如异步工具生命周期事件。
* 未配置事件发射器时静默忽略,避免非流式测试路径产生额外副作用。</p>
*
* @param event 运行时事件
*/
public void emitEvent(AgentRuntimeEvent event) {
if (eventEmitter != null && event != null) {
eventEmitter.accept(event);
}
}
/**
* 获取运行时事件发射器。
*
* @return 运行时事件发射器
*/
public Consumer<AgentRuntimeEvent> getEventEmitter() {
return eventEmitter;
}
/**
* 设置运行时事件发射器。
*
* @param eventEmitter 运行时事件发射器
*/
public void setEventEmitter(Consumer<AgentRuntimeEvent> eventEmitter) {
this.eventEmitter = eventEmitter;
}
}

View File

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

View File

@@ -0,0 +1,56 @@
package com.easyagents.agent.runtime.tool.asynctool;
import com.easyagents.agent.runtime.tool.AgentToolContext;
import java.util.Map;
/**
* 调用方实现的异步业务子工具集合。
*/
public interface AsyncSubTools {
/**
* 提交异步任务。
*
* @param arguments 模型传入的业务参数
* @param context 工具调用上下文
* @return 提交结果
*/
AsyncToolSubmitResult submit(Map<String, Object> arguments, AgentToolContext context);
/**
* 非阻塞观察任务状态和增量事件。
*
* @param request 观察请求
* @param context 工具调用上下文
* @return 当前任务视图
*/
AsyncToolTaskView observe(AsyncToolObserveRequest request, AgentToolContext context);
/**
* 获取任务结果,未完成时返回当前观察态。
*
* @param request 结果请求
* @param context 工具调用上下文
* @return 当前任务视图
*/
AsyncToolTaskView result(AsyncToolResultRequest request, AgentToolContext context);
/**
* 请求取消任务。
*
* @param request 取消请求
* @param context 工具调用上下文
* @return 取消结果
*/
AsyncToolCancelResult cancel(AsyncToolCancelRequest request, AgentToolContext context);
/**
* 查询当前上下文可见的任务列表。
*
* @param request 列表请求
* @param context 工具调用上下文
* @return 任务列表
*/
AsyncToolTaskListResult list(AsyncToolListRequest request, AgentToolContext context);
}

View File

@@ -0,0 +1,52 @@
package com.easyagents.agent.runtime.tool.asynctool;
/**
* 异步工具取消请求。
*/
public class AsyncToolCancelRequest {
private String taskId;
private String reason;
/**
* 创建空取消请求。
*/
public AsyncToolCancelRequest() {
}
/**
* 获取任务 ID。
*
* @return 任务 ID
*/
public String getTaskId() {
return taskId;
}
/**
* 设置任务 ID。
*
* @param taskId 任务 ID
*/
public void setTaskId(String taskId) {
this.taskId = taskId;
}
/**
* 获取取消原因。
*
* @return 取消原因
*/
public String getReason() {
return reason;
}
/**
* 设置取消原因。
*
* @param reason 取消原因
*/
public void setReason(String reason) {
this.reason = reason;
}
}

View File

@@ -0,0 +1,131 @@
package com.easyagents.agent.runtime.tool.asynctool;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 异步工具取消结果。
*/
public class AsyncToolCancelResult {
private String taskId;
private AsyncToolTaskStatus status;
private String message;
private String errorMessage;
private Map<String, Object> payload = new LinkedHashMap<>();
private Map<String, Object> metadata = new LinkedHashMap<>();
/**
* 创建空取消结果。
*/
public AsyncToolCancelResult() {
}
/**
* 获取任务 ID。
*
* @return 任务 ID
*/
public String getTaskId() {
return taskId;
}
/**
* 设置任务 ID。
*
* @param taskId 任务 ID
*/
public void setTaskId(String taskId) {
this.taskId = taskId;
}
/**
* 获取任务状态。
*
* @return 任务状态
*/
public AsyncToolTaskStatus getStatus() {
return status;
}
/**
* 设置任务状态。
*
* @param status 任务状态
*/
public void setStatus(AsyncToolTaskStatus status) {
this.status = status;
}
/**
* 获取取消消息。
*
* @return 取消消息
*/
public String getMessage() {
return message;
}
/**
* 设置取消消息。
*
* @param message 取消消息
*/
public void setMessage(String message) {
this.message = message;
}
/**
* 获取错误消息。
*
* @return 错误消息
*/
public String getErrorMessage() {
return errorMessage;
}
/**
* 设置错误消息。
*
* @param errorMessage 错误消息
*/
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
/**
* 获取业务扩展载荷。
*
* @return 业务扩展载荷
*/
public Map<String, Object> getPayload() {
return payload;
}
/**
* 设置业务扩展载荷。
*
* @param payload 业务扩展载荷
*/
public void setPayload(Map<String, Object> payload) {
this.payload = payload == null ? new LinkedHashMap<>() : payload;
}
/**
* 获取元数据。
*
* @return 元数据
*/
public Map<String, Object> getMetadata() {
return metadata;
}
/**
* 设置元数据。
*
* @param metadata 元数据
*/
public void setMetadata(Map<String, Object> metadata) {
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
}
}

View File

@@ -0,0 +1,33 @@
package com.easyagents.agent.runtime.tool.asynctool;
/**
* 异步工具列表请求。
*/
public class AsyncToolListRequest {
private AsyncToolTaskStatus status;
/**
* 创建空列表请求。
*/
public AsyncToolListRequest() {
}
/**
* 获取状态过滤条件。
*
* @return 状态过滤条件
*/
public AsyncToolTaskStatus getStatus() {
return status;
}
/**
* 设置状态过滤条件。
*
* @param status 状态过滤条件
*/
public void setStatus(AsyncToolTaskStatus status) {
this.status = status;
}
}

View File

@@ -0,0 +1,74 @@
package com.easyagents.agent.runtime.tool.asynctool;
/**
* 异步工具观察请求。
*/
public class AsyncToolObserveRequest {
private String taskId;
/**
* 调用方已读取到的事件位置,用于增量读取任务事件,避免重复返回全量日志。
*/
private Long cursor;
private Integer limit;
/**
* 创建空观察请求。
*/
public AsyncToolObserveRequest() {
}
/**
* 获取任务 ID。
*
* @return 任务 ID
*/
public String getTaskId() {
return taskId;
}
/**
* 设置任务 ID。
*
* @param taskId 任务 ID
*/
public void setTaskId(String taskId) {
this.taskId = taskId;
}
/**
* 获取已读事件位置。
*
* @return 已读事件位置
*/
public Long getCursor() {
return cursor;
}
/**
* 设置已读事件位置。
*
* @param cursor 已读事件位置
*/
public void setCursor(Long cursor) {
this.cursor = cursor;
}
/**
* 获取事件读取数量。
*
* @return 事件读取数量
*/
public Integer getLimit() {
return limit;
}
/**
* 设置事件读取数量。
*
* @param limit 事件读取数量
*/
public void setLimit(Integer limit) {
this.limit = limit;
}
}

View File

@@ -0,0 +1,196 @@
package com.easyagents.agent.runtime.tool.asynctool;
import java.time.Duration;
/**
* 异步工具 runtime 包装层的通用选项。
*/
public class AsyncToolOptions {
private Duration submitTimeout = Duration.ofSeconds(5);
private Duration observeTimeout = Duration.ofSeconds(3);
private Duration resultTimeout = Duration.ofSeconds(3);
private Duration cancelTimeout = Duration.ofSeconds(3);
private Duration listTimeout = Duration.ofSeconds(3);
private int defaultEventLimit = 20;
private int maxEventLimit = 100;
private int maxModelContentLength = 1200;
private int maxEventTextLength = 800;
/**
* 创建默认异步工具选项实例。
*/
public AsyncToolOptions() {
}
/**
* 创建默认异步工具选项。
*
* @return 默认选项
*/
public static AsyncToolOptions defaults() {
return new AsyncToolOptions();
}
/**
* 获取提交子工具超时时间。
*
* @return 超时时间
*/
public Duration getSubmitTimeout() {
return submitTimeout;
}
/**
* 设置提交子工具超时时间。
*
* @param submitTimeout 超时时间
*/
public void setSubmitTimeout(Duration submitTimeout) {
this.submitTimeout = submitTimeout == null ? Duration.ofSeconds(5) : submitTimeout;
}
/**
* 获取观察子工具超时时间。
*
* @return 超时时间
*/
public Duration getObserveTimeout() {
return observeTimeout;
}
/**
* 设置观察子工具超时时间。
*
* @param observeTimeout 超时时间
*/
public void setObserveTimeout(Duration observeTimeout) {
this.observeTimeout = observeTimeout == null ? Duration.ofSeconds(3) : observeTimeout;
}
/**
* 获取结果子工具超时时间。
*
* @return 超时时间
*/
public Duration getResultTimeout() {
return resultTimeout;
}
/**
* 设置结果子工具超时时间。
*
* @param resultTimeout 超时时间
*/
public void setResultTimeout(Duration resultTimeout) {
this.resultTimeout = resultTimeout == null ? Duration.ofSeconds(3) : resultTimeout;
}
/**
* 获取取消子工具超时时间。
*
* @return 超时时间
*/
public Duration getCancelTimeout() {
return cancelTimeout;
}
/**
* 设置取消子工具超时时间。
*
* @param cancelTimeout 超时时间
*/
public void setCancelTimeout(Duration cancelTimeout) {
this.cancelTimeout = cancelTimeout == null ? Duration.ofSeconds(3) : cancelTimeout;
}
/**
* 获取列表子工具超时时间。
*
* @return 超时时间
*/
public Duration getListTimeout() {
return listTimeout;
}
/**
* 设置列表子工具超时时间。
*
* @param listTimeout 超时时间
*/
public void setListTimeout(Duration listTimeout) {
this.listTimeout = listTimeout == null ? Duration.ofSeconds(3) : listTimeout;
}
/**
* 获取默认事件读取数量。
*
* @return 默认事件数量
*/
public int getDefaultEventLimit() {
return defaultEventLimit;
}
/**
* 设置默认事件读取数量。
*
* @param defaultEventLimit 默认事件数量
*/
public void setDefaultEventLimit(int defaultEventLimit) {
this.defaultEventLimit = defaultEventLimit <= 0 ? 20 : defaultEventLimit;
}
/**
* 获取最大事件读取数量。
*
* @return 最大事件数量
*/
public int getMaxEventLimit() {
return maxEventLimit;
}
/**
* 设置最大事件读取数量。
*
* @param maxEventLimit 最大事件数量
*/
public void setMaxEventLimit(int maxEventLimit) {
this.maxEventLimit = maxEventLimit <= 0 ? 100 : maxEventLimit;
}
/**
* 获取模型可见内容最大长度。
*
* @return 最大长度
*/
public int getMaxModelContentLength() {
return maxModelContentLength;
}
/**
* 设置模型可见内容最大长度。
*
* @param maxModelContentLength 最大长度
*/
public void setMaxModelContentLength(int maxModelContentLength) {
this.maxModelContentLength = maxModelContentLength <= 0 ? 1200 : maxModelContentLength;
}
/**
* 获取事件文本最大长度。
*
* @return 最大长度
*/
public int getMaxEventTextLength() {
return maxEventTextLength;
}
/**
* 设置事件文本最大长度。
*
* @param maxEventTextLength 最大长度
*/
public void setMaxEventTextLength(int maxEventTextLength) {
this.maxEventTextLength = maxEventTextLength <= 0 ? 800 : maxEventTextLength;
}
}

View File

@@ -0,0 +1,74 @@
package com.easyagents.agent.runtime.tool.asynctool;
/**
* 异步工具结果请求。
*/
public class AsyncToolResultRequest {
private String taskId;
/**
* 调用方已读取到的事件位置,用于增量读取任务事件,避免 result 重复返回全量日志。
*/
private Long cursor;
private Integer limit;
/**
* 创建空结果请求。
*/
public AsyncToolResultRequest() {
}
/**
* 获取任务 ID。
*
* @return 任务 ID
*/
public String getTaskId() {
return taskId;
}
/**
* 设置任务 ID。
*
* @param taskId 任务 ID
*/
public void setTaskId(String taskId) {
this.taskId = taskId;
}
/**
* 获取已读事件位置。
*
* @return 已读事件位置
*/
public Long getCursor() {
return cursor;
}
/**
* 设置已读事件位置。
*
* @param cursor 已读事件位置
*/
public void setCursor(Long cursor) {
this.cursor = cursor;
}
/**
* 获取事件读取数量。
*
* @return 事件读取数量
*/
public Integer getLimit() {
return limit;
}
/**
* 设置事件读取数量。
*
* @param limit 事件读取数量
*/
public void setLimit(Integer limit) {
this.limit = limit;
}
}

View File

@@ -0,0 +1,173 @@
package com.easyagents.agent.runtime.tool.asynctool;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 异步工具声明。
*
* <p>一个声明会被 runtime 展开为 submit、observe、result、cancel 和 list 五个普通工具。</p>
*/
public class AsyncToolSpec {
private String name;
private String description;
private Map<String, Object> submitParametersSchema = new LinkedHashMap<>();
private AsyncSubTools subTools;
private AsyncToolOptions options = AsyncToolOptions.defaults();
private boolean approvalRequired;
private AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest();
private Map<String, Object> metadata = new LinkedHashMap<>();
/**
* 创建空异步工具声明。
*/
public AsyncToolSpec() {
}
/**
* 获取异步工具基础名称。
*
* @return 工具名称
*/
public String getName() {
return name;
}
/**
* 设置异步工具基础名称。
*
* @param name 工具名称
*/
public void setName(String name) {
this.name = name;
}
/**
* 获取工具描述。
*
* @return 工具描述
*/
public String getDescription() {
return description;
}
/**
* 设置工具描述。
*
* @param description 工具描述
*/
public void setDescription(String description) {
this.description = description;
}
/**
* 获取提交子工具参数 Schema。
*
* @return 参数 Schema
*/
public Map<String, Object> getSubmitParametersSchema() {
return submitParametersSchema;
}
/**
* 设置提交子工具参数 Schema。
*
* @param submitParametersSchema 参数 Schema
*/
public void setSubmitParametersSchema(Map<String, Object> submitParametersSchema) {
this.submitParametersSchema = submitParametersSchema == null ? new LinkedHashMap<>() : submitParametersSchema;
}
/**
* 获取业务子工具实现。
*
* @return 子工具实现
*/
public AsyncSubTools getSubTools() {
return subTools;
}
/**
* 设置业务子工具实现。
*
* @param subTools 子工具实现
*/
public void setSubTools(AsyncSubTools subTools) {
this.subTools = subTools;
}
/**
* 获取异步工具选项。
*
* @return 工具选项
*/
public AsyncToolOptions getOptions() {
return options;
}
/**
* 设置异步工具选项。
*
* @param options 工具选项
*/
public void setOptions(AsyncToolOptions options) {
this.options = options == null ? AsyncToolOptions.defaults() : options;
}
/**
* 返回提交子工具是否需要人工审批。
*
* @return 需要审批时为 true
*/
public boolean isApprovalRequired() {
return approvalRequired;
}
/**
* 设置提交子工具是否需要人工审批。
*
* @param approvalRequired 审批标记
*/
public void setApprovalRequired(boolean approvalRequired) {
this.approvalRequired = approvalRequired;
}
/**
* 获取提交子工具的审批请求配置。
*
* @return 审批请求配置
*/
public AgentToolApprovalRequest getApprovalRequest() {
return approvalRequest;
}
/**
* 设置提交子工具的审批请求配置。
*
* @param approvalRequest 审批请求配置
*/
public void setApprovalRequest(AgentToolApprovalRequest approvalRequest) {
this.approvalRequest = approvalRequest == null ? new AgentToolApprovalRequest() : approvalRequest;
}
/**
* 获取元数据。
*
* @return 元数据
*/
public Map<String, Object> getMetadata() {
return metadata;
}
/**
* 设置元数据。
*
* @param metadata 元数据
*/
public void setMetadata(Map<String, Object> metadata) {
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
}
}

View File

@@ -0,0 +1,691 @@
package com.easyagents.agent.runtime.tool.asynctool;
import com.alibaba.fastjson2.JSON;
import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.tool.AgentToolCategory;
import com.easyagents.agent.runtime.tool.AgentToolContext;
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
import com.easyagents.agent.runtime.tool.AgentToolResult;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import com.easyagents.agent.runtime.tool.AgentToolVisibility;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
import java.util.regex.Pattern;
/**
* 异步工具声明展开器。
*
* <p>该类将一个业务无关的 {@link AsyncToolSpec} 展开为五个普通
* {@link AgentToolSpec} 与 {@link AgentToolInvoker},业务方只需要实现
* {@link AsyncSubTools}。</p>
*/
public class AsyncToolSpecExpander {
private static final Pattern SAFE_NAME = Pattern.compile("^[a-z][a-z0-9_]*$");
private static final String PHASE_SUBMIT = "submit";
private static final String PHASE_OBSERVE = "observe";
private static final String PHASE_RESULT = "result";
private static final String PHASE_CANCEL = "cancel";
private static final String PHASE_LIST = "list";
private static final String ERROR_TYPE_TIMEOUT = "TIMEOUT";
private static final String ERROR_TYPE_EXCEPTION = "EXCEPTION";
private final ExecutorService executor;
/**
* 使用公共 ForkJoinPool 创建展开器。
*/
public AsyncToolSpecExpander() {
this(ForkJoinPool.commonPool());
}
/**
* 使用指定执行器创建展开器。
*
* @param executor 执行器
*/
public AsyncToolSpecExpander(Executor executor) {
if (executor instanceof ExecutorService executorService) {
this.executor = executorService;
} else {
this.executor = new DelegatingExecutorService(executor);
}
}
/**
* 展开工具声明。
*
* @param spec 异步工具声明
* @return 五个普通工具声明
*/
public List<AgentToolSpec> expandSpecs(AsyncToolSpec spec) {
AsyncToolSpec safeSpec = validate(spec);
List<AgentToolSpec> specs = new ArrayList<>(5);
specs.add(toolSpec(safeSpec, PHASE_SUBMIT, safeSpec.getSubmitParametersSchema(), submitOutputSchema()));
specs.add(toolSpec(safeSpec, PHASE_OBSERVE, observeSchema(safeSpec), taskViewOutputSchema()));
specs.add(toolSpec(safeSpec, PHASE_RESULT, observeSchema(safeSpec), taskViewOutputSchema()));
specs.add(toolSpec(safeSpec, PHASE_CANCEL, cancelSchema(), cancelOutputSchema()));
specs.add(toolSpec(safeSpec, PHASE_LIST, listSchema(), listOutputSchema()));
return specs;
}
/**
* 展开工具调用器。
*
* @param spec 异步工具声明
* @return 按工具名索引的调用器
*/
public Map<String, AgentToolInvoker> expandInvokers(AsyncToolSpec spec) {
AsyncToolSpec safeSpec = validate(spec);
Map<String, AgentToolInvoker> invokers = new LinkedHashMap<>();
invokers.put(toolName(safeSpec, PHASE_SUBMIT), (arguments, context) -> submit(safeSpec, arguments, context));
invokers.put(toolName(safeSpec, PHASE_OBSERVE), (arguments, context) -> observe(safeSpec, arguments, context));
invokers.put(toolName(safeSpec, PHASE_RESULT), (arguments, context) -> result(safeSpec, arguments, context));
invokers.put(toolName(safeSpec, PHASE_CANCEL), (arguments, context) -> cancel(safeSpec, arguments, context));
invokers.put(toolName(safeSpec, PHASE_LIST), (arguments, context) -> list(safeSpec, arguments, context));
return invokers;
}
private AsyncToolSpec validate(AsyncToolSpec spec) {
if (spec == null) {
throw new AgentRuntimeException("Async tool spec is required.");
}
if (spec.getName() == null || spec.getName().isBlank()) {
throw new AgentRuntimeException("Async tool name is required.");
}
if (!SAFE_NAME.matcher(spec.getName()).matches()) {
throw new AgentRuntimeException("Async tool name must be safe snake_case: " + spec.getName());
}
if (spec.getSubTools() == null) {
throw new AgentRuntimeException("Async sub tools are required: " + spec.getName());
}
if (spec.getSubmitParametersSchema() == null || spec.getSubmitParametersSchema().isEmpty()) {
spec.setSubmitParametersSchema(emptyObjectSchema());
}
if (spec.getOptions() == null) {
spec.setOptions(AsyncToolOptions.defaults());
}
return spec;
}
private AgentToolSpec toolSpec(AsyncToolSpec spec,
String phase,
Map<String, Object> parametersSchema,
Map<String, Object> outputSchema) {
AgentToolSpec toolSpec = new AgentToolSpec();
toolSpec.setName(toolName(spec, phase));
toolSpec.setDescription(description(spec, phase));
toolSpec.setCategory(AgentToolCategory.CUSTOM);
toolSpec.setVisibility(AgentToolVisibility.VISIBLE);
toolSpec.setParametersSchema(parametersSchema);
toolSpec.setOutputSchema(outputSchema);
toolSpec.setApprovalRequired(PHASE_SUBMIT.equals(phase) && spec.isApprovalRequired());
toolSpec.setApprovalRequest(spec.getApprovalRequest());
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.putAll(spec.getMetadata());
metadata.put("asyncTool", true);
metadata.put("asyncToolName", spec.getName());
metadata.put("asyncToolPhase", phase);
toolSpec.setMetadata(metadata);
return toolSpec;
}
private String description(AsyncToolSpec spec, String phase) {
String prefix = spec.getDescription() == null || spec.getDescription().isBlank()
? "Async tool " + spec.getName()
: spec.getDescription();
return switch (phase) {
case PHASE_SUBMIT -> prefix
+ " This is the default entry point when the user asks to run this tool. Submit an asynchronous task with the normal tool arguments and return task_id.";
case PHASE_OBSERVE -> prefix
+ " Use immediately after submit with the returned task_id to check progress and incremental events. Do not ask the user for task_id immediately after submit.";
case PHASE_RESULT -> prefix
+ " Use only when a known task_id should return the final result, or the current observation if the task is still running.";
case PHASE_CANCEL -> prefix
+ " Use only when the user explicitly asks to cancel a known asynchronous task by task_id.";
case PHASE_LIST -> prefix
+ " Use only when the user explicitly asks to list visible asynchronous tasks in the current context.";
default -> prefix;
};
}
private AgentToolResult submit(AsyncToolSpec spec, Map<String, Object> arguments, AgentToolContext context) {
return execute(spec, PHASE_SUBMIT, context, spec.getOptions().getSubmitTimeout(),
guardedContext -> {
AsyncToolSubmitResult result = spec.getSubTools().submit(safeMap(arguments), guardedContext);
return wrapSubmit(spec, result, guardedContext);
});
}
private AgentToolResult observe(AsyncToolSpec spec, Map<String, Object> arguments, AgentToolContext context) {
return execute(spec, PHASE_OBSERVE, context, spec.getOptions().getObserveTimeout(),
guardedContext -> {
AsyncToolObserveRequest request = observeRequest(arguments, spec.getOptions());
return wrapTaskView(spec, PHASE_OBSERVE,
spec.getSubTools().observe(request, guardedContext), guardedContext);
});
}
private AgentToolResult result(AsyncToolSpec spec, Map<String, Object> arguments, AgentToolContext context) {
return execute(spec, PHASE_RESULT, context, spec.getOptions().getResultTimeout(),
guardedContext -> {
AsyncToolResultRequest request = resultRequest(arguments, spec.getOptions());
return wrapTaskView(spec, PHASE_RESULT,
spec.getSubTools().result(request, guardedContext), guardedContext);
});
}
private AgentToolResult cancel(AsyncToolSpec spec, Map<String, Object> arguments, AgentToolContext context) {
return execute(spec, PHASE_CANCEL, context, spec.getOptions().getCancelTimeout(),
guardedContext -> {
AsyncToolCancelRequest request = cancelRequest(arguments);
return wrapCancel(spec, spec.getSubTools().cancel(request, guardedContext), guardedContext);
});
}
private AgentToolResult list(AsyncToolSpec spec, Map<String, Object> arguments, AgentToolContext context) {
return execute(spec, PHASE_LIST, context, spec.getOptions().getListTimeout(),
guardedContext -> {
AsyncToolListRequest request = listRequest(arguments);
return wrapList(spec, spec.getSubTools().list(request, guardedContext), guardedContext);
});
}
private AgentToolResult execute(AsyncToolSpec spec,
String phase,
AgentToolContext context,
Duration timeout,
Function<AgentToolContext, AgentToolResult> supplier) {
AtomicBoolean active = new AtomicBoolean(true);
AgentToolContext guardedContext = guardedContext(context, active);
Future<AgentToolResult> future = executor.submit(() -> supplier.apply(guardedContext));
try {
return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
} catch (TimeoutException error) {
active.set(false);
future.cancel(true);
AgentToolResult result = failureResult(spec, phase, null, AsyncToolTaskStatus.TIMEOUT,
ERROR_TYPE_TIMEOUT, "Async tool " + phase + " timed out.");
emitFailure(spec, phase, context, null, AsyncToolTaskStatus.TIMEOUT, result.getErrorMessage());
return result;
} catch (InterruptedException error) {
active.set(false);
Thread.currentThread().interrupt();
AgentToolResult result = failureResult(spec, phase, null, AsyncToolTaskStatus.FAILED,
ERROR_TYPE_EXCEPTION, "Async tool " + phase + " interrupted.");
emitFailure(spec, phase, context, null, AsyncToolTaskStatus.FAILED, result.getErrorMessage());
return result;
} catch (ExecutionException error) {
active.set(false);
Throwable cause = error.getCause() == null ? error : error.getCause();
String message = cause.getMessage() == null || cause.getMessage().isBlank()
? "Async tool " + phase + " failed."
: cause.getMessage();
AgentToolResult result = failureResult(spec, phase, null, AsyncToolTaskStatus.FAILED,
ERROR_TYPE_EXCEPTION, message);
emitFailure(spec, phase, context, null, AsyncToolTaskStatus.FAILED, result.getErrorMessage());
return result;
}
}
private AgentToolContext guardedContext(AgentToolContext source, AtomicBoolean active) {
if (source == null) {
return null;
}
AgentToolContext context = new AgentToolContext();
context.setRequestId(source.getRequestId());
context.setTraceId(source.getTraceId());
context.setSessionId(source.getSessionId());
context.setAgentId(source.getAgentId());
context.setToolCallId(source.getToolCallId());
context.setRuntimeContext(source.getRuntimeContext());
context.setMetadata(new LinkedHashMap<>(source.getMetadata()));
context.setEventEmitter(event -> {
// 超时后底层业务可能仍在运行,迟到事件不能再覆盖 runtime 已返回的失败语义。
if (active.get()) {
source.emitEvent(event);
}
});
return context;
}
private AgentToolResult wrapSubmit(AsyncToolSpec spec, AsyncToolSubmitResult result, AgentToolContext context) {
AsyncToolSubmitResult safe = result == null ? new AsyncToolSubmitResult() : result;
AsyncToolTaskStatus status = status(safe.getStatus(), AsyncToolTaskStatus.RUNNING);
safe.setStatus(status);
if (safe.getTaskId() == null || safe.getTaskId().isBlank()) {
String message = "Async tool submit must return taskId.";
AgentToolResult toolResult = failureResult(spec, PHASE_SUBMIT, null, AsyncToolTaskStatus.FAILED,
ERROR_TYPE_EXCEPTION, message, safe);
emitFailure(spec, PHASE_SUBMIT, context, null, AsyncToolTaskStatus.FAILED, message);
return toolResult;
}
if (safe.getNextAction() == null || safe.getNextAction().isBlank()) {
safe.setNextAction(toolName(spec, PHASE_OBSERVE) + " 查看任务进度。");
}
AgentToolResult toolResult = successResult(spec, PHASE_SUBMIT, safe.getTaskId(), status,
modelContent(safe.getTaskId(), status, safe.getNextAction(), safe.getSummary()), safe);
emit(spec, PHASE_SUBMIT, AgentRuntimeEventType.ASYNC_TOOL_SUBMITTED, context, safe.getTaskId(), status,
safe.getCursor(), null, safe.getSummary(), null);
return toolResult;
}
private AgentToolResult wrapTaskView(AsyncToolSpec spec,
String phase,
AsyncToolTaskView view,
AgentToolContext context) {
AsyncToolTaskView safe = view == null ? new AsyncToolTaskView() : view;
AsyncToolTaskStatus status = status(safe.getStatus(), AsyncToolTaskStatus.RUNNING);
safe.setStatus(status);
if (safe.getTerminal() == null) {
safe.setTerminal(status.isTerminal());
}
if (safe.getResultAvailable() == null) {
safe.setResultAvailable(status.isSuccess() && safe.getResult() != null);
}
if (safe.getNextAction() == null || safe.getNextAction().isBlank()) {
safe.setNextAction(status.isTerminal()
? "任务已结束。"
: toolName(spec, PHASE_OBSERVE) + " 继续查看任务进度。");
}
AgentToolResult toolResult = successResult(spec, phase, safe.getTaskId(), status,
modelContent(safe.getTaskId(), status, safe.getNextAction(), safe.getSummary(),
Boolean.TRUE.equals(safe.getResultAvailable()), safe.getResult()), safe);
emit(spec, phase, PHASE_RESULT.equals(phase)
? AgentRuntimeEventType.ASYNC_TOOL_RESULT
: AgentRuntimeEventType.ASYNC_TOOL_OBSERVED,
context, safe.getTaskId(), status, safe.getCursor(), safe.getNextCursor(), safe.getSummary(),
safe.getErrorMessage(), safe.getResultAvailable());
return toolResult;
}
private AgentToolResult wrapCancel(AsyncToolSpec spec, AsyncToolCancelResult result, AgentToolContext context) {
AsyncToolCancelResult safe = result == null ? new AsyncToolCancelResult() : result;
AsyncToolTaskStatus status = status(safe.getStatus(), AsyncToolTaskStatus.CANCELLING);
safe.setStatus(status);
boolean success = safe.getErrorMessage() == null || safe.getErrorMessage().isBlank();
AgentToolResult toolResult = success
? successResult(spec, PHASE_CANCEL, safe.getTaskId(), status,
modelContent(safe.getTaskId(), status, "继续使用 " + toolName(spec, PHASE_OBSERVE) + " 查看取消状态。",
safe.getMessage()), safe)
: failureResult(spec, PHASE_CANCEL, safe.getTaskId(), status, ERROR_TYPE_EXCEPTION, safe.getErrorMessage(), safe);
emit(spec, PHASE_CANCEL, success ? AgentRuntimeEventType.ASYNC_TOOL_CANCELLED : AgentRuntimeEventType.ASYNC_TOOL_FAILED,
context, safe.getTaskId(), status, null, null, safe.getMessage(), safe.getErrorMessage());
return toolResult;
}
private AgentToolResult wrapList(AsyncToolSpec spec, AsyncToolTaskListResult result, AgentToolContext context) {
AsyncToolTaskListResult safe = result == null ? new AsyncToolTaskListResult() : result;
String summary = "" + safe.getTasks().size() + " 个任务。";
AgentToolResult toolResult = successResult(spec, PHASE_LIST, null, null,
modelContent(null, null, "按 task_id 使用观察或结果工具查看详情。", summary), safe);
emit(spec, PHASE_LIST, AgentRuntimeEventType.ASYNC_TOOL_LISTED, context, null, null, null, null, summary, null);
return toolResult;
}
private AgentToolResult successResult(AsyncToolSpec spec,
String phase,
String taskId,
AsyncToolTaskStatus status,
String modelContent,
Object displayContent) {
AgentToolResult result = AgentToolResult.success(truncate(modelContent, spec.getOptions().getMaxModelContentLength()));
result.setDisplayContent(displayContent);
result.setMetadata(metadata(spec, phase, taskId, status));
return result;
}
private AgentToolResult failureResult(AsyncToolSpec spec,
String phase,
String taskId,
AsyncToolTaskStatus status,
String errorType,
String errorMessage) {
return failureResult(spec, phase, taskId, status, errorType, errorMessage, null);
}
private AgentToolResult failureResult(AsyncToolSpec spec,
String phase,
String taskId,
AsyncToolTaskStatus status,
String errorType,
String errorMessage,
Object displayContent) {
String message = errorMessage == null || errorMessage.isBlank() ? "Async tool failed." : errorMessage;
AgentToolResult result = AgentToolResult.failure(message);
result.setDisplayContent(displayContent == null ? Map.of("errorType", errorType, "message", message) : displayContent);
result.setMetadata(metadata(spec, phase, taskId, status));
result.getMetadata().put("errorType", errorType);
return result;
}
private void emitFailure(AsyncToolSpec spec,
String phase,
AgentToolContext context,
String taskId,
AsyncToolTaskStatus status,
String errorMessage) {
emit(spec, phase, AgentRuntimeEventType.ASYNC_TOOL_FAILED, context, taskId, status, null, null, null, errorMessage);
}
private void emit(AsyncToolSpec spec,
String phase,
AgentRuntimeEventType type,
AgentToolContext context,
String taskId,
AsyncToolTaskStatus status,
Long cursor,
Long nextCursor,
String summary,
String errorMessage) {
emit(spec, phase, type, context, taskId, status, cursor, nextCursor, summary, errorMessage, null);
}
private void emit(AsyncToolSpec spec,
String phase,
AgentRuntimeEventType type,
AgentToolContext context,
String taskId,
AsyncToolTaskStatus status,
Long cursor,
Long nextCursor,
String summary,
String errorMessage,
Boolean resultAvailable) {
if (context == null) {
return;
}
AgentRuntimeEvent event = AgentRuntimeEvent.of(type);
event.setTraceId(context.getTraceId());
event.setSessionId(context.getSessionId());
event.setAgentId(context.getAgentId());
event.setToolCallId(context.getToolCallId());
event.getMetadata().putAll(spec.getMetadata());
putIfNotNull(event.getMetadata(), "requestId", context.getRequestId());
event.getPayload().put("asyncToolName", spec.getName());
event.getPayload().put("phase", phase);
putIfNotNull(event.getPayload(), "toolDisplayName", spec.getMetadata().get("toolDisplayName"));
putIfNotNull(event.getPayload(), "taskId", taskId);
putIfNotNull(event.getPayload(), "status", status == null ? null : status.name());
putIfNotNull(event.getPayload(), "cursor", cursor);
putIfNotNull(event.getPayload(), "nextCursor", nextCursor);
putIfNotNull(event.getPayload(), "summary", truncate(summary, spec.getOptions().getMaxEventTextLength()));
putIfNotNull(event.getPayload(), "errorMessage", truncate(errorMessage, spec.getOptions().getMaxEventTextLength()));
putIfNotNull(event.getPayload(), "resultAvailable", resultAvailable);
event.getMetadata().put("asyncTool", true);
event.getMetadata().put("asyncToolName", spec.getName());
event.getMetadata().put("asyncToolPhase", phase);
context.emitEvent(event);
}
private Map<String, Object> metadata(AsyncToolSpec spec, String phase, String taskId, AsyncToolTaskStatus status) {
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.putAll(spec.getMetadata());
metadata.put("asyncTool", true);
metadata.put("asyncToolName", spec.getName());
metadata.put("asyncToolPhase", phase);
putIfNotNull(metadata, "taskId", taskId);
putIfNotNull(metadata, "status", status == null ? null : status.name());
return metadata;
}
private AsyncToolObserveRequest observeRequest(Map<String, Object> arguments, AsyncToolOptions options) {
AsyncToolObserveRequest request = new AsyncToolObserveRequest();
request.setTaskId(stringValue(arguments, "taskId"));
request.setCursor(longValue(arguments, "cursor"));
request.setLimit(limit(arguments, options));
return request;
}
private AsyncToolResultRequest resultRequest(Map<String, Object> arguments, AsyncToolOptions options) {
AsyncToolResultRequest request = new AsyncToolResultRequest();
request.setTaskId(stringValue(arguments, "taskId"));
request.setCursor(longValue(arguments, "cursor"));
request.setLimit(limit(arguments, options));
return request;
}
private AsyncToolCancelRequest cancelRequest(Map<String, Object> arguments) {
AsyncToolCancelRequest request = new AsyncToolCancelRequest();
request.setTaskId(stringValue(arguments, "taskId"));
request.setReason(stringValue(arguments, "reason"));
return request;
}
private AsyncToolListRequest listRequest(Map<String, Object> arguments) {
AsyncToolListRequest request = new AsyncToolListRequest();
String status = stringValue(arguments, "status");
if (status != null && !status.isBlank()) {
request.setStatus(AsyncToolTaskStatus.valueOf(status.trim().toUpperCase(Locale.ROOT)));
}
return request;
}
private Integer limit(Map<String, Object> arguments, AsyncToolOptions options) {
Integer limit = intValue(arguments, "limit");
if (limit == null || limit <= 0) {
return options.getDefaultEventLimit();
}
return Math.min(limit, options.getMaxEventLimit());
}
private String modelContent(String taskId, AsyncToolTaskStatus status, String nextAction, String summary) {
return modelContent(taskId, status, nextAction, summary, false, null);
}
private String modelContent(String taskId,
AsyncToolTaskStatus status,
String nextAction,
String summary,
boolean resultAvailable,
Object result) {
StringBuilder builder = new StringBuilder();
if (taskId != null && !taskId.isBlank()) {
builder.append("task_id: ").append(taskId).append('\n');
}
if (status != null) {
builder.append("status: ").append(status.name()).append('\n');
}
if (summary != null && !summary.isBlank()) {
builder.append("summary: ").append(summary).append('\n');
}
if (resultAvailable) {
builder.append("result_available: true").append('\n');
builder.append("result: ").append(modelResult(result)).append('\n');
}
if (nextAction != null && !nextAction.isBlank()) {
builder.append("next_action: ").append(nextAction);
}
return builder.toString();
}
private String modelResult(Object result) {
if (result == null) {
return "";
}
if (result instanceof CharSequence
|| result instanceof Number
|| result instanceof Boolean
|| result instanceof Character
|| result instanceof Enum<?>) {
return String.valueOf(result);
}
try {
return JSON.toJSONString(result);
} catch (Exception ignored) {
return String.valueOf(result);
}
}
private String toolName(AsyncToolSpec spec, String phase) {
return spec.getName() + "_" + phase;
}
private AsyncToolTaskStatus status(AsyncToolTaskStatus status, AsyncToolTaskStatus defaultStatus) {
return status == null ? defaultStatus : status;
}
private Map<String, Object> emptyObjectSchema() {
Map<String, Object> schema = new LinkedHashMap<>();
schema.put("type", "object");
schema.put("properties", new LinkedHashMap<>());
return schema;
}
private Map<String, Object> observeSchema(AsyncToolSpec spec) {
Map<String, Object> schema = new LinkedHashMap<>();
schema.put("type", "object");
Map<String, Object> properties = new LinkedHashMap<>();
properties.put("taskId", property("string", "Task id returned by submit."));
properties.put("cursor", property("integer", "Event cursor returned by previous observe/result."));
properties.put("limit", property("integer", "Maximum number of incremental events."));
schema.put("properties", properties);
schema.put("required", List.of("taskId"));
return schema;
}
private Map<String, Object> cancelSchema() {
Map<String, Object> schema = new LinkedHashMap<>();
schema.put("type", "object");
Map<String, Object> properties = new LinkedHashMap<>();
properties.put("taskId", property("string", "Task id returned by submit."));
properties.put("reason", property("string", "Optional cancellation reason."));
schema.put("properties", properties);
schema.put("required", List.of("taskId"));
return schema;
}
private Map<String, Object> listSchema() {
Map<String, Object> schema = new LinkedHashMap<>();
schema.put("type", "object");
Map<String, Object> properties = new LinkedHashMap<>();
properties.put("status", property("string", "Optional task status filter."));
schema.put("properties", properties);
return schema;
}
private Map<String, Object> submitOutputSchema() {
return outputSchema("AsyncToolSubmitResult");
}
private Map<String, Object> taskViewOutputSchema() {
return outputSchema("AsyncToolTaskView");
}
private Map<String, Object> cancelOutputSchema() {
return outputSchema("AsyncToolCancelResult");
}
private Map<String, Object> listOutputSchema() {
return outputSchema("AsyncToolTaskListResult");
}
private Map<String, Object> outputSchema(String title) {
Map<String, Object> schema = new LinkedHashMap<>();
schema.put("type", "object");
schema.put("title", title);
return schema;
}
private Map<String, Object> property(String type, String description) {
Map<String, Object> property = new LinkedHashMap<>();
property.put("type", type);
property.put("description", description);
return property;
}
private Map<String, Object> safeMap(Map<String, Object> arguments) {
return arguments == null ? new LinkedHashMap<>() : new LinkedHashMap<>(arguments);
}
private String stringValue(Map<String, Object> arguments, String key) {
Object value = arguments == null ? null : arguments.get(key);
return value == null ? null : String.valueOf(value);
}
private Long longValue(Map<String, Object> arguments, String key) {
Object value = arguments == null ? null : arguments.get(key);
if (value instanceof Number number) {
return number.longValue();
}
if (value == null || String.valueOf(value).isBlank()) {
return null;
}
return Long.parseLong(String.valueOf(value));
}
private Integer intValue(Map<String, Object> arguments, String key) {
Object value = arguments == null ? null : arguments.get(key);
if (value instanceof Number number) {
return number.intValue();
}
if (value == null || String.valueOf(value).isBlank()) {
return null;
}
return Integer.parseInt(String.valueOf(value));
}
private void putIfNotNull(Map<String, Object> target, String key, Object value) {
if (value != null) {
target.put(key, value);
}
}
private String truncate(String value, int maxLength) {
if (value == null || value.length() <= maxLength) {
return value;
}
return value.substring(0, Math.max(0, maxLength)) + "...";
}
private static class DelegatingExecutorService extends AbstractExecutorService {
private final Executor executor;
private volatile boolean shutdown;
private DelegatingExecutorService(Executor executor) {
this.executor = executor == null ? ForkJoinPool.commonPool() : executor;
}
@Override
public void shutdown() {
shutdown = true;
}
@Override
public List<Runnable> shutdownNow() {
shutdown = true;
return List.of();
}
@Override
public boolean isShutdown() {
return shutdown;
}
@Override
public boolean isTerminated() {
return shutdown;
}
@Override
public boolean awaitTermination(long timeout, TimeUnit unit) {
return shutdown;
}
@Override
public void execute(Runnable command) {
executor.execute(command);
}
}
}

View File

@@ -0,0 +1,153 @@
package com.easyagents.agent.runtime.tool.asynctool;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 异步工具提交结果。
*/
public class AsyncToolSubmitResult {
private String taskId;
private AsyncToolTaskStatus status;
/**
* 提交后调用方已读取到的事件位置,后续 observe 可从该位置继续增量读取。
*/
private Long cursor;
private String summary;
private String nextAction;
private Map<String, Object> payload = new LinkedHashMap<>();
private Map<String, Object> metadata = new LinkedHashMap<>();
/**
* 创建空提交结果。
*/
public AsyncToolSubmitResult() {
}
/**
* 获取任务 ID。
*
* @return 任务 ID
*/
public String getTaskId() {
return taskId;
}
/**
* 设置任务 ID。
*
* @param taskId 任务 ID
*/
public void setTaskId(String taskId) {
this.taskId = taskId;
}
/**
* 获取任务状态。
*
* @return 任务状态
*/
public AsyncToolTaskStatus getStatus() {
return status;
}
/**
* 设置任务状态。
*
* @param status 任务状态
*/
public void setStatus(AsyncToolTaskStatus status) {
this.status = status;
}
/**
* 获取当前事件读取位置。
*
* @return 当前事件读取位置
*/
public Long getCursor() {
return cursor;
}
/**
* 设置当前事件读取位置。
*
* @param cursor 当前事件读取位置
*/
public void setCursor(Long cursor) {
this.cursor = cursor;
}
/**
* 获取摘要。
*
* @return 摘要
*/
public String getSummary() {
return summary;
}
/**
* 设置摘要。
*
* @param summary 摘要
*/
public void setSummary(String summary) {
this.summary = summary;
}
/**
* 获取下一步建议。
*
* @return 下一步建议
*/
public String getNextAction() {
return nextAction;
}
/**
* 设置下一步建议。
*
* @param nextAction 下一步建议
*/
public void setNextAction(String nextAction) {
this.nextAction = nextAction;
}
/**
* 获取业务扩展载荷。
*
* @return 业务扩展载荷
*/
public Map<String, Object> getPayload() {
return payload;
}
/**
* 设置业务扩展载荷。
*
* @param payload 业务扩展载荷
*/
public void setPayload(Map<String, Object> payload) {
this.payload = payload == null ? new LinkedHashMap<>() : payload;
}
/**
* 获取元数据。
*
* @return 元数据
*/
public Map<String, Object> getMetadata() {
return metadata;
}
/**
* 设置元数据。
*
* @param metadata 元数据
*/
public void setMetadata(Map<String, Object> metadata) {
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
}
}

View File

@@ -0,0 +1,116 @@
package com.easyagents.agent.runtime.tool.asynctool;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 异步工具任务事件。
*/
public class AsyncToolTaskEvent {
/**
* 任务内单调递增事件序号,用于 cursor 增量读取。
*/
private Long sequence;
private String type;
private String text;
private Instant createdAt = Instant.now();
private Map<String, Object> payload = new LinkedHashMap<>();
/**
* 创建空任务事件。
*/
public AsyncToolTaskEvent() {
}
/**
* 获取事件序号。
*
* @return 事件序号
*/
public Long getSequence() {
return sequence;
}
/**
* 设置事件序号。
*
* @param sequence 事件序号
*/
public void setSequence(Long sequence) {
this.sequence = sequence;
}
/**
* 获取事件类型。
*
* @return 事件类型
*/
public String getType() {
return type;
}
/**
* 设置事件类型。
*
* @param type 事件类型
*/
public void setType(String type) {
this.type = type;
}
/**
* 获取事件文本。
*
* @return 事件文本
*/
public String getText() {
return text;
}
/**
* 设置事件文本。
*
* @param text 事件文本
*/
public void setText(String text) {
this.text = text;
}
/**
* 获取事件创建时间。
*
* @return 事件创建时间
*/
public Instant getCreatedAt() {
return createdAt;
}
/**
* 设置事件创建时间。
*
* @param createdAt 事件创建时间
*/
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt == null ? Instant.now() : createdAt;
}
/**
* 获取业务扩展载荷。
*
* @return 业务扩展载荷
*/
public Map<String, Object> getPayload() {
return payload;
}
/**
* 设置业务扩展载荷。
*
* @param payload 业务扩展载荷
*/
public void setPayload(Map<String, Object> payload) {
this.payload = payload == null ? new LinkedHashMap<>() : payload;
}
}

View File

@@ -0,0 +1,76 @@
package com.easyagents.agent.runtime.tool.asynctool;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 异步工具任务列表结果。
*/
public class AsyncToolTaskListResult {
private List<AsyncToolTaskSummary> tasks = new ArrayList<>();
private Map<String, Object> payload = new LinkedHashMap<>();
private Map<String, Object> metadata = new LinkedHashMap<>();
/**
* 创建空任务列表结果。
*/
public AsyncToolTaskListResult() {
}
/**
* 获取任务摘要列表。
*
* @return 任务摘要列表
*/
public List<AsyncToolTaskSummary> getTasks() {
return tasks;
}
/**
* 设置任务摘要列表。
*
* @param tasks 任务摘要列表
*/
public void setTasks(List<AsyncToolTaskSummary> tasks) {
this.tasks = tasks == null ? new ArrayList<>() : tasks;
}
/**
* 获取业务扩展载荷。
*
* @return 业务扩展载荷
*/
public Map<String, Object> getPayload() {
return payload;
}
/**
* 设置业务扩展载荷。
*
* @param payload 业务扩展载荷
*/
public void setPayload(Map<String, Object> payload) {
this.payload = payload == null ? new LinkedHashMap<>() : payload;
}
/**
* 获取元数据。
*
* @return 元数据
*/
public Map<String, Object> getMetadata() {
return metadata;
}
/**
* 设置元数据。
*
* @param metadata 元数据
*/
public void setMetadata(Map<String, Object> metadata) {
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
}
}

View File

@@ -0,0 +1,78 @@
package com.easyagents.agent.runtime.tool.asynctool;
/**
* 异步工具任务对 Agent Runtime 暴露的统一状态。
*/
public enum AsyncToolTaskStatus {
/**
* 任务已创建,等待业务侧执行。
*/
PENDING,
/**
* 任务正在执行。
*/
RUNNING,
/**
* 任务执行成功,结果可用。
*/
SUCCEEDED,
/**
* 任务执行失败。
*/
FAILED,
/**
* 任务正在取消。
*/
CANCELLING,
/**
* 任务已取消。
*/
CANCELLED,
/**
* 任务执行超时。
*/
TIMEOUT;
/**
* 判断状态是否为终态。
*
* @return 终态返回 true
*/
public boolean isTerminal() {
return this == SUCCEEDED || this == FAILED || this == CANCELLED || this == TIMEOUT;
}
/**
* 判断状态是否仍在执行或等待执行。
*
* @return 仍在运行返回 true
*/
public boolean isRunning() {
return this == PENDING || this == RUNNING || this == CANCELLING;
}
/**
* 判断状态是否表示成功。
*
* @return 成功返回 true
*/
public boolean isSuccess() {
return this == SUCCEEDED;
}
/**
* 判断状态是否表示失败类终态。
*
* @return 失败、取消或超时返回 true
*/
public boolean isFailure() {
return this == FAILED || this == CANCELLED || this == TIMEOUT;
}
}

View File

@@ -0,0 +1,132 @@
package com.easyagents.agent.runtime.tool.asynctool;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 异步工具任务摘要。
*/
public class AsyncToolTaskSummary {
private String taskId;
private AsyncToolTaskStatus status;
private String summary;
private Instant createdAt;
private Instant updatedAt;
private Map<String, Object> payload = new LinkedHashMap<>();
/**
* 创建空任务摘要。
*/
public AsyncToolTaskSummary() {
}
/**
* 获取任务 ID。
*
* @return 任务 ID
*/
public String getTaskId() {
return taskId;
}
/**
* 设置任务 ID。
*
* @param taskId 任务 ID
*/
public void setTaskId(String taskId) {
this.taskId = taskId;
}
/**
* 获取任务状态。
*
* @return 任务状态
*/
public AsyncToolTaskStatus getStatus() {
return status;
}
/**
* 设置任务状态。
*
* @param status 任务状态
*/
public void setStatus(AsyncToolTaskStatus status) {
this.status = status;
}
/**
* 获取任务摘要。
*
* @return 任务摘要
*/
public String getSummary() {
return summary;
}
/**
* 设置任务摘要。
*
* @param summary 任务摘要
*/
public void setSummary(String summary) {
this.summary = summary;
}
/**
* 获取创建时间。
*
* @return 创建时间
*/
public Instant getCreatedAt() {
return createdAt;
}
/**
* 设置创建时间。
*
* @param createdAt 创建时间
*/
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
/**
* 获取更新时间。
*
* @return 更新时间
*/
public Instant getUpdatedAt() {
return updatedAt;
}
/**
* 设置更新时间。
*
* @param updatedAt 更新时间
*/
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
/**
* 获取业务扩展载荷。
*
* @return 业务扩展载荷
*/
public Map<String, Object> getPayload() {
return payload;
}
/**
* 设置业务扩展载荷。
*
* @param payload 业务扩展载荷
*/
public void setPayload(Map<String, Object> payload) {
this.payload = payload == null ? new LinkedHashMap<>() : payload;
}
}

View File

@@ -0,0 +1,312 @@
package com.easyagents.agent.runtime.tool.asynctool;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 异步工具任务视图。
*
* <p>observe 和 result 子工具共用该实体。未完成时表达观察态,完成时可同时携带最终结果。</p>
*/
public class AsyncToolTaskView {
private String taskId;
private AsyncToolTaskStatus status;
/**
* 调用方本次传入的已读事件位置,用于增量读取任务事件。
*/
private Long cursor;
/**
* 服务端返回的下一次观察起点,调用方下次应使用该值继续读取增量事件。
*/
private Long nextCursor;
private Integer progress;
private String summary;
private String nextAction;
private List<AsyncToolTaskEvent> events = new ArrayList<>();
private Object result;
private String errorMessage;
private String errorType;
private Boolean terminal;
private Boolean resultAvailable;
private Map<String, Object> payload = new LinkedHashMap<>();
private Map<String, Object> metadata = new LinkedHashMap<>();
/**
* 创建空任务视图。
*/
public AsyncToolTaskView() {
}
/**
* 获取任务 ID。
*
* @return 任务 ID
*/
public String getTaskId() {
return taskId;
}
/**
* 设置任务 ID。
*
* @param taskId 任务 ID
*/
public void setTaskId(String taskId) {
this.taskId = taskId;
}
/**
* 获取任务状态。
*
* @return 任务状态
*/
public AsyncToolTaskStatus getStatus() {
return status;
}
/**
* 设置任务状态。
*
* @param status 任务状态
*/
public void setStatus(AsyncToolTaskStatus status) {
this.status = status;
}
/**
* 获取本次请求的已读事件位置。
*
* @return 已读事件位置
*/
public Long getCursor() {
return cursor;
}
/**
* 设置本次请求的已读事件位置。
*
* @param cursor 已读事件位置
*/
public void setCursor(Long cursor) {
this.cursor = cursor;
}
/**
* 获取下一次观察起点。
*
* @return 下一次观察起点
*/
public Long getNextCursor() {
return nextCursor;
}
/**
* 设置下一次观察起点。
*
* @param nextCursor 下一次观察起点
*/
public void setNextCursor(Long nextCursor) {
this.nextCursor = nextCursor;
}
/**
* 获取进度百分比。
*
* @return 进度百分比
*/
public Integer getProgress() {
return progress;
}
/**
* 设置进度百分比。
*
* @param progress 进度百分比
*/
public void setProgress(Integer progress) {
this.progress = progress;
}
/**
* 获取摘要。
*
* @return 摘要
*/
public String getSummary() {
return summary;
}
/**
* 设置摘要。
*
* @param summary 摘要
*/
public void setSummary(String summary) {
this.summary = summary;
}
/**
* 获取下一步建议。
*
* @return 下一步建议
*/
public String getNextAction() {
return nextAction;
}
/**
* 设置下一步建议。
*
* @param nextAction 下一步建议
*/
public void setNextAction(String nextAction) {
this.nextAction = nextAction;
}
/**
* 获取本次增量事件。
*
* @return 本次增量事件
*/
public List<AsyncToolTaskEvent> getEvents() {
return events;
}
/**
* 设置本次增量事件。
*
* @param events 本次增量事件
*/
public void setEvents(List<AsyncToolTaskEvent> events) {
this.events = events == null ? new ArrayList<>() : events;
}
/**
* 获取最终结果。
*
* @return 最终结果
*/
public Object getResult() {
return result;
}
/**
* 设置最终结果。
*
* @param result 最终结果
*/
public void setResult(Object result) {
this.result = result;
}
/**
* 获取错误消息。
*
* @return 错误消息
*/
public String getErrorMessage() {
return errorMessage;
}
/**
* 设置错误消息。
*
* @param errorMessage 错误消息
*/
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
/**
* 获取错误类型。
*
* @return 错误类型
*/
public String getErrorType() {
return errorType;
}
/**
* 设置错误类型。
*
* @param errorType 错误类型
*/
public void setErrorType(String errorType) {
this.errorType = errorType;
}
/**
* 获取是否终态。
*
* @return 是否终态
*/
public Boolean getTerminal() {
return terminal;
}
/**
* 设置是否终态。
*
* @param terminal 是否终态
*/
public void setTerminal(Boolean terminal) {
this.terminal = terminal;
}
/**
* 获取最终结果是否可用。
*
* @return 最终结果是否可用
*/
public Boolean getResultAvailable() {
return resultAvailable;
}
/**
* 设置最终结果是否可用。
*
* @param resultAvailable 最终结果是否可用
*/
public void setResultAvailable(Boolean resultAvailable) {
this.resultAvailable = resultAvailable;
}
/**
* 获取业务扩展载荷。
*
* @return 业务扩展载荷
*/
public Map<String, Object> getPayload() {
return payload;
}
/**
* 设置业务扩展载荷。
*
* @param payload 业务扩展载荷
*/
public void setPayload(Map<String, Object> payload) {
this.payload = payload == null ? new LinkedHashMap<>() : payload;
}
/**
* 获取元数据。
*
* @return 元数据
*/
public Map<String, Object> getMetadata() {
return metadata;
}
/**
* 设置元数据。
*
* @param metadata 元数据
*/
public void setMetadata(Map<String, Object> metadata) {
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
}
}

View File

@@ -0,0 +1,195 @@
package com.easyagents.agent.runtime.tool.operate;
import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
import com.easyagents.agent.runtime.tool.AgentToolCategory;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import com.easyagents.agent.runtime.tool.AgentToolVisibility;
import io.agentscope.core.tool.Toolkit;
import java.nio.file.Path;
import java.util.*;
/**
* AgentScope 内置操作工具适配器。
*
* <p>该适配器将 Easy-Agents 的操作工具声明转换为与 AgentScope 1.x 工具名和 Schema 兼容的
* 受控实现。Shell 人工审批继续通过 Easy-Agents {@code ToolHitlInterceptor} 处理,以保持
* SSE 暂停、恢复和审计语义一致。
*/
public class AgentOperateToolAdapter {
public static final String VIEW_TEXT_FILE_TOOL = "view_text_file";
public static final String LIST_DIRECTORY_TOOL = "list_directory";
public static final String WRITE_TEXT_FILE_TOOL = "write_text_file";
public static final String INSERT_TEXT_FILE_TOOL = "insert_text_file";
public static final String APPLY_PATCH_TOOL = "apply_patch";
public static final String EXECUTE_SHELL_COMMAND_TOOL = "execute_shell_command";
/**
* 将操作工具声明注册到 Toolkit并返回供 HITL 拦截器使用的工具声明。
*
* @param specs 操作工具声明
* @param toolkit AgentScope Toolkit
* @return 已启用操作工具对应的 AgentToolSpec
*/
public List<AgentToolSpec> register(List<AgentOperateToolSpec> specs, Toolkit toolkit) {
List<AgentToolSpec> toolSpecs = new ArrayList<>();
if (specs == null || specs.isEmpty()) {
return toolSpecs;
}
for (AgentOperateToolSpec spec : specs) {
if (spec == null || !spec.isEnabled()) {
continue;
}
registerOne(spec, toolkit, toolSpecs);
}
return toolSpecs;
}
/**
* 解析已启用操作工具会注册到 AgentScope 的工具名称。
*
* @param specs 操作工具声明
* @return 工具名称集合
*/
public Set<String> enabledToolNames(List<AgentOperateToolSpec> specs) {
Set<String> names = new LinkedHashSet<>();
if (specs == null || specs.isEmpty()) {
return names;
}
for (AgentOperateToolSpec spec : specs) {
if (spec == null || !spec.isEnabled() || spec.getType() == null) {
continue;
}
switch (spec.getType()) {
case READ_FILE -> {
names.add(VIEW_TEXT_FILE_TOOL);
names.add(LIST_DIRECTORY_TOOL);
}
case WRITE_FILE -> {
names.add(WRITE_TEXT_FILE_TOOL);
names.add(INSERT_TEXT_FILE_TOOL);
}
case PATCH -> names.add(APPLY_PATCH_TOOL);
case SHELL -> names.add(EXECUTE_SHELL_COMMAND_TOOL);
default -> {
}
}
}
return names;
}
private void registerOne(AgentOperateToolSpec spec, Toolkit toolkit, List<AgentToolSpec> toolSpecs) {
AgentOperateToolType type = spec.getType();
if (type == null) {
throw new AgentRuntimeException("Agent operate tool type is required.");
}
WorkspacePathGuard pathGuard = createPathGuard(spec);
WorkspaceQuotaGuard quotaGuard = new WorkspaceQuotaGuard(
pathGuard, spec.getWorkspaceQuotaLimits(), spec.getWorkspaceQuotaHook());
switch (type) {
case READ_FILE -> {
assertNoToolConflict(toolkit, VIEW_TEXT_FILE_TOOL);
assertNoToolConflict(toolkit, LIST_DIRECTORY_TOOL);
SafeReadFileTool readFileTool = new SafeReadFileTool(pathGuard, quotaGuard);
toolkit.registerAgentTool(readFileTool.viewTextFileTool());
toolkit.registerAgentTool(readFileTool.listDirectoryTool());
toolSpecs.add(toolSpec(spec, VIEW_TEXT_FILE_TOOL, "View text file content.", false));
toolSpecs.add(toolSpec(spec, LIST_DIRECTORY_TOOL, "List files and directories.", false));
}
case WRITE_FILE -> {
assertNoToolConflict(toolkit, WRITE_TEXT_FILE_TOOL);
assertNoToolConflict(toolkit, INSERT_TEXT_FILE_TOOL);
SafeWriteFileTool writeFileTool = new SafeWriteFileTool(pathGuard, quotaGuard);
toolkit.registerAgentTool(writeFileTool.writeTextFileTool());
toolkit.registerAgentTool(writeFileTool.insertTextFileTool());
toolSpecs.add(toolSpec(spec, WRITE_TEXT_FILE_TOOL, "Write or replace text file content.", false));
toolSpecs.add(toolSpec(spec, INSERT_TEXT_FILE_TOOL, "Insert text into a file.", false));
}
case PATCH -> {
assertNoToolConflict(toolkit, APPLY_PATCH_TOOL);
toolkit.registerAgentTool(new ApplyPatchTool(
pathGuard, quotaGuard, spec.getPatchMaxSize(),
spec.getPatchMaxFiles(), spec.getPatchMaxAffectedBytes()));
toolSpecs.add(toolSpec(spec, APPLY_PATCH_TOOL, "Apply a workspace text patch.", false));
}
case SHELL -> {
assertNoToolConflict(toolkit, EXECUTE_SHELL_COMMAND_TOOL);
ControlledShellTool shellTool = new ControlledShellTool(pathGuard, quotaGuard, spec);
toolkit.registerAgentTool(shellTool);
AgentToolSpec shellToolSpec = toolSpec(
spec, EXECUTE_SHELL_COMMAND_TOOL, "Execute shell command.", true);
if (shellToolSpec.isApprovalRequired()) {
// 命令级审批策略服从 Agent 的 Shell 审批开关;关闭后仅保留安全校验。
shellToolSpec.setApprovalPolicy(shellTool::approvalEvaluation);
}
toolSpecs.add(shellToolSpec);
}
default -> throw new AgentRuntimeException("Unsupported agent operate tool type: " + type);
}
}
private WorkspacePathGuard createPathGuard(AgentOperateToolSpec spec) {
String baseDir = spec.getBaseDir();
if (baseDir == null || baseDir.isBlank()) {
throw new AgentRuntimeException("Agent operate tool baseDir is required.");
}
if (!Path.of(baseDir).isAbsolute()) {
throw new AgentRuntimeException("Agent operate tool baseDir must be an absolute path.");
}
try {
return new WorkspacePathGuard(Path.of(baseDir).toAbsolutePath().normalize());
} catch (RuntimeException error) {
if (error instanceof AgentRuntimeException runtimeError) {
throw runtimeError;
}
throw new AgentRuntimeException("Agent operate tool baseDir is invalid.", error);
}
}
private AgentToolSpec toolSpec(AgentOperateToolSpec operateSpec,
String toolName,
String description,
boolean defaultApprovalRequired) {
boolean approvalRequired = operateSpec.getApprovalRequired() == null
? defaultApprovalRequired : operateSpec.getApprovalRequired();
AgentToolSpec toolSpec = new AgentToolSpec();
toolSpec.setName(toolName);
toolSpec.setDescription(description);
toolSpec.setCategory(AgentToolCategory.CUSTOM);
toolSpec.setVisibility(AgentToolVisibility.VISIBLE);
toolSpec.setApprovalRequired(approvalRequired);
toolSpec.setApprovalRequest(approvalRequest(operateSpec, approvalRequired));
toolSpec.setMetadata(metadata(operateSpec, approvalRequired));
return toolSpec;
}
private AgentToolApprovalRequest approvalRequest(AgentOperateToolSpec operateSpec, boolean approvalRequired) {
AgentToolApprovalRequest request = operateSpec.getApprovalRequest();
if (request != null) {
return request;
}
AgentToolApprovalRequest defaultRequest = new AgentToolApprovalRequest();
if (approvalRequired) {
defaultRequest.setApprovalPrompt("请确认是否允许智能体执行该操作工具。");
}
return defaultRequest;
}
private Map<String, Object> metadata(AgentOperateToolSpec spec, boolean approvalRequired) {
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("operateTool", true);
metadata.put("operateToolType", spec.getType().name());
if (spec.getType() == AgentOperateToolType.SHELL && approvalRequired) {
metadata.put("forceApprovalCommands", List.of("rm"));
metadata.put("forceApprovalCommandArgument", "command");
}
return metadata;
}
private void assertNoToolConflict(Toolkit toolkit, String toolName) {
if (toolkit.getTool(toolName) != null) {
throw new AgentRuntimeException("Agent operate tool conflicts with existing tool: " + toolName);
}
}
}

View File

@@ -0,0 +1,341 @@
package com.easyagents.agent.runtime.tool.operate;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
import java.util.LinkedHashSet;
import java.util.Set;
import java.time.Duration;
/**
* Agent 操作类工具声明。
*
* <p>操作类工具由 runtime 适配为与 AgentScope 1.x 契约兼容的受控工具。调用方必须按
* agent、session 或 user 维度传入独立的绝对工作目录,并通过配额与 Shell 参数限制资源使用。
*/
public class AgentOperateToolSpec {
private AgentOperateToolType type;
private boolean enabled = true;
private String baseDir;
private Boolean approvalRequired;
private AgentToolApprovalRequest approvalRequest;
private WorkspaceQuotaLimits workspaceQuotaLimits = WorkspaceQuotaLimits.unlimited();
private transient WorkspaceQuotaHook workspaceQuotaHook = WorkspaceQuotaHook.noop();
private Set<String> shellAllowedCommands = new LinkedHashSet<>(ControlledShellTool.DEFAULT_ALLOWED_COMMANDS);
private String shellCharset = "UTF-8";
private Duration shellDefaultTimeout = Duration.ofSeconds(60);
private Duration shellMaxTimeout = Duration.ofSeconds(300);
private int shellMaxCommandLength = 4096;
private long shellMaxOutputSize = 1024L * 1024L;
private int shellMaxConcurrency = 2;
private long patchMaxSize = 1024L * 1024L;
private int patchMaxFiles = 100;
private long patchMaxAffectedBytes = 16L * 1024L * 1024L;
/**
* 获取操作工具类型。
*
* @return 操作工具类型
*/
public AgentOperateToolType getType() {
return type;
}
/**
* 设置操作工具类型。
*
* @param type 操作工具类型
*/
public void setType(AgentOperateToolType type) {
this.type = type;
}
/**
* 返回是否启用该操作工具。
*
* @return 启用时为 true
*/
public boolean isEnabled() {
return enabled;
}
/**
* 设置是否启用该操作工具。
*
* @param enabled 启用标记
*/
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
/**
* 获取操作工具工作目录。
*
* @return 绝对工作目录
*/
public String getBaseDir() {
return baseDir;
}
/**
* 设置操作工具工作目录。
*
* @param baseDir 绝对工作目录
*/
public void setBaseDir(String 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;
}
/**
* 获取审批开关覆盖值。
*
* @return 审批覆盖值null 表示使用工具类型默认值
*/
public Boolean getApprovalRequired() {
return approvalRequired;
}
/**
* 设置审批开关覆盖值。
*
* @param approvalRequired 审批覆盖值
*/
public void setApprovalRequired(Boolean approvalRequired) {
this.approvalRequired = approvalRequired;
}
/**
* 获取审批请求配置。
*
* @return 审批请求配置
*/
public AgentToolApprovalRequest getApprovalRequest() {
return approvalRequest;
}
/**
* 设置审批请求配置。
*
* @param approvalRequest 审批请求配置
*/
public void setApprovalRequest(AgentToolApprovalRequest approvalRequest) {
this.approvalRequest = approvalRequest;
}
/**
* 获取 Shell 命令白名单。
*
* @return Shell 命令白名单
*/
public Set<String> getShellAllowedCommands() {
return shellAllowedCommands;
}
/**
* 设置 Shell 命令白名单。
*
* @param shellAllowedCommands Shell 命令白名单
*/
public void setShellAllowedCommands(Set<String> shellAllowedCommands) {
this.shellAllowedCommands = shellAllowedCommands == null ? new LinkedHashSet<>() : new LinkedHashSet<>(shellAllowedCommands);
}
/**
* 获取 Shell 输出字符集名称。
*
* @return 字符集名称
*/
public String getShellCharset() {
return shellCharset;
}
/**
* 设置 Shell 输出字符集名称。
*
* @param shellCharset 字符集名称
*/
public void setShellCharset(String shellCharset) {
this.shellCharset = shellCharset;
}
/**
* 获取 Shell 默认超时。
*
* @return 默认超时
*/
public Duration getShellDefaultTimeout() {
return shellDefaultTimeout;
}
/**
* 设置 Shell 默认超时。
*
* @param shellDefaultTimeout 默认超时
*/
public void setShellDefaultTimeout(Duration shellDefaultTimeout) {
this.shellDefaultTimeout = shellDefaultTimeout;
}
/**
* 获取 Shell 最大超时。
*
* @return 最大超时
*/
public Duration getShellMaxTimeout() {
return shellMaxTimeout;
}
/**
* 设置 Shell 最大超时。
*
* @param shellMaxTimeout 最大超时
*/
public void setShellMaxTimeout(Duration shellMaxTimeout) {
this.shellMaxTimeout = shellMaxTimeout;
}
/**
* 获取 Shell 命令最大长度。
*
* @return 最大字符数
*/
public int getShellMaxCommandLength() {
return shellMaxCommandLength;
}
/**
* 设置 Shell 命令最大长度。
*
* @param shellMaxCommandLength 最大字符数
*/
public void setShellMaxCommandLength(int shellMaxCommandLength) {
this.shellMaxCommandLength = shellMaxCommandLength;
}
/**
* 获取 Shell 单次标准输出和错误输出各自的最大字节数。
*
* @return 最大字节数
*/
public long getShellMaxOutputSize() {
return shellMaxOutputSize;
}
/**
* 设置 Shell 单次标准输出和错误输出各自的最大字节数。
*
* @param shellMaxOutputSize 最大字节数
*/
public void setShellMaxOutputSize(long shellMaxOutputSize) {
this.shellMaxOutputSize = shellMaxOutputSize;
}
/**
* 获取 JVM 实例级 Shell 最大并发数。
*
* @return 最大并发数
*/
public int getShellMaxConcurrency() {
return shellMaxConcurrency;
}
/**
* 设置 JVM 实例级 Shell 最大并发数。
*
* @param shellMaxConcurrency 最大并发数
*/
public void setShellMaxConcurrency(int shellMaxConcurrency) {
this.shellMaxConcurrency = shellMaxConcurrency;
}
/**
* 获取 Patch 输入最大字节数。
*
* @return 最大字节数
*/
public long getPatchMaxSize() {
return patchMaxSize;
}
/**
* 设置 Patch 输入最大字节数。
*
* @param patchMaxSize 最大字节数
*/
public void setPatchMaxSize(long patchMaxSize) {
this.patchMaxSize = patchMaxSize;
}
/**
* 获取 Patch 最大影响文件数。
*
* @return 最大文件数
*/
public int getPatchMaxFiles() {
return patchMaxFiles;
}
/**
* 设置 Patch 最大影响文件数。
*
* @param patchMaxFiles 最大文件数
*/
public void setPatchMaxFiles(int patchMaxFiles) {
this.patchMaxFiles = patchMaxFiles;
}
/**
* 获取 Patch 影响内容最大总字节数。
*
* @return 最大字节数
*/
public long getPatchMaxAffectedBytes() {
return patchMaxAffectedBytes;
}
/**
* 设置 Patch 影响内容最大总字节数。
*
* @param patchMaxAffectedBytes 最大字节数
*/
public void setPatchMaxAffectedBytes(long patchMaxAffectedBytes) {
this.patchMaxAffectedBytes = patchMaxAffectedBytes;
}
}

View File

@@ -0,0 +1,27 @@
package com.easyagents.agent.runtime.tool.operate;
/**
* Agent 操作类工具类型。
*/
public enum AgentOperateToolType {
/**
* 读取文本文件与列出目录。
*/
READ_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,106 @@
package com.easyagents.agent.runtime.agentscope;
import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.model.AgentGenerationOptions;
import com.easyagents.agent.runtime.model.AgentHttpVersionPolicy;
import com.easyagents.agent.runtime.model.AgentModelProviderType;
import com.easyagents.agent.runtime.model.AgentModelSpec;
import io.agentscope.core.model.transport.HttpTransport;
import io.agentscope.core.model.transport.HttpTransportFactory;
import io.agentscope.core.model.transport.HttpVersion;
import io.agentscope.core.model.transport.JdkHttpTransport;
import org.junit.Assert;
import org.junit.Test;
import java.lang.reflect.Field;
import java.net.http.HttpClient;
/**
* Agent HTTP Transport 策略测试。
*/
public class AgentHttpTransportProviderTest {
/**
* 验证相同策略复用 Transport且显式策略使用预期 JDK HTTP 版本。
*
* @throws Exception 反射读取 JDK client 失败时抛出
*/
@Test
public void shouldReuseTransportAndConfigureExpectedHttpVersion() throws Exception {
AgentHttpTransportProvider provider = AgentHttpTransportProvider.shared();
HttpTransport http11 = provider.getTransport(AgentHttpVersionPolicy.HTTP_1_1, "https://example.com");
HttpTransport http11Again = provider.getTransport(AgentHttpVersionPolicy.HTTP_1_1, "http://example.com");
HttpTransport http2 = provider.getTransport(AgentHttpVersionPolicy.HTTP_2_PREFERRED, "http://example.com");
Assert.assertSame(http11, http11Again);
Assert.assertNotSame(http11, http2);
Assert.assertEquals(HttpClient.Version.HTTP_1_1, httpClient(http11).version());
Assert.assertEquals(HttpClient.Version.HTTP_2, httpClient(http2).version());
Assert.assertTrue(HttpTransportFactory.isManaged(http11));
Assert.assertTrue(HttpTransportFactory.isManaged(http2));
Assert.assertEquals(HttpVersion.HTTP_1_1,
AgentHttpTransportProvider.resolveHttpVersion(AgentHttpVersionPolicy.HTTP_1_1));
Assert.assertEquals(HttpVersion.HTTP_2,
AgentHttpTransportProvider.resolveHttpVersion(AgentHttpVersionPolicy.HTTP_2_PREFERRED));
}
/**
* 验证 AUTO 根据 URL 协议选择安全 Transport。
*
* @throws Exception 反射读取 JDK client 失败时抛出
*/
@Test
public void autoShouldResolveTransportFromBaseUrlScheme() throws Exception {
AgentHttpTransportProvider provider = AgentHttpTransportProvider.shared();
HttpTransport http = provider.getTransport(AgentHttpVersionPolicy.AUTO, "http://example.com/v1");
HttpTransport https = provider.getTransport(AgentHttpVersionPolicy.AUTO, "https://example.com/v1");
HttpTransport unknown = provider.getTransport(AgentHttpVersionPolicy.AUTO, "example.com/v1");
Assert.assertSame(provider.getTransport(AgentHttpVersionPolicy.HTTP_1_1, null), http);
Assert.assertSame(provider.getTransport(AgentHttpVersionPolicy.HTTP_2_PREFERRED, null), https);
Assert.assertEquals(HttpClient.Version.HTTP_1_1, httpClient(http).version());
Assert.assertEquals(HttpClient.Version.HTTP_2, httpClient(https).version());
Assert.assertSame(HttpTransportFactory.getDefault(), unknown);
Assert.assertEquals(AgentHttpVersionPolicy.HTTP_1_1,
AgentHttpTransportProvider.resolveEffectivePolicy(AgentHttpVersionPolicy.AUTO, "HTTP://EXAMPLE.COM"));
Assert.assertEquals(AgentHttpVersionPolicy.HTTP_2_PREFERRED,
AgentHttpTransportProvider.resolveEffectivePolicy(AgentHttpVersionPolicy.AUTO, "HTTPS://EXAMPLE.COM"));
Assert.assertEquals(AgentHttpVersionPolicy.AUTO,
AgentHttpTransportProvider.resolveEffectivePolicy(AgentHttpVersionPolicy.AUTO, "not a uri"));
}
/**
* 验证不支持注入 Transport 的 Provider 会返回明确错误。
*/
@Test
public void unsupportedProviderShouldRejectExplicitHttpPolicy() {
AgentModelSpec spec = new AgentModelSpec();
spec.setProviderType(AgentModelProviderType.ANTHROPIC);
spec.setModelName("claude-test");
spec.setApiKey("test-key");
spec.setHttpVersionPolicy(AgentHttpVersionPolicy.HTTP_1_1);
try {
new AgentScopeModelFactory().create(spec, new AgentGenerationOptions());
Assert.fail("Expected unsupported HTTP transport policy error");
} catch (AgentRuntimeException exception) {
Assert.assertTrue(exception.getMessage().contains("HTTP_1_1"));
Assert.assertTrue(exception.getMessage().contains("ANTHROPIC"));
}
}
/**
* 获取 Transport 内部复用的 JDK HttpClient。
*
* @param transport Transport 实例
* @return JDK HttpClient
* @throws Exception 反射失败时抛出
*/
private HttpClient httpClient(HttpTransport transport) throws Exception {
Assert.assertTrue(transport instanceof JdkHttpTransport);
Field field = JdkHttpTransport.class.getDeclaredField("client");
field.setAccessible(true);
return (HttpClient) field.get(transport);
}
}

View File

@@ -0,0 +1,213 @@
package com.easyagents.agent.runtime.agentscope;
import com.easyagents.agent.runtime.model.AgentGenerationOptions;
import com.easyagents.agent.runtime.model.AgentMessageContentFormat;
import com.easyagents.agent.runtime.model.AgentModelProviderType;
import com.easyagents.agent.runtime.model.AgentModelSpec;
import io.agentscope.core.formatter.openai.dto.OpenAIContentPart;
import io.agentscope.core.formatter.openai.dto.OpenAIMessage;
import io.agentscope.core.message.Base64Source;
import io.agentscope.core.message.ImageBlock;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.MsgRole;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock;
import io.agentscope.core.model.OpenAIChatModel;
import org.junit.Assert;
import org.junit.Test;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Map;
/**
* Agent OpenAI Chat 消息格式兼容测试。
*/
public class AgentOpenAIChatFormatterTest {
/**
* 验证多轮上下文中全部纯文本消息都转换为 text 内容块数组。
*/
@Test
public void shouldConvertAllTextMessagesToContentParts() {
AgentOpenAIChatFormatter formatter = new AgentOpenAIChatFormatter();
List<OpenAIMessage> messages = formatter.format(List.of(
Msg.builder().role(MsgRole.SYSTEM).textContent("system prompt").build(),
Msg.builder().role(MsgRole.USER).textContent("hello").build(),
Msg.builder().role(MsgRole.ASSISTANT).textContent("hi").build(),
Msg.builder().role(MsgRole.USER).textContent("follow up").build()));
assertTextContent(messages.get(0), "system prompt");
assertTextContent(messages.get(1), "hello");
assertTextContent(messages.get(2), "hi");
assertTextContent(messages.get(3), "follow up");
}
/**
* 验证图片加文本消息保持已有内容块数组和顺序。
*/
@Test
public void shouldPreserveMultimodalContentParts() {
AgentOpenAIChatFormatter formatter = new AgentOpenAIChatFormatter();
ImageBlock image = ImageBlock.builder()
.source(Base64Source.builder()
.mediaType("image/png")
.data("aW1hZ2U=")
.build())
.build();
List<OpenAIMessage> messages = formatter.format(List.of(
Msg.builder()
.role(MsgRole.USER)
.content(TextBlock.builder().text("describe").build(), image)
.build()));
List<OpenAIContentPart> contentParts = messages.get(0).getContentAsList();
Assert.assertNotNull(contentParts);
Assert.assertEquals(2, contentParts.size());
Assert.assertEquals("text", contentParts.get(0).getType());
Assert.assertEquals("describe", contentParts.get(0).getText());
Assert.assertEquals("image_url", contentParts.get(1).getType());
}
/**
* 验证工具调用和工具结果的附属字段在 content 数组化后保持不变。
*/
@Test
public void shouldPreserveToolCallFieldsAndConvertToolResult() {
AgentOpenAIChatFormatter formatter = new AgentOpenAIChatFormatter();
ToolUseBlock toolUse = ToolUseBlock.builder()
.id("call-1")
.name("lookup")
.input(Map.of("query", "weather"))
.build();
ToolResultBlock toolResult = ToolResultBlock.builder()
.id("call-1")
.name("lookup")
.output(TextBlock.builder().text("sunny").build())
.build();
List<OpenAIMessage> messages = formatter.format(List.of(
Msg.builder().role(MsgRole.ASSISTANT).content(toolUse).build(),
Msg.builder().role(MsgRole.TOOL).content(toolResult).build()));
assertTextContent(messages.get(0), "");
Assert.assertNotNull(messages.get(0).getToolCalls());
Assert.assertEquals(1, messages.get(0).getToolCalls().size());
Assert.assertEquals("call-1", messages.get(0).getToolCalls().get(0).getId());
assertTextContent(messages.get(1), "sunny");
Assert.assertEquals("call-1", messages.get(1).getToolCallId());
}
/**
* 验证专用 Formatter 的供应商规则执行后仍会统一 content 数组。
*/
@Test
public void shouldPreserveDeepSeekAndGlmRulesWithContentParts() {
List<OpenAIMessage> deepSeekMessages = new AgentDeepSeekChatFormatter().format(List.of(
Msg.builder().role(MsgRole.SYSTEM).textContent("system prompt").build()));
Assert.assertEquals("user", deepSeekMessages.get(0).getRole());
assertTextContent(deepSeekMessages.get(0), "system prompt");
List<OpenAIMessage> glmMessages = new AgentGLMChatFormatter().format(List.of(
Msg.builder().role(MsgRole.SYSTEM).textContent("system prompt").build()));
Assert.assertEquals(2, glmMessages.size());
Assert.assertEquals("system", glmMessages.get(0).getRole());
Assert.assertEquals("user", glmMessages.get(1).getRole());
assertTextContent(glmMessages.get(0), "system prompt");
assertTextContent(glmMessages.get(1), "");
}
/**
* 验证内容块数组策略会安装 EasyAgents 的 OpenAI Formatter。
*
* @throws Exception 反射读取 Formatter 失败时抛出
*/
@Test
public void textPartsPolicyShouldInstallAgentOpenAIFormatter() throws Exception {
AgentModelSpec spec = new AgentModelSpec();
spec.setProviderType(AgentModelProviderType.OPENAI_COMPATIBLE);
spec.setModelName("vlm-test");
spec.setBaseUrl("http://model.example.com/v1");
spec.setApiKey("test-key");
spec.setMessageContentFormat(AgentMessageContentFormat.TEXT_PARTS);
OpenAIChatModel model = (OpenAIChatModel) new AgentScopeModelFactory()
.create(spec, new AgentGenerationOptions());
Field formatterField = OpenAIChatModel.class.getDeclaredField("formatter");
formatterField.setAccessible(true);
Assert.assertTrue(formatterField.get(model) instanceof AgentOpenAIChatFormatter);
}
/**
* 验证默认策略继续使用 AgentScope 原生 Formatter。
*
* @throws Exception 反射读取 Formatter 失败时抛出
*/
@Test
public void standardPolicyShouldKeepDefaultOpenAIFormatter() throws Exception {
AgentModelSpec spec = new AgentModelSpec();
spec.setProviderType(AgentModelProviderType.OPENAI_COMPATIBLE);
spec.setModelName("chat-test");
spec.setBaseUrl("http://model.example.com/v1");
spec.setApiKey("test-key");
OpenAIChatModel model = (OpenAIChatModel) new AgentScopeModelFactory()
.create(spec, new AgentGenerationOptions());
Field formatterField = OpenAIChatModel.class.getDeclaredField("formatter");
formatterField.setAccessible(true);
Assert.assertFalse(formatterField.get(model) instanceof AgentOpenAIChatFormatter);
}
/**
* 验证严格数组策略为专用 Provider 安装保留供应商规则的 Formatter。
*
* @throws Exception 反射读取 Formatter 失败时抛出
*/
@Test
public void textPartsPolicyShouldInstallProviderSpecificFormatters() throws Exception {
Assert.assertTrue(formatterFor(AgentModelProviderType.DEEPSEEK)
instanceof AgentDeepSeekChatFormatter);
Assert.assertTrue(formatterFor(AgentModelProviderType.GLM)
instanceof AgentGLMChatFormatter);
}
/**
* 创建指定 Provider 的严格数组模型并读取其 Formatter。
*
* @param providerType Provider 类型
* @return 模型 Formatter
* @throws Exception 反射读取 Formatter 失败时抛出
*/
private Object formatterFor(AgentModelProviderType providerType) throws Exception {
AgentModelSpec spec = new AgentModelSpec();
spec.setProviderType(providerType);
spec.setModelName("provider-test");
spec.setApiKey("test-key");
spec.setMessageContentFormat(AgentMessageContentFormat.TEXT_PARTS);
OpenAIChatModel model = (OpenAIChatModel) new AgentScopeModelFactory()
.create(spec, new AgentGenerationOptions());
Field formatterField = OpenAIChatModel.class.getDeclaredField("formatter");
formatterField.setAccessible(true);
return formatterField.get(model);
}
/**
* 断言消息只包含一个指定文本内容块。
*
* @param message OpenAI 请求消息
* @param expectedText 预期文本
*/
private void assertTextContent(OpenAIMessage message, String expectedText) {
Assert.assertTrue(message.getContent() instanceof List<?>);
List<OpenAIContentPart> contentParts = message.getContentAsList();
Assert.assertNotNull(contentParts);
Assert.assertEquals(1, contentParts.size());
Assert.assertEquals("text", contentParts.get(0).getType());
Assert.assertEquals(expectedText, contentParts.get(0).getText());
}
}

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

@@ -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

@@ -0,0 +1,117 @@
package com.easyagents.agent.runtime.event.interceptor;
import com.easyagents.agent.runtime.agentscope.AgentScopeMessageAdapter;
import com.easyagents.agent.runtime.media.AgentMediaResource;
import com.easyagents.agent.runtime.message.AgentMediaBlock;
import io.agentscope.core.ReActAgent;
import io.agentscope.core.hook.PreReasoningEvent;
import io.agentscope.core.message.Base64Source;
import io.agentscope.core.message.ImageBlock;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.MsgRole;
import io.agentscope.core.message.URLSource;
import io.agentscope.core.model.ChatResponse;
import io.agentscope.core.model.GenerateOptions;
import io.agentscope.core.model.Model;
import io.agentscope.core.model.ToolSchema;
import io.agentscope.core.tool.Toolkit;
import org.junit.Assert;
import org.junit.Test;
import reactor.core.publisher.Flux;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
/**
* 测试模型调用边界的媒体引用解析。
*/
public class MediaReferenceInterceptorTest {
/**
* 验证私有引用仅在模型调用边界转换为 Base64原始记忆消息保持稳定引用。
*/
@Test
public void shouldResolveReferenceToBase64WithoutMutatingOriginalMessage() {
AgentScopeMessageAdapter adapter = new AgentScopeMessageAdapter();
AgentMediaBlock mediaBlock = new AgentMediaBlock("image");
mediaBlock.setReference("draft:upload-1");
mediaBlock.setMimeType("image/png");
Msg original = Msg.builder()
.id("message-1")
.name("user")
.role(MsgRole.USER)
.content(adapter.toContentBlock(mediaBlock))
.build();
AtomicReference<String> resolvedReference = new AtomicReference<>();
MediaReferenceInterceptor interceptor = new MediaReferenceInterceptor(reference -> {
resolvedReference.set(reference);
return new AgentMediaResource("image/png", "png-data".getBytes(StandardCharsets.UTF_8));
});
ReActAgent agent = ReActAgent.builder()
.name("media-test-agent")
.sysPrompt("system")
.model(new EmptyModel())
.toolkit(new Toolkit())
.build();
PreReasoningEvent event = new PreReasoningEvent(agent, "reasoning-1", null, List.of(original));
interceptor.intercept(event).block();
ImageBlock originalImage = original.getFirstContentBlock(ImageBlock.class);
ImageBlock resolvedImage = event.getInputMessages().get(0).getFirstContentBlock(ImageBlock.class);
Assert.assertTrue(originalImage.getSource() instanceof URLSource);
Assert.assertTrue(((URLSource) originalImage.getSource()).getUrl()
.startsWith(AgentScopeMessageAdapter.MEDIA_REFERENCE_SCHEME));
Assert.assertTrue(resolvedImage.getSource() instanceof Base64Source);
Assert.assertEquals("draft:upload-1", resolvedReference.get());
Assert.assertEquals("cG5nLWRhdGE=", ((Base64Source) resolvedImage.getSource()).getData());
}
/**
* 验证消息适配器序列化后仍能恢复不透明媒体引用。
*/
@Test
public void shouldRoundTripStableReferenceThroughMessageAdapter() {
AgentScopeMessageAdapter adapter = new AgentScopeMessageAdapter();
AgentMediaBlock mediaBlock = new AgentMediaBlock("image");
mediaBlock.setReference("formal:message-1:0:image/png");
AgentMediaBlock restored = (AgentMediaBlock) adapter.toAgentBlock(adapter.toContentBlock(mediaBlock));
Assert.assertEquals("formal:message-1:0:image/png", restored.getReference());
Assert.assertNull(restored.getData());
Assert.assertNull(restored.getUrl());
}
/**
* 仅用于构建真实 AgentScope 事件的空模型。
*/
private static final class EmptyModel implements Model {
/**
* 返回空响应流,当前测试不会实际调用模型。
*
* @param messages 输入消息
* @param toolSchemas 工具定义
* @param options 生成参数
* @return 空响应流
*/
@Override
public Flux<ChatResponse> stream(List<Msg> messages,
List<ToolSchema> toolSchemas,
GenerateOptions options) {
return Flux.empty();
}
/**
* 返回测试模型名称。
*
* @return 测试模型名称
*/
@Override
public String getModelName() {
return "media-test-model";
}
}
}

View File

@@ -0,0 +1,390 @@
package com.easyagents.agent.runtime.hitl;
import com.easyagents.agent.runtime.AgentResumeRequest;
import com.easyagents.agent.runtime.AgentRuntimeException;
import org.junit.Assert;
import org.junit.Test;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
/**
* 测试工具审批协调器。
*/
public class AgentToolApprovalCoordinatorTest {
/**
* 验证同批次全部调用批准后才签发逐调用执行授权。
*/
@Test
public void shouldAuthorizeBatchOnlyAfterAllCallsApproved() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
AgentPendingState first = register(coordinator, "call-1", "search", Map.of("q", "first"), "batch-1");
AgentPendingState second = register(coordinator, "call-2", "search", Map.of("q", "second"), "batch-1");
AgentToolApprovalResolution firstResolution = coordinator.resolve(resume(first, true));
Assert.assertEquals(AgentToolApprovalResolution.Status.WAITING, firstResolution.getStatus());
Assert.assertEquals(1, firstResolution.getRemainingStates().size());
assertAuthorizationRejected(coordinator, "call-1", "search", Map.of("q", "first"));
AgentToolApprovalResolution secondResolution = coordinator.resolve(resume(second, true));
Assert.assertEquals(AgentToolApprovalResolution.Status.READY, secondResolution.getStatus());
coordinator.consumeExecutionAuthorization("call-1", "search", Map.of("q", "first"));
coordinator.consumeExecutionAuthorization("call-2", "search", Map.of("q", "second"));
assertAuthorizationRejected(coordinator, "call-1", "search", Map.of("q", "first"));
}
/**
* 验证拒绝一个调用会关闭整个审批批次。
*/
@Test
public void shouldRejectWholeBatchWhenAnyCallRejected() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
AgentPendingState first = register(coordinator, "call-1", "search", Map.of("q", "first"), "batch-1");
AgentPendingState second = register(coordinator, "call-2", "search", Map.of("q", "second"), "batch-1");
coordinator.resolve(resume(first, true));
AgentResumeRequest rejection = resume(second, false);
rejection.setRejectReason("not allowed");
AgentToolApprovalResolution resolution = coordinator.resolve(rejection);
Assert.assertEquals(AgentToolApprovalResolution.Status.REJECTED, resolution.getStatus());
Assert.assertEquals("not allowed", resolution.getReason());
assertAuthorizationRejected(coordinator, "call-1", "search", Map.of("q", "first"));
try {
coordinator.resolve(resume(first, true));
Assert.fail("已消费的审批令牌不能重复使用");
} catch (AgentRuntimeException expected) {
Assert.assertTrue(expected.getMessage().contains("invalid"));
}
}
/**
* 验证过期令牌不能签发工具执行授权。
*/
@Test
public void shouldExpireApprovalBeforeResolution() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
AgentPendingState expired = coordinator.register(
"session-1",
"agent-1",
"call-expired",
"search",
"approve",
Map.of("q", "expired"),
Map.of(),
Instant.now().minusSeconds(1),
"batch-expired");
AgentToolApprovalResolution resolution = coordinator.resolve(resume(expired, true));
Assert.assertEquals(AgentToolApprovalResolution.Status.EXPIRED, resolution.getStatus());
assertAuthorizationRejected(coordinator, "call-expired", "search", Map.of("q", "expired"));
}
/**
* 验证并发重复点击同一令牌时最多一个请求可以成功消费。
*
* @throws Exception 并发任务执行失败时抛出
*/
@Test
public void shouldConsumeConcurrentDuplicateApprovalOnlyOnce() throws Exception {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
AgentPendingState pending = register(
coordinator, "call-1", "search", Map.of("q", "easyflow"), "batch-1");
CountDownLatch start = new CountDownLatch(1);
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<Boolean> first = executor.submit(() -> resolveAfter(start, coordinator, pending));
Future<Boolean> second = executor.submit(() -> resolveAfter(start, coordinator, pending));
start.countDown();
int successCount = (first.get() ? 1 : 0) + (second.get() ? 1 : 0);
Assert.assertEquals(1, successCount);
coordinator.consumeExecutionAuthorization(
"call-1", "search", Map.of("q", "easyflow"));
assertAuthorizationRejected(
coordinator, "call-1", "search", Map.of("q", "easyflow"));
} finally {
executor.shutdownNow();
}
}
/**
* 验证工具名称或入参变化时批准凭证立即失效。
*/
@Test
public void shouldRejectExecutionWhenApprovedCallIsModified() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
AgentPendingState pending = register(
coordinator, "call-1", "search", Map.of("q", "easyflow"), "batch-1");
coordinator.resolve(resume(pending, true));
assertAuthorizationRejected(
coordinator, "call-1", "search", Map.of("q", "modified"));
assertAuthorizationRejected(
coordinator, "call-1", "search", Map.of("q", "easyflow"));
}
/**
* 验证跨节点受信任恢复仍需绑定明确的工具调用信息。
*/
@Test
public void shouldAuthorizeTrustedExecutionByToolCallIdentity() {
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", "search",
"toolInput", Map.of("q", "easyflow")));
coordinator.authorizeTrustedExecution(request);
coordinator.consumeExecutionAuthorization(
"call-1", "search", Map.of("q", "easyflow"));
assertAuthorizationRejected(
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 不能被重新绑定到不同工具内容。
*/
@Test
public void shouldRejectDuplicateToolCallIdWithDifferentInput() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
register(coordinator, "call-1", "search", Map.of("q", "easyflow"), "batch-1");
try {
register(coordinator, "call-1", "search", Map.of("q", "modified"), "batch-2");
Assert.fail("重复 toolCallId 不能绑定不同入参");
} catch (AgentRuntimeException expected) {
Assert.assertTrue(expected.getMessage().contains("Duplicate toolCallId"));
}
}
/**
* 注册测试用审批状态。
*
* @param coordinator 审批协调器
* @param toolCallId 工具调用ID
* @param toolName 工具名称
* @param toolInput 工具入参
* @param batchId 审批批次ID
* @return 待审批状态
*/
private AgentPendingState register(AgentToolApprovalCoordinator coordinator,
String toolCallId,
String toolName,
Map<String, Object> toolInput,
String batchId) {
return coordinator.register(
"session-1",
"agent-1",
toolCallId,
toolName,
"approve",
toolInput,
Map.of(),
Instant.now().plusSeconds(60),
batchId);
}
/**
* 创建测试用恢复请求。
*
* @param state 待审批状态
* @param approved 是否批准
* @return 恢复请求
*/
private AgentResumeRequest resume(AgentPendingState state, boolean approved) {
AgentResumeRequest request = new AgentResumeRequest();
request.setResumeToken(state.getResumeToken());
request.setApproved(approved);
return request;
}
/**
* 等待并发起跑信号后消费审批令牌。
*
* @param start 起跑信号
* @param coordinator 审批协调器
* @param pending 待审批状态
* @return 成功消费时为 true
* @throws InterruptedException 等待被中断时抛出
*/
private boolean resolveAfter(CountDownLatch start,
AgentToolApprovalCoordinator coordinator,
AgentPendingState pending) throws InterruptedException {
start.await();
try {
coordinator.resolve(resume(pending, true));
return true;
} catch (AgentRuntimeException expected) {
return false;
}
}
/**
* 断言工具执行授权不可用。
*
* @param coordinator 审批协调器
* @param toolCallId 工具调用ID
* @param toolName 工具名称
* @param toolInput 工具入参
*/
private void assertAuthorizationRejected(AgentToolApprovalCoordinator coordinator,
String toolCallId,
String toolName,
Map<String, Object> toolInput) {
try {
coordinator.consumeExecutionAuthorization(toolCallId, toolName, toolInput);
Assert.fail("未授权或已消费的工具调用必须被拒绝");
} catch (AgentToolApprovalRejectedException expected) {
Assert.assertNotNull(expected.getMessage());
}
}
}

View File

@@ -72,6 +72,32 @@ public class HeuristicKnowledgeCitationMatcherTest {
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,77 @@
package com.easyagents.agent.runtime.mcp;
import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolAdapter;
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolSpec;
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolType;
import org.junit.Test;
import java.util.List;
import java.util.Map;
/**
* 测试 MCP 声明校验器。
*/
public class McpSpecValidatorTest {
@Test(expected = AgentRuntimeException.class)
public void shouldRejectMissingStdioCommand() {
McpSpec spec = spec(McpTransportType.STDIO);
McpSpecValidator.validateConnection(spec);
}
@Test(expected = AgentRuntimeException.class)
public void shouldRejectMissingSseUrl() {
McpSpec spec = spec(McpTransportType.SSE);
McpSpecValidator.validateConnection(spec);
}
@Test(expected = AgentRuntimeException.class)
public void shouldRejectMissingHttpUrl() {
McpSpec spec = spec(McpTransportType.HTTP);
McpSpecValidator.validateConnection(spec);
}
@Test(expected = AgentRuntimeException.class)
public void shouldRejectMcpToolNameConflictWithBusinessTool() {
AgentToolSpec businessTool = toolSpec("search");
AgentToolSpec mcpTool = toolSpec("search");
McpSpecValidator.validateToolConflicts(List.of(businessTool), List.of(mcpTool), List.of());
}
@Test(expected = AgentRuntimeException.class)
public void shouldRejectMcpToolNameConflictWithOperateTool() {
AgentToolSpec mcpTool = toolSpec(AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL);
AgentOperateToolSpec operateTool = new AgentOperateToolSpec();
operateTool.setType(AgentOperateToolType.SHELL);
McpSpecValidator.validateToolConflicts(List.of(), List.of(mcpTool), List.of(operateTool));
}
@Test(expected = AgentRuntimeException.class)
public void shouldRejectDuplicatedRuntimeToolAliases() {
McpSpec spec = spec(McpTransportType.STDIO);
spec.setCommand("node");
spec.setToolAliases(Map.of("search", "mcp_1_tool", "search.v2", "mcp_1_tool"));
McpSpecValidator.validateConnection(spec);
}
private McpSpec spec(McpTransportType type) {
McpSpec spec = new McpSpec();
spec.setName("mcp");
spec.setTransportType(type);
return spec;
}
private AgentToolSpec toolSpec(String name) {
AgentToolSpec spec = new AgentToolSpec();
spec.setName(name);
return spec;
}
}

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

@@ -0,0 +1,370 @@
package com.easyagents.agent.runtime.mcp;
import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.agentscope.AgentScopeSkillAdapter;
import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec;
import com.easyagents.agent.runtime.skill.AgentSkillSpec;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.tool.Toolkit;
import io.agentscope.core.tool.mcp.McpClientWrapper;
import io.modelcontextprotocol.spec.McpSchema;
import org.junit.Assert;
import org.junit.Test;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
/**
* 测试 MCP Toolkit 适配器。
*/
public class McpToolkitAdapterTest {
@Test
public void shouldRegisterEnabledMcpToolsAndBuildRuntimeToolSpecs() {
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo",
List.of(tool("search"), tool("write_file")));
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpSpec spec = stdioSpec();
spec.setEnableTools(List.of("search"));
spec.setApprovalRequired(true);
spec.setMetadata(Map.of("owner", "runtime"));
Toolkit toolkit = new Toolkit();
McpRegistration registration = adapter.register(List.of(spec), toolkit);
Assert.assertEquals(1, registration.getClients().size());
Assert.assertEquals(1, registration.getToolSpecs().size());
Assert.assertNotNull(toolkit.getTool("search"));
Assert.assertNull(toolkit.getTool("write_file"));
AgentToolSpec toolSpec = registration.getToolSpecs().get(0);
Assert.assertEquals("search", toolSpec.getName());
Assert.assertTrue(toolSpec.isApprovalRequired());
Assert.assertEquals("MCP", toolSpec.getMetadata().get("source"));
Assert.assertEquals("demo", toolSpec.getMetadata().get("mcpName"));
Assert.assertEquals("stdio", toolSpec.getMetadata().get("transportType"));
Assert.assertEquals("runtime", toolSpec.getMetadata().get("owner"));
}
@Test
public void shouldFilterSensitiveMetadataKeysFromRuntimeToolSpec() {
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", List.of(tool("search")));
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpSpec spec = stdioSpec();
spec.setMetadata(Map.of(
"owner", "runtime",
"apiKey", "secret-key",
"Authorization", "Bearer secret-token",
"password", "secret-password"));
McpRegistration registration = adapter.register(List.of(spec), new Toolkit());
Map<String, Object> metadata = registration.getToolSpecs().get(0).getMetadata();
Assert.assertEquals("runtime", metadata.get("owner"));
Assert.assertFalse(metadata.containsKey("apiKey"));
Assert.assertFalse(metadata.containsKey("Authorization"));
Assert.assertFalse(metadata.containsKey("password"));
}
@Test
public void shouldRegisterAliasedMcpToolAndCallRawToolName() {
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", List.of(tool("search")));
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpSpec spec = stdioSpec();
spec.setEnableTools(List.of("mcp_1_search"));
spec.setToolAliases(Map.of("search", "mcp_1_search"));
Toolkit toolkit = new Toolkit();
McpRegistration registration = adapter.register(List.of(spec), toolkit);
Assert.assertNotNull(toolkit.getTool("mcp_1_search"));
Assert.assertNull(toolkit.getTool("search"));
AgentToolSpec toolSpec = registration.getToolSpecs().get(0);
Assert.assertEquals("mcp_1_search", toolSpec.getName());
Assert.assertEquals("search", toolSpec.getMetadata().get("rawMcpToolName"));
registration.getClients().get(0).callTool("mcp_1_search", Map.of("q", "hello")).block();
Assert.assertEquals("search", client.lastCalledToolName.get());
}
/**
* 验证未配置工具白名单时会为 MCP 下全部工具动态生成运行时别名。
*/
@Test
public void shouldRegisterAllToolsWithDynamicPrefixAndCallRawToolName() {
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo",
List.of(tool("search.tool"), tool("write-file")));
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpSpec spec = stdioSpec();
spec.setDescription("Demo MCP");
spec.setToolNamePrefix("mcp_20_");
Toolkit toolkit = new Toolkit();
McpRegistration registration = adapter.register(List.of(spec), toolkit);
Assert.assertNotNull(toolkit.getTool("mcp_20_search_tool"));
Assert.assertNotNull(toolkit.getTool("mcp_20_write-file"));
Assert.assertNull(toolkit.getTool("search.tool"));
Assert.assertEquals(2, registration.getToolSpecs().size());
AgentToolSpec toolSpec = registration.getToolSpecs().get(0);
Assert.assertEquals("mcp_20_search_tool", toolSpec.getName());
Assert.assertEquals("search.tool", toolSpec.getMetadata().get("rawMcpToolName"));
Assert.assertEquals("Demo MCP - search.tool", toolSpec.getMetadata().get("toolDisplayName"));
registration.getClients().get(0).callTool("mcp_20_search_tool", Map.of("q", "hello")).block();
Assert.assertEquals("search.tool", client.lastCalledToolName.get());
}
/**
* 验证动态别名安全化后发生重名时会自动追加序号。
*/
@Test
public void shouldDeduplicateDynamicAliasesAfterSanitizingToolNames() {
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo",
List.of(tool("search.tool"), tool("search_tool")));
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpSpec spec = stdioSpec();
spec.setToolNamePrefix("mcp_20_");
Toolkit toolkit = new Toolkit();
McpRegistration registration = adapter.register(List.of(spec), toolkit);
Assert.assertNotNull(toolkit.getTool("mcp_20_search_tool"));
Assert.assertNotNull(toolkit.getTool("mcp_20_search_tool_2"));
Assert.assertEquals("search.tool", registration.getToolSpecs().get(0).getMetadata().get("rawMcpToolName"));
Assert.assertEquals("search_tool", registration.getToolSpecs().get(1).getMetadata().get("rawMcpToolName"));
registration.getClients().get(0).callTool("mcp_20_search_tool_2", Map.of("q", "hello")).block();
Assert.assertEquals("search_tool", client.lastCalledToolName.get());
}
@Test
public void shouldReturnEmptyToolSpecsWhenMcpServerHasNoTools() {
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", List.of());
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpRegistration registration = adapter.register(List.of(stdioSpec()), new Toolkit());
Assert.assertEquals(1, registration.getClients().size());
Assert.assertTrue(registration.getToolSpecs().isEmpty());
}
@Test
public void shouldCloseCreatedClientWhenRegistrationFails() {
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", List.of(tool("search")));
client.failOnListTools = true;
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
try {
adapter.register(List.of(stdioSpec()), new Toolkit());
Assert.fail("Expected MCP registration failure.");
} catch (AgentRuntimeException | IllegalStateException ignored) {
Assert.assertTrue(client.closed.get());
}
}
/**
* 验证 Skill MCP 只注册到禁用的 Skill Tool Group加载前不向模型暴露。
*/
@Test
public void shouldRegisterSkillMcpAsInactiveSkillToolGroup() {
List<McpSchema.Tool> frozenTools = List.of(tool("search"));
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo",
List.of(tool("search"), tool("new_remote_tool")));
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpSpec spec = stdioSpec();
spec.setSkillId("skill-1");
spec.setToolAliases(Map.of("search", "skill_1_mcp_search"));
spec.setFrozenToolManifest(McpToolManifest.fromTools(frozenTools));
spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest()));
Toolkit toolkit = new Toolkit();
McpRegistration registration = adapter.register(List.of(spec), toolkit);
Assert.assertNull(toolkit.getTool("skill_1_mcp_search"));
Assert.assertEquals(1, registration.getSkillRegistrations().size());
Assert.assertEquals(List.of("skill_1_mcp_search"),
registration.getSkillRegistrations().get(0).getEnableTools());
AgentSkillSpec skill = new AgentSkillSpec();
skill.setSkillId("skill-1");
skill.setName("Search Skill");
skill.setDescription("Search through MCP.");
skill.setSkillContent("Load this skill before searching.");
AgentSkillBoxSpec skillBoxSpec = new AgentSkillBoxSpec();
skillBoxSpec.setSkills(List.of(skill));
new AgentScopeSkillAdapter().createSkillBox(skillBoxSpec, toolkit, Map.of(),
registration.getSkillRegistrations());
Assert.assertNotNull(toolkit.getTool("skill_1_mcp_search"));
Assert.assertFalse(toolkit.getActiveGroups().contains("skill-1_skill_tools"));
Assert.assertTrue(toolkit.getToolSchemas().stream()
.noneMatch(schema -> "skill_1_mcp_search".equals(schema.getName())));
Assert.assertNull(toolkit.getTool("skill_1_mcp_new_remote_tool"));
Assert.assertEquals(1, client.remoteListCalls.get());
}
/**
* 验证 Skill MCP 会在读取 Tool 清单前完成异步初始化。
*/
@Test
public void shouldInitializeSkillMcpBeforeListingTools() {
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", List.of(tool("search")));
client.deferInitialization = true;
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpSpec spec = stdioSpec();
spec.setSkillId("skill-1");
spec.setFrozenToolManifest(McpToolManifest.fromTools(List.of(tool("search"))));
spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest()));
McpRegistration registration = adapter.register(List.of(spec), new Toolkit());
Assert.assertTrue(client.isInitialized());
Assert.assertEquals(1, client.remoteListCalls.get());
Assert.assertEquals(1, registration.getSkillRegistrations().size());
}
/**
* 验证冻结 Tool 缺失时拒绝注册并关闭 client。
*/
@Test
public void shouldRejectMissingFrozenSkillMcpToolAndCloseClient() {
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", List.of(tool("other")));
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpSpec spec = stdioSpec();
spec.setSkillId("skill-1");
spec.setFrozenToolManifest(McpToolManifest.fromTools(List.of(tool("search"))));
spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest()));
try {
adapter.register(List.of(spec), new Toolkit());
Assert.fail("Expected frozen MCP tool validation failure.");
} catch (AgentRuntimeException expected) {
Assert.assertTrue(expected.getMessage().contains("missing"));
Assert.assertTrue(client.closed.get());
}
}
/**
* 验证冻结 Tool Schema 漂移时拒绝注册。
*/
@Test(expected = AgentRuntimeException.class)
public void shouldRejectChangedFrozenSkillMcpSchema() {
McpSchema.Tool expectedTool = tool("search");
McpSchema.JsonSchema changedSchema = new McpSchema.JsonSchema("object",
Map.of("keyword", Map.of("type", "string")), List.of("keyword"), null, null, null);
McpSchema.Tool actualTool = new McpSchema.Tool("search", "search", "search description",
changedSchema, null, null, null);
McpToolkitAdapter adapter = new McpToolkitAdapter(
new FakeMcpClientFactory(new FakeMcpClientWrapper("demo", List.of(actualTool))));
McpSpec spec = stdioSpec();
spec.setSkillId("skill-1");
spec.setFrozenToolManifest(McpToolManifest.fromTools(List.of(expectedTool)));
spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest()));
adapter.register(List.of(spec), new Toolkit());
}
/**
* 验证远端仅调整 Tool 描述时不破坏已发布 Skill 的运行兼容性。
*/
@Test
public void shouldAllowChangedDescriptionWhenFrozenSchemaIsStable() {
McpSchema.Tool expectedTool = tool("search");
McpSchema.Tool actualTool = new McpSchema.Tool(
"search", "search", "updated description",
expectedTool.inputSchema(), expectedTool.outputSchema(), null, null);
McpToolkitAdapter adapter = new McpToolkitAdapter(
new FakeMcpClientFactory(new FakeMcpClientWrapper("demo", List.of(actualTool))));
McpSpec spec = stdioSpec();
spec.setSkillId("skill-1");
spec.setFrozenToolManifest(McpToolManifest.fromTools(List.of(expectedTool)));
spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest()));
McpRegistration registration = adapter.register(List.of(spec), new Toolkit());
Assert.assertEquals(1, registration.getSkillRegistrations().size());
Assert.assertEquals("search description", registration.getToolSpecs().get(0).getDescription());
}
private McpSpec stdioSpec() {
McpSpec spec = new McpSpec();
spec.setName("demo");
spec.setTransportType(McpTransportType.STDIO);
spec.setCommand("node");
spec.setTimeout(Duration.ofSeconds(10));
spec.setInitializationTimeout(Duration.ofSeconds(3));
return spec;
}
private McpSchema.Tool tool(String name) {
McpSchema.JsonSchema schema = new McpSchema.JsonSchema("object",
Map.of("q", Map.of("type", "string", "description", "query")),
List.of("q"), null, null, null);
return new McpSchema.Tool(name, name, name + " description", schema, null, null, null);
}
private static class FakeMcpClientFactory extends McpClientFactory {
private final McpClientWrapper client;
private FakeMcpClientFactory(McpClientWrapper client) {
this.client = client;
}
@Override
public McpClientWrapper create(McpSpec spec) {
return client;
}
}
private static class FakeMcpClientWrapper extends McpClientWrapper {
private final List<McpSchema.Tool> tools;
private final AtomicBoolean closed = new AtomicBoolean(false);
private final AtomicReference<String> lastCalledToolName = new AtomicReference<>();
private final java.util.concurrent.atomic.AtomicInteger remoteListCalls =
new java.util.concurrent.atomic.AtomicInteger();
private boolean failOnListTools;
private boolean deferInitialization;
private FakeMcpClientWrapper(String name, List<McpSchema.Tool> tools) {
super(name);
this.tools = tools;
}
@Override
public Mono<Void> initialize() {
if (deferInitialization) {
return Mono.fromRunnable(() -> initialized = true);
}
initialized = true;
return Mono.empty();
}
@Override
public Mono<List<McpSchema.Tool>> listTools() {
if (!initialized) {
return Mono.error(new IllegalStateException("client is not initialized"));
}
remoteListCalls.incrementAndGet();
if (failOnListTools) {
return Mono.error(new IllegalStateException("list tools failed"));
}
return Mono.just(tools);
}
@Override
public Mono<McpSchema.CallToolResult> callTool(String toolName, Map<String, Object> arguments) {
lastCalledToolName.set(toolName);
return Mono.just(new McpSchema.CallToolResult(List.of(), false));
}
@Override
public void close() {
closed.set(true);
initialized = false;
}
}
}

View File

@@ -0,0 +1,27 @@
package com.easyagents.agent.runtime.mcp;
import com.easyagents.agent.runtime.AgentRuntimeException;
import org.junit.Assert;
import org.junit.Test;
/**
* 测试 MCP 连接方式解析。
*/
public class McpTransportTypeTest {
@Test
public void shouldParseCompatibleTransportValues() {
Assert.assertEquals(McpTransportType.STDIO, McpTransportType.from(null));
Assert.assertEquals(McpTransportType.STDIO, McpTransportType.from("stdio"));
Assert.assertEquals(McpTransportType.SSE, McpTransportType.from("http-sse"));
Assert.assertEquals(McpTransportType.SSE, McpTransportType.from("SSE"));
Assert.assertEquals(McpTransportType.HTTP, McpTransportType.from("http-stream"));
Assert.assertEquals(McpTransportType.HTTP, McpTransportType.from("HTTP"));
Assert.assertEquals(McpTransportType.HTTP, McpTransportType.from("streamable-http"));
}
@Test(expected = AgentRuntimeException.class)
public void shouldRejectUnsupportedTransportValue() {
McpTransportType.from("websocket");
}
}

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