Compare commits

25 Commits

Author SHA1 Message Date
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
221 changed files with 31246 additions and 1056 deletions

View File

@@ -24,6 +24,7 @@ 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-support`Flow 与 Easy-Agents 适配模块。
- `easy-agents-spring-boot-starter`Spring Boot 自动配置支持。
@@ -39,11 +40,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

View File

@@ -1,6 +1,7 @@
package com.easyagents.agent.runtime;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever;
import com.easyagents.agent.runtime.media.AgentMediaResolver;
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;
@@ -55,6 +56,11 @@ public class AgentInitRequest {
*/
private Map<String, Object> metadata = new LinkedHashMap<>();
/**
* 媒体引用解析器,仅在模型调用前解析稳定引用。
*/
private AgentMediaResolver mediaResolver;
/**
* 获取会话ID。
*
@@ -200,4 +206,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

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

@@ -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,12 +3,15 @@ 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;
@@ -152,6 +155,9 @@ public class AgentScopeReActRuntime implements AgentRuntime {
*/
@Override
public void close() {
if (approvalCoordinator != null) {
approvalCoordinator.cancelAll("Agent runtime has been closed.");
}
closeMcpClients();
initialized.set(false);
}
@@ -187,17 +193,27 @@ 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));
@@ -239,18 +255,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())
@@ -270,9 +283,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));
@@ -303,6 +315,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.");
}
}
/**
@@ -535,9 +553,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()
@@ -545,6 +564,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 -> cleanupTurn());
}
/**
* 将待审批状态转换为前端可消费的稳定字段。
*
* @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;
}
/**
* 生成开始事件。
*
@@ -735,6 +794,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
* 清理本轮状态。
*/
private void cleanupTurn() {
approvalCoordinator.clearExecutionAuthorizations();
turnContextHolder.clear();
running.set(false);
}
@@ -815,26 +875,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);
}
/**
* 从知识库旁路事件中收集本轮候选引用。
*
@@ -958,12 +998,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<>();
/**
* 归一化流式事件。
@@ -990,10 +1030,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()));
}
}
@@ -1087,6 +1131,7 @@ 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.mcpToolSpecs(),
toolkitBuildResult.operateToolSpecs());
interceptors.add(new ToolHitlInterceptor(eventBridge, approvalCoordinator,

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,6 +1,7 @@
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;
@@ -11,6 +12,8 @@ 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;
@@ -21,25 +24,28 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
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 +82,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 +101,191 @@ 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 (toolSpec == null || !toolSpec.isApprovalRequired()) {
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 toolSpec != null && toolSpec.isApprovalRequired();
}
/**
* 复制工具调用并替换调用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();
})
.filter(this::isApprovalRequired)
.toList();
}
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
@@ -156,9 +308,18 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
approvalPrompt(approvalRequest),
toolUse.getInput(),
metadata,
Instant.now().plus(timeout));
Instant.now().plus(timeout),
approvalBatchId);
}
/**
* 构建工具审批请求事件。
*
* @param toolSpec 工具声明
* @param toolUse 工具调用
* @param pendingState 待审批状态
* @return 审批请求事件
*/
private AgentRuntimeEvent toolApprovalRequiredEvent(AgentToolSpec toolSpec,
ToolUseBlock toolUse,
AgentPendingState pendingState) {
@@ -179,6 +340,13 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
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());
@@ -191,6 +359,12 @@ 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;
@@ -203,12 +377,25 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
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
@@ -217,4 +404,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

@@ -5,18 +5,30 @@ 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 {
/** 是否启用内存审批协调。 */
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<>();
/**
* 创建已启用的协调器。
@@ -66,6 +78,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 +133,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 +163,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 +177,211 @@ 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} 提供上述字段组成的列表。该入口仅供已经完成持久化令牌
* 校验和一次性消费的服务端集成层使用。</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<>();
int authorizationCount = 0;
if (approvedToolCalls instanceof List<?> calls) {
for (Object call : calls) {
if (call instanceof Map<?, ?> callMap) {
authorizeTrustedCall(callMap, trustedAuthorizations);
authorizationCount++;
}
}
} else if (metadata.containsKey("toolCallId")) {
authorizeTrustedCall(metadata, trustedAuthorizations);
authorizationCount++;
}
if (authorizationCount == 0) {
throw new AgentRuntimeException(
"Trusted resume metadata must include approved toolCallId, toolName, and toolInput.");
}
executionAuthorizations.putAll(trustedAuthorizations);
}
/**
* 消费指定工具调用的一次性执行授权。
*
* @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("工具调用与已批准内容不一致。");
}
}
/**
* 清理尚未消费的一次性执行授权。
*/
public synchronized void clearExecutionAuthorizations() {
executionAuthorizations.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 +389,16 @@ 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();
}
/**
@@ -163,13 +410,232 @@ 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;
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 本次恢复待签发的临时授权集合
*/
private void authorizeTrustedCall(Map<?, ?> callMap,
Map<String, ExecutionAuthorization> trustedAuthorizations) {
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"));
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,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

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

@@ -17,6 +17,7 @@ import com.easyagents.agent.runtime.memory.AgentMemoryPolicy;
import com.easyagents.agent.runtime.memory.AgentMemorySnapshot;
import com.easyagents.agent.runtime.message.AgentMessage;
import com.easyagents.agent.runtime.message.AgentMessageRole;
import com.easyagents.agent.runtime.message.AgentMediaBlock;
import com.easyagents.agent.runtime.model.AgentModelSpec;
import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore;
import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec;
@@ -55,6 +56,7 @@ import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BooleanSupplier;
/**
@@ -78,6 +80,20 @@ public class AgentScopeStatefulRuntimeTest {
runtime.stream(message);
}
@Test(expected = AgentRuntimeException.class)
public void shouldRejectImageWhenModelCapabilityIsDisabled() {
AgentScopeReActRuntime runtime = fakeRuntime();
AgentInitRequest request = initRequest();
runtime.init(request);
AgentMessage message = new AgentMessage();
message.setRole(AgentMessageRole.USER);
AgentMediaBlock image = new AgentMediaBlock("image");
image.setReference("draft:upload-1");
message.setContentBlocks(List.of(image));
runtime.stream(message);
}
@Test(expected = AgentRuntimeException.class)
public void shouldRejectDuplicateInit() {
AgentScopeReActRuntime runtime = fakeRuntime();
@@ -674,6 +690,49 @@ public class AgentScopeStatefulRuntimeTest {
Assert.assertTrue(sessionStore.exists("session-1"));
}
/**
* 验证增量模式会保留 URL 中连续出现的相同字符。
*/
@Test
public void shouldPreserveRepeatedIdenticalTextDeltas() {
String expectedUrl = "http://127.0.0.1:39000/easyflow/file.docx";
AgentScopeReActRuntime runtime = runtimeWithStreamingModel(List.of(
ChatResponse.builder()
.id("url-response")
.content(List.of(TextBlock.builder().text("http://127.0.0.1:39").build()))
.build(),
ChatResponse.builder()
.id("url-response")
.content(List.of(TextBlock.builder().text("0").build()))
.build(),
ChatResponse.builder()
.id("url-response")
.content(List.of(TextBlock.builder().text("0").build()))
.build(),
ChatResponse.builder()
.id("url-response")
.content(List.of(TextBlock.builder().text("0/easyflow/file.docx").build()))
.finishReason("stop")
.build()));
runtime.init(initRequest());
List<AgentRuntimeEvent> events = runtime.stream(
AgentMessage.text(AgentMessageRole.USER, "create file"))
.collectList()
.block();
String streamedText = events.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.MESSAGE_DELTA)
.map(event -> String.valueOf(event.getPayload().getOrDefault("text", "")))
.reduce("", String::concat);
Assert.assertEquals(expectedUrl, streamedText);
AgentRuntimeEvent completed = events.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED)
.findFirst()
.orElseThrow();
Assert.assertEquals(expectedUrl, completed.getPayload().get("text"));
}
@Test
public void shouldAllowNextStreamAfterPreviousStreamCompleted() {
AgentScopeReActRuntime runtime = fakeRuntime();
@@ -828,6 +887,9 @@ public class AgentScopeStatefulRuntimeTest {
Assert.assertFalse(((List<?>) suspended.getPayload().get("pendingApprovals")).isEmpty());
Assert.assertFalse(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED));
Assert.assertFalse(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_RESULT));
Assert.assertEquals(1, events.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.SUSPENDED)
.count());
}
@Test(expected = AgentRuntimeException.class)
@@ -912,6 +974,267 @@ public class AgentScopeStatefulRuntimeTest {
Assert.assertTrue(sessionStore.exists("session-1"));
}
/**
* 验证同一轮推理包含多个审批工具时,全部批准前不会执行任何工具。
*/
@Test
public void shouldWaitForAllToolApprovalsBeforeExecutingBatch() {
AgentInitRequest request = initRequest();
AgentToolSpec searchSpec = new AgentToolSpec();
searchSpec.setName("search");
searchSpec.setDescription("search");
searchSpec.setApprovalRequired(true);
AgentToolSpec auditSpec = new AgentToolSpec();
auditSpec.setName("audit");
auditSpec.setDescription("audit");
auditSpec.setApprovalRequired(true);
request.getAgentDefinition().setToolSpecs(List.of(searchSpec, auditSpec));
AtomicInteger invocationCount = new AtomicInteger();
request.setToolInvokers(Map.of(
"search", (arguments, context) -> {
invocationCount.incrementAndGet();
return AgentToolResult.success("search result");
},
"audit", (arguments, context) -> {
invocationCount.incrementAndGet();
return AgentToolResult.success("audit result");
}));
AgentScopeReActRuntime runtime = runtimeWithModel(List.of(
ChatResponse.builder()
.id("tool-call-message")
.content(List.of(
ToolUseBlock.builder()
.id("call-search")
.name("search")
.input(Map.of("q", "easyflow"))
.build(),
ToolUseBlock.builder()
.id("call-audit")
.name("audit")
.input(Map.of("scope", "current"))
.build()))
.finishReason("tool_calls")
.build(),
ChatResponse.builder()
.id("final-message")
.content(List.of(TextBlock.builder().text("done").build()))
.finishReason("stop")
.build()));
runtime.init(request);
List<AgentRuntimeEvent> initialEvents = runtime.stream(
AgentMessage.text(AgentMessageRole.USER, "use tools"))
.collectList()
.block();
List<AgentRuntimeEvent> approvals = initialEvents.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)
.toList();
Assert.assertEquals(2, approvals.size());
Assert.assertEquals(1, initialEvents.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.SUSPENDED)
.count());
List<AgentRuntimeEvent> firstResumeEvents = runtime.resume(resumeFromApproval(approvals.get(0), true))
.collectList()
.block();
Assert.assertEquals(0, invocationCount.get());
AgentRuntimeEvent waitingEvent = firstResumeEvents.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.SUSPENDED)
.findFirst()
.orElseThrow();
Assert.assertEquals(1, ((List<?>) waitingEvent.getPayload().get("pendingApprovals")).size());
Assert.assertFalse(firstResumeEvents.stream()
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_CALL));
List<AgentRuntimeEvent> secondResumeEvents = runtime.resume(resumeFromApproval(approvals.get(1), true))
.collectList()
.block();
Assert.assertEquals(2, invocationCount.get());
Assert.assertEquals(2, secondResumeEvents.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_RESULT)
.count());
Assert.assertTrue(secondResumeEvents.stream()
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED));
}
/**
* 验证不同 toolCallId 即使名称和入参相同也会分别审批和执行。
*/
@Test
public void shouldPreserveDistinctApprovedToolCallsWithIdenticalInput() {
AgentInitRequest request = initRequest();
AgentToolSpec toolSpec = new AgentToolSpec();
toolSpec.setName("search");
toolSpec.setDescription("search");
toolSpec.setApprovalRequired(true);
request.getAgentDefinition().setToolSpecs(List.of(toolSpec));
AtomicInteger invocationCount = new AtomicInteger();
request.setToolInvokers(Map.of("search", (arguments, context) -> {
invocationCount.incrementAndGet();
return AgentToolResult.success("tool result");
}));
Map<String, Object> input = Map.of("q", "easyflow");
AgentScopeReActRuntime runtime = runtimeWithModel(List.of(
ChatResponse.builder()
.id("tool-call-message")
.content(List.of(
ToolUseBlock.builder()
.id("call-search")
.name("search")
.input(input)
.build(),
ToolUseBlock.builder()
.id("call-search-duplicate")
.name("search")
.input(input)
.build()))
.finishReason("tool_calls")
.build(),
ChatResponse.builder()
.id("final-message")
.content(List.of(TextBlock.builder().text("done").build()))
.finishReason("stop")
.build()));
runtime.init(request);
List<AgentRuntimeEvent> suspendedEvents = runtime.stream(
AgentMessage.text(AgentMessageRole.USER, "use tool"))
.collectList()
.block();
List<AgentRuntimeEvent> approvals = suspendedEvents.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)
.toList();
Assert.assertEquals(2, approvals.size());
List<AgentRuntimeEvent> firstResumeEvents = runtime.resume(resumeFromApproval(approvals.get(0), true))
.collectList()
.block();
Assert.assertEquals(0, invocationCount.get());
Assert.assertTrue(firstResumeEvents.stream()
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.SUSPENDED));
List<AgentRuntimeEvent> secondResumeEvents = runtime.resume(resumeFromApproval(approvals.get(1), true))
.collectList()
.block();
Assert.assertEquals(2, invocationCount.get());
Assert.assertEquals(2, secondResumeEvents.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_RESULT)
.count());
Assert.assertTrue(secondResumeEvents.stream()
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED));
}
/**
* 验证相同 toolCallId 的同轮重放只审批并执行一次。
*/
@Test
public void shouldDeduplicateRepeatedToolCallIdWithinReasoning() {
AgentInitRequest request = initRequest();
AgentToolSpec toolSpec = new AgentToolSpec();
toolSpec.setName("search");
toolSpec.setDescription("search");
toolSpec.setApprovalRequired(true);
request.getAgentDefinition().setToolSpecs(List.of(toolSpec));
AtomicInteger invocationCount = new AtomicInteger();
request.setToolInvokers(Map.of("search", (arguments, context) -> {
invocationCount.incrementAndGet();
return AgentToolResult.success("tool result");
}));
Map<String, Object> input = Map.of("q", "easyflow");
AgentScopeReActRuntime runtime = runtimeWithModel(List.of(
ChatResponse.builder()
.id("tool-call-message")
.content(List.of(
ToolUseBlock.builder()
.id("call-search")
.name("search")
.input(input)
.build(),
ToolUseBlock.builder()
.id("call-search")
.name("search")
.input(input)
.build()))
.finishReason("tool_calls")
.build(),
ChatResponse.builder()
.id("final-message")
.content(List.of(TextBlock.builder().text("done").build()))
.finishReason("stop")
.build()));
runtime.init(request);
List<AgentRuntimeEvent> suspendedEvents = runtime.stream(
AgentMessage.text(AgentMessageRole.USER, "use tool"))
.collectList()
.block();
List<AgentRuntimeEvent> approvals = suspendedEvents.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)
.toList();
Assert.assertEquals(1, approvals.size());
List<AgentRuntimeEvent> resumeEvents = runtime.resume(resumeFromApproval(approvals.get(0), true))
.collectList()
.block();
Assert.assertEquals(1, invocationCount.get());
Assert.assertEquals(1, resumeEvents.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_RESULT)
.count());
Assert.assertTrue(resumeEvents.stream()
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED));
}
/**
* 验证模型未提供 toolCallId 时审批链路仍会获得完整的调用身份。
*/
@Test
public void shouldProvideApprovalIdentityWhenModelOmitsToolCallId() {
AgentInitRequest request = initRequest();
AgentToolSpec toolSpec = new AgentToolSpec();
toolSpec.setName("search");
toolSpec.setDescription("search");
toolSpec.setApprovalRequired(true);
request.getAgentDefinition().setToolSpecs(List.of(toolSpec));
request.setToolInvokers(Map.of("search", (arguments, context) ->
AgentToolResult.success("tool result")));
AgentScopeReActRuntime runtime = runtimeWithModel(List.of(
ChatResponse.builder()
.id("tool-call-message")
.content(List.of(ToolUseBlock.builder()
.name("search")
.input(Map.of("q", "easyflow"))
.build()))
.finishReason("tool_calls")
.build(),
ChatResponse.builder()
.id("final-message")
.content(List.of(TextBlock.builder().text("done").build()))
.finishReason("stop")
.build()));
runtime.init(request);
List<AgentRuntimeEvent> events = runtime.stream(
AgentMessage.text(AgentMessageRole.USER, "use tool"))
.collectList()
.block();
AgentRuntimeEvent approval = events.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)
.findFirst()
.orElseThrow();
String toolCallId = String.valueOf(approval.getPayload().get("toolCallId"));
Assert.assertFalse(toolCallId.isBlank());
@SuppressWarnings("unchecked")
Map<String, Object> metadata =
(Map<String, Object>) approval.getPayload().get("approvalMetadata");
String approvalBatchId = String.valueOf(metadata.get("approvalBatchId"));
Assert.assertFalse(approvalBatchId.isBlank());
}
@Test
public void shouldCancelRejectedToolResumeWithoutExecutingTool() {
InMemoryAgentSessionStore sessionStore = new InMemoryAgentSessionStore();
@@ -1090,6 +1413,43 @@ public class AgentScopeStatefulRuntimeTest {
new AgentScopeMessageAdapter());
}
/**
* 创建单次模型调用返回多个增量响应的运行时。
*
* @param responses 同一次模型调用中的响应增量
* @return 测试运行时
*/
private AgentScopeReActRuntime runtimeWithStreamingModel(List<ChatResponse> responses) {
AgentScopeModelFactory modelFactory = new AgentScopeModelFactory() {
@Override
public Model create(AgentModelSpec modelSpec,
com.easyagents.agent.runtime.model.AgentGenerationOptions generationOptions) {
return new StreamingScriptedModel(
modelSpec == null ? "fake-model" : modelSpec.getModelName(),
responses);
}
};
return new AgentScopeReActRuntime(modelFactory, new AgentScopeToolAdapter(),
new AgentScopeKnowledgeAdapter(), new AgentScopeMemoryAdapter(), new AgentScopeSkillAdapter(),
new AgentScopeMessageAdapter());
}
/**
* 根据审批事件创建恢复请求。
*
* @param approvalEvent 工具审批事件
* @param approved 是否批准
* @return 恢复请求
*/
private AgentResumeRequest resumeFromApproval(AgentRuntimeEvent approvalEvent, boolean approved) {
AgentResumeRequest request = new AgentResumeRequest();
AgentResumeToken token = new AgentResumeToken();
token.setValue(String.valueOf(approvalEvent.getPayload().get("resumeToken")));
request.setResumeToken(token);
request.setApproved(approved);
return request;
}
private static class ScriptedModel implements Model {
private final String modelName;
@@ -1123,6 +1483,51 @@ public class AgentScopeStatefulRuntimeTest {
}
}
/**
* 单次调用按顺序返回全部响应增量的测试模型。
*/
private static class StreamingScriptedModel implements Model {
private final String modelName;
private final List<ChatResponse> responses;
/**
* 创建流式测试模型。
*
* @param modelName 模型名称
* @param responses 响应增量
*/
private StreamingScriptedModel(String modelName, List<ChatResponse> responses) {
this.modelName = modelName;
this.responses = responses;
}
/**
* 返回预设的响应增量。
*
* @param messages 输入消息
* @param toolSchemas 工具定义
* @param options 生成配置
* @return 响应流
*/
@Override
public Flux<ChatResponse> stream(List<Msg> messages,
List<ToolSchema> toolSchemas,
GenerateOptions options) {
return Flux.fromIterable(responses);
}
/**
* 获取模型名称。
*
* @return 模型名称
*/
@Override
public String getModelName() {
return modelName;
}
}
private AgentInitRequest initRequest() {
AgentModelSpec modelSpec = new AgentModelSpec();
modelSpec.setModelName("fake-model");

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,258 @@
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"));
}
/**
* 验证同一 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

@@ -256,6 +256,10 @@
<!--store end-->
<!--agent runtime start-->
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-skill</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-agent-runtime</artifactId>

View File

@@ -233,7 +233,7 @@ public class ReActAgent implements IAgent {
AiMessageResponse response = chatModel.chat(memoryPrompt, chatOptions);
notifyOnChatResponse(response);
String content = response.getMessage().getContent();
String content = response.getMessage().getTextContent();
AiMessage message = new AiMessage(content);
// 请求用户输入

View File

@@ -0,0 +1,37 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.easyagents.core.file2text;
/**
* 轻量文档读取错误码。
*/
public enum DocumentReadErrorCode {
/** 不支持的文档类型。 */
UNSUPPORTED_DOCUMENT_TYPE,
/** 文档结构超过安全上限。 */
DOCUMENT_STRUCTURE_LIMIT_EXCEEDED,
/** 文档已加密。 */
DOCUMENT_ENCRYPTED,
/** 文档损坏或容器不合法。 */
DOCUMENT_CORRUPTED,
/** 文档中没有可读取文字。 */
DOCUMENT_NO_READABLE_TEXT,
/** 文档读取已取消。 */
DOCUMENT_READ_CANCELLED,
/** 未分类的文档读取失败。 */
DOCUMENT_READ_FAILED
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.easyagents.core.file2text;
/**
* 轻量文档读取异常。
*/
public class DocumentReadException extends RuntimeException {
private final DocumentReadErrorCode errorCode;
/**
* 创建文档读取异常。
*
* @param errorCode 错误码
* @param message 错误消息
*/
public DocumentReadException(DocumentReadErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
/**
* 创建带原因的文档读取异常。
*
* @param errorCode 错误码
* @param message 错误消息
* @param cause 原始异常
*/
public DocumentReadException(DocumentReadErrorCode errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
/**
* 获取错误码。
*
* @return 错误码
*/
public DocumentReadErrorCode getErrorCode() {
return errorCode;
}
}

View File

@@ -0,0 +1,107 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0.
*/
package com.easyagents.core.file2text;
import com.easyagents.core.file2text.source.DocumentSource;
import java.util.List;
/**
* 结构化文档读取结果构造工具。
*/
public final class DocumentReadSupport {
private DocumentReadSupport() {
}
/**
* 创建带定位的文本片段。
*
* @param segmentId 片段 ID
* @param text 文本
* @param locatorType 定位类型
* @param locatorLabel 定位标签
* @param startIndex 起始字符下标
* @param headingPath 标题路径
* @return 文本片段
*/
public static DocumentTextSegment segment(String segmentId,
String text,
String locatorType,
String locatorLabel,
int startIndex,
List<String> headingPath) {
String safeText = text == null ? "" : text.trim();
DocumentTextSegment segment = new DocumentTextSegment();
segment.setSegmentId(segmentId);
segment.setText(safeText);
segment.setLocatorType(locatorType);
segment.setLocatorLabel(locatorLabel);
segment.setStartIndex(startIndex);
segment.setEndIndex(startIndex + safeText.length());
segment.setHeadingPath(headingPath);
segment.setTokenEstimate(estimateTokens(safeText));
return segment;
}
/**
* 汇总结构化读取结果。
*
* @param source 文档来源
* @param segments 片段
* @param request 读取请求
* @return 读取结果
* @throws DocumentReadException 结果为空或超过边界
*/
public static LightweightDocumentReadResult result(DocumentSource source,
List<DocumentTextSegment> segments,
LightweightDocumentReadRequest request)
throws DocumentReadException {
List<DocumentTextSegment> nonEmpty = segments.stream()
.filter(item -> item.getText() != null && !item.getText().isBlank())
.toList();
if (nonEmpty.isEmpty()) {
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_NO_READABLE_TEXT,
"No readable text detected");
}
long charCount = nonEmpty.stream().mapToLong(item -> item.getText().length()).sum()
+ Math.max(0, nonEmpty.size() - 1L);
if (charCount > request.getMaxExpandedChars() || charCount > Integer.MAX_VALUE) {
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_STRUCTURE_LIMIT_EXCEEDED,
"Expanded document text exceeds the configured limit");
}
LightweightDocumentReadResult result = new LightweightDocumentReadResult();
result.setFileName(source.getFileName());
result.setMimeType(source.getMimeType());
result.setSegments(nonEmpty);
result.setCharCount((int) charCount);
result.setTokenEstimate(nonEmpty.stream().mapToInt(DocumentTextSegment::getTokenEstimate).sum());
return result;
}
/**
* 使用偏保守的字符规则估算 Token 数。
*
* @param text 文本
* @return Token 估算
*/
public static int estimateTokens(String text) {
if (text == null || text.isEmpty()) {
return 0;
}
double tokens = 0;
for (int offset = 0; offset < text.length();) {
int codePoint = text.codePointAt(offset);
Character.UnicodeScript script = Character.UnicodeScript.of(codePoint);
tokens += switch (script) {
case HAN, HANGUL, HIRAGANA, KATAKANA -> 1.0d;
default -> Character.isWhitespace(codePoint) ? 0.1d : 0.25d;
};
offset += Character.charCount(codePoint);
}
return Math.max(1, (int) Math.ceil(tokens));
}
}

View File

@@ -0,0 +1,80 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Licensed under the Apache License, Version 2.0.
*/
package com.easyagents.core.file2text;
import java.util.ArrayList;
import java.util.List;
/**
* 带稳定定位信息的文档文本片段。
*/
public class DocumentTextSegment {
private String segmentId;
private String text;
private String locatorType;
private String locatorLabel;
private int startIndex;
private int endIndex;
private List<String> headingPath = new ArrayList<>();
private int tokenEstimate;
/** @return 片段 ID */
public String getSegmentId() { return segmentId; }
/** @param segmentId 片段 ID */
public void setSegmentId(String segmentId) { this.segmentId = segmentId; }
/** @return 片段文本 */
public String getText() { return text; }
/** @param text 片段文本 */
public void setText(String text) { this.text = text; }
/** @return 定位类型 */
public String getLocatorType() { return locatorType; }
/** @param locatorType 定位类型 */
public void setLocatorType(String locatorType) { this.locatorType = locatorType; }
/** @return 可读定位标签 */
public String getLocatorLabel() { return locatorLabel; }
/** @param locatorLabel 可读定位标签 */
public void setLocatorLabel(String locatorLabel) { this.locatorLabel = locatorLabel; }
/** @return 全文起始字符下标 */
public int getStartIndex() { return startIndex; }
/** @param startIndex 全文起始字符下标 */
public void setStartIndex(int startIndex) { this.startIndex = startIndex; }
/** @return 全文结束字符下标 */
public int getEndIndex() { return endIndex; }
/** @param endIndex 全文结束字符下标 */
public void setEndIndex(int endIndex) { this.endIndex = endIndex; }
/** @return 标题路径 */
public List<String> getHeadingPath() { return headingPath; }
/** @param headingPath 标题路径 */
public void setHeadingPath(List<String> headingPath) {
this.headingPath = headingPath == null ? new ArrayList<>() : new ArrayList<>(headingPath);
}
/** @return Token 估算 */
public int getTokenEstimate() { return tokenEstimate; }
/** @param tokenEstimate Token 估算 */
public void setTokenEstimate(int tokenEstimate) { this.tokenEstimate = tokenEstimate; }
}

View File

@@ -22,9 +22,13 @@ import com.easyagents.core.file2text.source.*;
import java.io.File;
import java.io.InputStream;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
/**
* 文档轻量读取服务。
*/
public class File2TextService {
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(File2TextService.class);
private final ExtractorRegistry registry;
@@ -75,6 +79,44 @@ public class File2TextService {
* @throws IllegalArgumentException 输入源为空
*/
public String extractTextFromSource(DocumentSource source) {
return readFromSource(new LightweightDocumentReadRequest(source)).getText();
}
/**
* 从文件读取结构化文档内容。
*
* @param file 文档文件
* @return 结构化结果
*/
public LightweightDocumentReadResult readFromFile(File file) {
return readFromSource(new LightweightDocumentReadRequest(new FileDocumentSource(file)));
}
/**
* 从输入流读取结构化文档内容。
*
* @param inputStream 文档输入流
* @param fileName 文件名
* @param mimeType MIME 类型
* @return 结构化结果
*/
public LightweightDocumentReadResult readFromStream(InputStream inputStream, String fileName, String mimeType) {
return readFromSource(new LightweightDocumentReadRequest(
new ByteStreamDocumentSource(inputStream, fileName, mimeType)));
}
/**
* 按请求读取结构化文档内容。
*
* @param request 读取请求
* @return 结构化结果
* @throws DocumentReadException 不支持、空文本或读取失败
*/
public LightweightDocumentReadResult readFromSource(LightweightDocumentReadRequest request) {
if (request == null || request.getSource() == null) {
throw new IllegalArgumentException("Document read request cannot be null");
}
DocumentSource source = request.getSource();
if (source == null) {
throw new IllegalArgumentException("DocumentSource cannot be null");
}
@@ -83,8 +125,8 @@ public class File2TextService {
// 获取可用的 Extractor按优先级排序
List<FileExtractor> candidates = registry.findExtractors(source);
if (candidates.isEmpty()) {
log.warn("No extractor supports this document: " + safeFileName(source));
return null;
throw new DocumentReadException(DocumentReadErrorCode.UNSUPPORTED_DOCUMENT_TYPE,
"Unsupported document type: " + safeFileName(source));
}
// 日志:输出候选 Extractor
@@ -93,29 +135,41 @@ public class File2TextService {
.map(e -> e.getClass().getSimpleName())
.collect(Collectors.joining(", ")));
DocumentReadException lastFailure = null;
for (FileExtractor extractor : candidates) {
try {
log.debug("Trying {} on {}", extractor.getClass().getSimpleName(), safeFileName(source));
String text = extractor.extractText(source);
if (text != null && !text.trim().isEmpty()) {
LightweightDocumentReadResult result = extractor.read(request);
if (result != null && !result.getSegments().isEmpty()) {
log.debug("Success with {}: extracted {} chars",
extractor.getClass().getSimpleName(), text.length());
return text;
} else {
log.debug("Extractor {} returned null", extractor.getClass().getSimpleName());
extractor.getClass().getSimpleName(), result.getCharCount());
return result;
}
} catch (Exception e) {
} catch (DocumentReadException e) {
lastFailure = e;
log.warn("Extractor {} rejected {} with {}: {}",
extractor.getClass().getSimpleName(), safeFileName(source),
e.getErrorCode(), e.getMessage());
} catch (IOException e) {
lastFailure = new DocumentReadException(DocumentReadErrorCode.DOCUMENT_READ_FAILED,
"Failed to read document: " + safeFileName(source), e);
log.warn("Extractor {} failed on {}: {}",
extractor.getClass().getSimpleName(),
safeFileName(source),
e.toString());
} catch (RuntimeException e) {
lastFailure = new DocumentReadException(DocumentReadErrorCode.DOCUMENT_READ_FAILED,
"Failed to read document: " + safeFileName(source), e);
log.warn("Extractor {} failed on {}: {}",
extractor.getClass().getSimpleName(), safeFileName(source), e.toString());
}
}
log.warn(String.format("All %d extractors failed for: %s", candidates.size(), safeFileName(source)));
return null;
if (lastFailure != null) {
throw lastFailure;
}
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_NO_READABLE_TEXT,
"No readable text detected: " + safeFileName(source));
} finally {
source.cleanup();
}

View File

@@ -0,0 +1,100 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0.
*/
package com.easyagents.core.file2text;
import com.easyagents.core.file2text.source.DocumentSource;
import java.util.Objects;
import java.util.function.BooleanSupplier;
/**
* 轻量文档读取请求及结构安全边界。
*/
public class LightweightDocumentReadRequest {
private final DocumentSource source;
private int maxPdfPages = 200;
private int maxSlides = 200;
private int maxSheets = 20;
private int maxNonEmptyCells = 50_000;
private long maxExpandedChars = 150L * 1024L * 1024L;
private BooleanSupplier cancelled = () -> false;
/**
* 创建读取请求。
*
* @param source 文档来源
*/
public LightweightDocumentReadRequest(DocumentSource source) {
this.source = Objects.requireNonNull(source, "DocumentSource cannot be null");
}
/** @return 文档来源 */
public DocumentSource getSource() { return source; }
/** @return 最大 PDF 页数 */
public int getMaxPdfPages() { return maxPdfPages; }
/** @param maxPdfPages 最大 PDF 页数 */
public void setMaxPdfPages(int maxPdfPages) { this.maxPdfPages = positive(maxPdfPages, "maxPdfPages"); }
/** @return 最大幻灯片数 */
public int getMaxSlides() { return maxSlides; }
/** @param maxSlides 最大幻灯片数 */
public void setMaxSlides(int maxSlides) { this.maxSlides = positive(maxSlides, "maxSlides"); }
/** @return 最大工作表数 */
public int getMaxSheets() { return maxSheets; }
/** @param maxSheets 最大工作表数 */
public void setMaxSheets(int maxSheets) { this.maxSheets = positive(maxSheets, "maxSheets"); }
/** @return 最大非空单元格数 */
public int getMaxNonEmptyCells() { return maxNonEmptyCells; }
/** @param maxNonEmptyCells 最大非空单元格数 */
public void setMaxNonEmptyCells(int maxNonEmptyCells) {
this.maxNonEmptyCells = positive(maxNonEmptyCells, "maxNonEmptyCells");
}
/** @return 最大展开字符数 */
public long getMaxExpandedChars() { return maxExpandedChars; }
/** @param maxExpandedChars 最大展开字符数 */
public void setMaxExpandedChars(long maxExpandedChars) {
if (maxExpandedChars <= 0) {
throw new IllegalArgumentException("maxExpandedChars must be positive");
}
this.maxExpandedChars = maxExpandedChars;
}
/** @return 取消检查器 */
public BooleanSupplier getCancelled() { return cancelled; }
/** @param cancelled 取消检查器 */
public void setCancelled(BooleanSupplier cancelled) {
this.cancelled = cancelled == null ? () -> false : cancelled;
}
/**
* 检查当前读取是否已取消。
*
* @throws DocumentReadException 已取消时抛出
*/
public void checkCancelled() throws DocumentReadException {
if (cancelled.getAsBoolean() || Thread.currentThread().isInterrupted()) {
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_READ_CANCELLED, "Document read cancelled");
}
}
private int positive(int value, String name) {
if (value <= 0) {
throw new IllegalArgumentException(name + " must be positive");
}
return value;
}
}

View File

@@ -0,0 +1,91 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0.
*/
package com.easyagents.core.file2text;
import java.util.ArrayList;
import java.util.List;
/**
* 轻量文档结构化读取结果。
*/
public class LightweightDocumentReadResult {
/** 当前读取器版本。 */
public static final String READER_VERSION = "v1";
/** 当前读取策略版本。 */
public static final String READ_POLICY_VERSION = "v1";
private String fileName;
private String mimeType;
private String readerVersion = READER_VERSION;
private String readPolicyVersion = READ_POLICY_VERSION;
private int charCount;
private int tokenEstimate;
private List<DocumentTextSegment> segments = new ArrayList<>();
/** @return 文件名 */
public String getFileName() { return fileName; }
/** @param fileName 文件名 */
public void setFileName(String fileName) { this.fileName = fileName; }
/** @return MIME 类型 */
public String getMimeType() { return mimeType; }
/** @param mimeType MIME 类型 */
public void setMimeType(String mimeType) { this.mimeType = mimeType; }
/** @return 读取器版本 */
public String getReaderVersion() { return readerVersion; }
/** @param readerVersion 读取器版本 */
public void setReaderVersion(String readerVersion) { this.readerVersion = readerVersion; }
/** @return 读取策略版本 */
public String getReadPolicyVersion() { return readPolicyVersion; }
/** @param readPolicyVersion 读取策略版本 */
public void setReadPolicyVersion(String readPolicyVersion) { this.readPolicyVersion = readPolicyVersion; }
/** @return 字符数 */
public int getCharCount() { return charCount; }
/** @param charCount 字符数 */
public void setCharCount(int charCount) { this.charCount = charCount; }
/** @return Token 估算 */
public int getTokenEstimate() { return tokenEstimate; }
/** @param tokenEstimate Token 估算 */
public void setTokenEstimate(int tokenEstimate) { this.tokenEstimate = tokenEstimate; }
/** @return 文本片段 */
public List<DocumentTextSegment> getSegments() { return segments; }
/** @param segments 文本片段 */
public void setSegments(List<DocumentTextSegment> segments) {
this.segments = segments == null ? new ArrayList<>() : new ArrayList<>(segments);
}
/**
* 按片段顺序拼接兼容纯文本。
*
* @return 拼接后的文本
*/
public String getText() {
StringBuilder text = new StringBuilder(Math.max(0, charCount));
for (DocumentTextSegment segment : segments) {
if (segment.getText() == null || segment.getText().isBlank()) {
continue;
}
if (!text.isEmpty()) {
text.append('\n');
}
text.append(segment.getText());
}
return text.toString();
}
}

View File

@@ -35,7 +35,10 @@ public class ExtractorRegistry {
register(new PdfTextExtractor());
register(new DocxExtractor());
register(new DocExtractor());
register(new PptExtractor());
register(new PptxExtractor());
register(new XlsExtractor());
register(new XlsxExtractor());
register(new HtmlExtractor());
register(new PlainTextExtractor());
}

View File

@@ -15,24 +15,59 @@
*/
package com.easyagents.core.file2text.extractor;
import com.easyagents.core.file2text.DocumentReadSupport;
import com.easyagents.core.file2text.LightweightDocumentReadRequest;
import com.easyagents.core.file2text.LightweightDocumentReadResult;
import com.easyagents.core.file2text.source.DocumentSource;
import java.io.IOException;
import java.util.Comparator;
import java.util.List;
/**
* 文档文本提取器。
*/
public interface FileExtractor {
Comparator<FileExtractor> ORDER_COMPARATOR =
Comparator.comparingInt(FileExtractor::getOrder);
/**
* 判断该 Extractor 是否支持处理此文档
* 判断该 Extractor 是否支持处理此文档
*
* @param source 文档来源
* @return 是否支持
*/
boolean supports(DocumentSource source);
/**
* 提取兼容纯文本。
*
* @param source 文档来源
* @return 提取文本
* @throws IOException 文档读取失败
*/
String extractText(DocumentSource source) throws IOException;
/**
* 提取结构化文档片段。
*
* @param request 读取请求
* @return 结构化结果
* @throws IOException 文档读取失败
*/
default LightweightDocumentReadResult read(LightweightDocumentReadRequest request) throws IOException {
String text = extractText(request.getSource());
return DocumentReadSupport.result(request.getSource(),
List.of(DocumentReadSupport.segment("segment-1", text, "DOCUMENT", "全文", 0, List.of())),
request);
}
/**
* 获取读取器优先级。
*
* @return 越小越优先
*/
default int getOrder() {
return 100;
}

View File

@@ -15,6 +15,12 @@
*/
package com.easyagents.core.file2text.extractor.impl;
import com.easyagents.core.file2text.DocumentReadException;
import com.easyagents.core.file2text.DocumentReadErrorCode;
import com.easyagents.core.file2text.DocumentReadSupport;
import com.easyagents.core.file2text.DocumentTextSegment;
import com.easyagents.core.file2text.LightweightDocumentReadRequest;
import com.easyagents.core.file2text.LightweightDocumentReadResult;
import com.easyagents.core.file2text.extractor.FileExtractor;
import com.easyagents.core.file2text.source.DocumentSource;
import org.apache.poi.hwpf.HWPFDocument;
@@ -24,7 +30,9 @@ import org.apache.poi.poifs.filesystem.POIFSFileSystem;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collections;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
@@ -68,25 +76,47 @@ public class DocExtractor implements FileExtractor {
@Override
public String extractText(DocumentSource source) throws IOException {
return read(new LightweightDocumentReadRequest(source)).getText();
}
/**
* 按段落读取 Word 97-2003 文档。
*
* @param request 读取请求
* @return 段落结构化结果
* @throws IOException 文档 I/O 失败
*/
@Override
public LightweightDocumentReadResult read(LightweightDocumentReadRequest request) throws IOException {
DocumentSource source = request.getSource();
try (InputStream is = source.openStream();
POIFSFileSystem fs = new POIFSFileSystem(is);
HWPFDocument doc = new HWPFDocument(fs)) {
WordExtractor extractor = new WordExtractor(doc);
String[] paragraphs = extractor.getParagraphText();
StringBuilder text = new StringBuilder();
for (String para : paragraphs) {
try (WordExtractor extractor = new WordExtractor(doc)) {
String[] paragraphs = extractor.getParagraphText();
List<DocumentTextSegment> segments = new ArrayList<>();
int offset = 0;
for (int index = 0; index < paragraphs.length; index++) {
request.checkCancelled();
String para = paragraphs[index];
// 清理控制字符
String clean = para.replaceAll("[\\r\\001]+", "").trim();
if (!clean.isEmpty()) {
text.append(clean).append("\n");
String clean = para.replaceAll("[\\r\\001]+", "").trim();
if (!clean.isEmpty()) {
DocumentTextSegment segment = DocumentReadSupport.segment(
"paragraph-" + (index + 1), clean, "PARAGRAPH",
"" + (index + 1) + "", offset, List.of());
segments.add(segment);
offset = segment.getEndIndex() + 1;
}
}
return DocumentReadSupport.result(source, segments, request);
}
return text.toString().trim();
} catch (DocumentReadException e) {
throw e;
} catch (Exception e) {
throw new IOException("Failed to extract .doc file: " + e.getMessage(), e);
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_CORRUPTED,
"Failed to extract .doc file", e);
}
}

View File

@@ -16,12 +16,19 @@
package com.easyagents.core.file2text.extractor.impl;
import com.easyagents.core.file2text.DocumentReadErrorCode;
import com.easyagents.core.file2text.DocumentReadException;
import com.easyagents.core.file2text.DocumentReadSupport;
import com.easyagents.core.file2text.DocumentTextSegment;
import com.easyagents.core.file2text.LightweightDocumentReadRequest;
import com.easyagents.core.file2text.LightweightDocumentReadResult;
import com.easyagents.core.file2text.extractor.FileExtractor;
import com.easyagents.core.file2text.source.DocumentSource;
import org.apache.poi.xwpf.usermodel.*;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
@@ -79,37 +86,66 @@ public class DocxExtractor implements FileExtractor {
@Override
public String extractText(DocumentSource source) throws IOException {
StringBuilder text = new StringBuilder();
return read(new LightweightDocumentReadRequest(source)).getText();
}
/**
* 按正文顺序读取 DOCX 段落和表格。
*
* @param request 读取请求
* @return 结构化读取结果
* @throws IOException 文档 I/O 失败
*/
@Override
public LightweightDocumentReadResult read(LightweightDocumentReadRequest request) throws IOException {
DocumentSource source = request.getSource();
try (InputStream is = source.openStream();
XWPFDocument document = new XWPFDocument(is)) {
// 提取段落
for (XWPFParagraph paragraph : document.getParagraphs()) {
String paraText = getParagraphText(paragraph);
if (paraText != null && !paraText.trim().isEmpty()) {
text.append(paraText).append("\n");
List<DocumentTextSegment> segments = new ArrayList<>();
List<String> headingPath = new ArrayList<>();
int paragraphIndex = 0;
int tableIndex = 0;
int offset = 0;
for (IBodyElement element : document.getBodyElements()) {
request.checkCancelled();
if (element instanceof XWPFParagraph paragraph) {
paragraphIndex++;
String text = getParagraphText(paragraph);
if (text == null || text.isBlank()) {
continue;
}
int headingLevel = headingLevel(paragraph);
if (headingLevel > 0) {
while (headingPath.size() >= headingLevel) {
headingPath.remove(headingPath.size() - 1);
}
headingPath.add(text.trim());
}
DocumentTextSegment segment = DocumentReadSupport.segment(
"paragraph-" + paragraphIndex, text, "PARAGRAPH",
"" + paragraphIndex + "", offset, headingPath);
segments.add(segment);
offset = segment.getEndIndex() + 1;
} else if (element instanceof XWPFTable table) {
tableIndex++;
String tableText = getTableText(table);
if (tableText.isBlank()) {
continue;
}
DocumentTextSegment segment = DocumentReadSupport.segment(
"table-" + tableIndex, tableText, "TABLE",
"" + tableIndex + " 个表格", offset, headingPath);
segments.add(segment);
offset = segment.getEndIndex() + 1;
}
}
// 提取表格
for (XWPFTable table : document.getTables()) {
text.append("\n[Table Start]\n");
for (XWPFTableRow row : table.getRows()) {
List<String> cellTexts = row.getTableCells().stream()
.map(this::getCellText)
.map(String::trim)
.collect(Collectors.toList());
text.append(cellTexts).append("\n");
}
text.append("[Table End]\n\n");
}
return DocumentReadSupport.result(source, segments, request);
} catch (DocumentReadException e) {
throw e;
} catch (Exception e) {
throw new IOException("Failed to extract DOCX: " + e.getMessage(), e);
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_CORRUPTED,
"Failed to extract DOCX", e);
}
return text.toString().trim();
}
private String getParagraphText(XWPFParagraph paragraph) {
@@ -138,6 +174,52 @@ public class DocxExtractor implements FileExtractor {
return text.toString().trim();
}
/**
* 获取表格的显示文本。
*
* @param table 表格
* @return 表格文本
*/
private String getTableText(XWPFTable table) {
StringBuilder text = new StringBuilder();
for (XWPFTableRow row : table.getRows()) {
List<String> cellTexts = row.getTableCells().stream()
.map(this::getCellText)
.map(String::trim)
.collect(Collectors.toList());
if (cellTexts.stream().anyMatch(item -> !item.isEmpty())) {
text.append(String.join(" | ", cellTexts)).append('\n');
}
}
return text.toString().trim();
}
/**
* 解析常见 Word 标题样式层级。
*
* @param paragraph 段落
* @return 标题层级,非标题返回 0
*/
private int headingLevel(XWPFParagraph paragraph) {
String style = paragraph.getStyle();
if (style == null) {
return 0;
}
String normalized = style.replaceAll("\\s+", "").toLowerCase();
if (!normalized.startsWith("heading") && !normalized.startsWith("标题")) {
return 0;
}
String digits = normalized.replaceAll("\\D+", "");
if (digits.isEmpty()) {
return 1;
}
try {
return Math.max(1, Math.min(9, Integer.parseInt(digits)));
} catch (NumberFormatException ignored) {
return 1;
}
}
@Override
public int getOrder() {
return 10;

View File

@@ -15,15 +15,24 @@
*/
package com.easyagents.core.file2text.extractor.impl;
import com.easyagents.core.file2text.DocumentReadErrorCode;
import com.easyagents.core.file2text.DocumentReadException;
import com.easyagents.core.file2text.DocumentReadSupport;
import com.easyagents.core.file2text.DocumentTextSegment;
import com.easyagents.core.file2text.LightweightDocumentReadRequest;
import com.easyagents.core.file2text.LightweightDocumentReadResult;
import com.easyagents.core.file2text.extractor.FileExtractor;
import com.easyagents.core.file2text.source.DocumentSource;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.encryption.InvalidPasswordException;
import org.apache.pdfbox.text.PDFTextStripper;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collections;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
@@ -64,12 +73,54 @@ public class PdfTextExtractor implements FileExtractor {
@Override
public String extractText(DocumentSource source) throws IOException {
return read(new LightweightDocumentReadRequest(source)).getText();
}
/**
* 按页读取 PDF 文本层。
*
* @param request 读取请求
* @return 按页结构化结果
* @throws IOException PDF I/O 失败
*/
@Override
public LightweightDocumentReadResult read(LightweightDocumentReadRequest request) throws IOException {
DocumentSource source = request.getSource();
try (InputStream is = source.openStream();
PDDocument doc = PDDocument.load(is)) {
if (doc.isEncrypted()) {
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_ENCRYPTED,
"Encrypted PDF is not supported");
}
int pages = doc.getNumberOfPages();
if (pages > request.getMaxPdfPages()) {
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_STRUCTURE_LIMIT_EXCEEDED,
"PDF page count exceeds " + request.getMaxPdfPages());
}
PDFTextStripper stripper = new PDFTextStripper();
return stripper.getText(doc).trim();
List<DocumentTextSegment> segments = new ArrayList<>();
int offset = 0;
for (int page = 1; page <= pages; page++) {
request.checkCancelled();
stripper.setStartPage(page);
stripper.setEndPage(page);
String text = stripper.getText(doc).trim();
if (!text.isEmpty()) {
DocumentTextSegment segment = DocumentReadSupport.segment(
"page-" + page, text, "PAGE", "" + page + "", offset, List.of());
segments.add(segment);
offset = segment.getEndIndex() + 1;
}
}
return DocumentReadSupport.result(source, segments, request);
} catch (InvalidPasswordException e) {
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_ENCRYPTED,
"Encrypted PDF is not supported", e);
} catch (DocumentReadException e) {
throw e;
} catch (Exception e) {
throw new IOException("Failed to extract PDF text: " + e.getMessage(), e);
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_CORRUPTED,
"Failed to extract PDF text", e);
}
}

View File

@@ -16,6 +16,12 @@
package com.easyagents.core.file2text.extractor.impl;
import com.easyagents.core.file2text.DocumentReadErrorCode;
import com.easyagents.core.file2text.DocumentReadException;
import com.easyagents.core.file2text.DocumentReadSupport;
import com.easyagents.core.file2text.DocumentTextSegment;
import com.easyagents.core.file2text.LightweightDocumentReadRequest;
import com.easyagents.core.file2text.LightweightDocumentReadResult;
import com.easyagents.core.file2text.extractor.FileExtractor;
import com.easyagents.core.file2text.source.DocumentSource;
@@ -23,9 +29,17 @@ import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 纯文本文件提取器(支持 UTF-8、GBK、GB2312 编码自动检测)
@@ -33,6 +47,9 @@ import java.util.Set;
*/
public class PlainTextExtractor implements FileExtractor {
private static final int LINES_PER_SEGMENT = 40;
private static final Charset GB18030 = Charset.forName("GB18030");
private static final Pattern MARKDOWN_HEADING = Pattern.compile("^(#{1,6})\\s+(.+?)\\s*$");
private static final Set<String> SUPPORTED_MIME_TYPES;
private static final Set<String> SUPPORTED_EXTENSIONS;
@@ -80,21 +97,160 @@ public class PlainTextExtractor implements FileExtractor {
@Override
public String extractText(DocumentSource source) throws IOException {
try (InputStream is = source.openStream()) {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, "utf-8"))) {
StringBuilder text = new StringBuilder();
char[] buffer = new char[8192];
int read;
while ((read = reader.read(buffer)) != -1) {
text.append(buffer, 0, read);
}
return text.toString().trim();
return read(new LightweightDocumentReadRequest(source)).getText();
}
/**
* 按文本行区间读取 TXT 或 Markdown。
*
* @param request 读取请求
* @return 行区间结构化结果
* @throws IOException 文本 I/O 失败
*/
@Override
public LightweightDocumentReadResult read(LightweightDocumentReadRequest request) throws IOException {
DocumentSource source = request.getSource();
CharsetDetection detection = detectCharset(source);
try {
return readWithCharset(request, detection.charset(), detection.bomLength());
} catch (CharacterCodingException error) {
if (!StandardCharsets.UTF_8.equals(detection.charset()) || detection.bomLength() > 0) {
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_CORRUPTED,
"Text encoding is invalid", error);
}
} catch (Exception e) {
throw new RuntimeException(e);
// 严格 UTF-8 解码失败时仅回退到受控 GB18030。
return readWithCharset(request, GB18030, 0);
} catch (DocumentReadException error) {
throw error;
} catch (Exception error) {
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_READ_FAILED,
"Failed to read text document", error);
}
}
/**
* 使用指定字符集流式读取文本。
*
* @param request 读取请求
* @param charset 字符集
* @param bomLength BOM 长度
* @return 结构化结果
* @throws Exception 打开或读取失败
*/
private LightweightDocumentReadResult readWithCharset(LightweightDocumentReadRequest request,
Charset charset,
int bomLength) throws IOException {
DocumentSource source = request.getSource();
List<DocumentTextSegment> segments = new ArrayList<>();
List<String> headingPath = new ArrayList<>();
boolean markdown = isMarkdown(source.getFileName(), source.getMimeType());
try (InputStream input = openStream(source)) {
input.skipNBytes(bomLength);
InputStreamReader streamReader = new InputStreamReader(input,
charset.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT));
try (BufferedReader reader = new BufferedReader(streamReader, 8192)) {
StringBuilder block = new StringBuilder();
int line = 0;
int blockStart = 1;
int offset = 0;
String value;
while ((value = reader.readLine()) != null) {
request.checkCancelled();
line++;
if (markdown) {
updateHeadingPath(headingPath, value);
}
if (!block.isEmpty()) {
block.append('\n');
}
block.append(value);
if (line - blockStart + 1 >= LINES_PER_SEGMENT) {
DocumentTextSegment segment = addLineSegment(
segments, block, blockStart, line, offset, headingPath);
offset = segment == null ? offset : segment.getEndIndex() + 1;
block.setLength(0);
blockStart = line + 1;
}
if ((long) offset + block.length() > request.getMaxExpandedChars()) {
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_STRUCTURE_LIMIT_EXCEEDED,
"Expanded text exceeds the configured limit");
}
}
if (!block.isEmpty()) {
addLineSegment(segments, block, blockStart, line, offset, headingPath);
}
}
}
return DocumentReadSupport.result(source, segments, request);
}
private InputStream openStream(DocumentSource source) throws IOException {
try {
return source.openStream();
} catch (IOException error) {
throw error;
} catch (Exception error) {
throw new IOException("Failed to open text document", error);
}
}
private DocumentTextSegment addLineSegment(List<DocumentTextSegment> segments,
StringBuilder block,
int startLine,
int endLine,
int offset,
List<String> headingPath) {
if (block.toString().isBlank()) {
return null;
}
DocumentTextSegment segment = DocumentReadSupport.segment(
"lines-" + startLine + "-" + endLine, block.toString(), "LINE_RANGE",
"" + startLine + "-" + endLine + "", offset, headingPath);
segments.add(segment);
return segment;
}
private void updateHeadingPath(List<String> headingPath, String line) {
Matcher matcher = MARKDOWN_HEADING.matcher(line);
if (!matcher.matches()) {
return;
}
int level = matcher.group(1).length();
while (headingPath.size() >= level) {
headingPath.remove(headingPath.size() - 1);
}
headingPath.add(matcher.group(2).trim());
}
private CharsetDetection detectCharset(DocumentSource source) throws IOException {
try (InputStream input = source.openStream()) {
byte[] prefix = input.readNBytes(3);
if (prefix.length >= 3 && (prefix[0] & 0xff) == 0xef
&& (prefix[1] & 0xff) == 0xbb && (prefix[2] & 0xff) == 0xbf) {
return new CharsetDetection(StandardCharsets.UTF_8, 3);
}
if (prefix.length >= 2 && (prefix[0] & 0xff) == 0xff && (prefix[1] & 0xff) == 0xfe) {
return new CharsetDetection(StandardCharsets.UTF_16LE, 2);
}
if (prefix.length >= 2 && (prefix[0] & 0xff) == 0xfe && (prefix[1] & 0xff) == 0xff) {
return new CharsetDetection(StandardCharsets.UTF_16BE, 2);
}
return new CharsetDetection(StandardCharsets.UTF_8, 0);
} catch (IOException error) {
throw error;
} catch (Exception error) {
throw new IOException("Failed to inspect text encoding", error);
}
}
private boolean isMarkdown(String fileName, String mimeType) {
return "text/markdown".equalsIgnoreCase(mimeType)
|| (fileName != null && (fileName.toLowerCase().endsWith(".md")
|| fileName.toLowerCase().endsWith(".markdown")));
}
@Override
public int getOrder() {
@@ -106,4 +262,7 @@ public class PlainTextExtractor implements FileExtractor {
int lastDot = fileName.lastIndexOf('.');
return fileName.substring(lastDot + 1).toLowerCase();
}
private record CharsetDetection(Charset charset, int bomLength) {
}
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0.
*/
package com.easyagents.core.file2text.extractor.impl;
import com.easyagents.core.file2text.DocumentReadErrorCode;
import com.easyagents.core.file2text.DocumentReadException;
import com.easyagents.core.file2text.DocumentReadSupport;
import com.easyagents.core.file2text.DocumentTextSegment;
import com.easyagents.core.file2text.LightweightDocumentReadRequest;
import com.easyagents.core.file2text.LightweightDocumentReadResult;
import com.easyagents.core.file2text.extractor.FileExtractor;
import com.easyagents.core.file2text.source.DocumentSource;
import org.apache.poi.hslf.usermodel.HSLFShape;
import org.apache.poi.hslf.usermodel.HSLFSlide;
import org.apache.poi.hslf.usermodel.HSLFSlideShow;
import org.apache.poi.hslf.usermodel.HSLFTextShape;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* PowerPoint 97-2003 文档提取器。
*/
public class PptExtractor implements FileExtractor {
private static final Set<String> MIME_TYPES = Set.of(
"application/vnd.ms-powerpoint",
"application/mspowerpoint",
"application/powerpoint");
/**
* 判断是否支持 PPT。
*
* @param source 文档来源
* @return 是否支持
*/
@Override
public boolean supports(DocumentSource source) {
if (source.getMimeType() != null && MIME_TYPES.contains(source.getMimeType().toLowerCase(Locale.ROOT))) {
return true;
}
String fileName = source.getFileName();
return fileName != null && (fileName.toLowerCase(Locale.ROOT).endsWith(".ppt")
|| fileName.toLowerCase(Locale.ROOT).endsWith(".pps"));
}
/**
* 提取兼容纯文本。
*
* @param source 文档来源
* @return 文本
* @throws IOException 文档 I/O 失败
*/
@Override
public String extractText(DocumentSource source) throws IOException {
return read(new LightweightDocumentReadRequest(source)).getText();
}
/**
* 按幻灯片读取 PPT 文本。
*
* @param request 读取请求
* @return 幻灯片结构化结果
* @throws IOException 文档 I/O 失败
*/
@Override
public LightweightDocumentReadResult read(LightweightDocumentReadRequest request) throws IOException {
DocumentSource source = request.getSource();
try (InputStream input = source.openStream();
HSLFSlideShow slideShow = new HSLFSlideShow(input)) {
List<HSLFSlide> slides = slideShow.getSlides();
if (slides.size() > request.getMaxSlides()) {
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_STRUCTURE_LIMIT_EXCEEDED,
"Slide count exceeds " + request.getMaxSlides());
}
List<DocumentTextSegment> segments = new ArrayList<>();
int offset = 0;
for (int index = 0; index < slides.size(); index++) {
request.checkCancelled();
StringBuilder text = new StringBuilder();
for (HSLFShape shape : slides.get(index).getShapes()) {
if (shape instanceof HSLFTextShape textShape) {
String value = textShape.getText();
if (value != null && !value.isBlank()) {
text.append(value.trim()).append('\n');
}
}
}
if (!text.toString().isBlank()) {
DocumentTextSegment segment = DocumentReadSupport.segment(
"slide-" + (index + 1), text.toString(), "SLIDE",
"" + (index + 1) + " 张幻灯片", offset, List.of());
segments.add(segment);
offset = segment.getEndIndex() + 1;
}
}
return DocumentReadSupport.result(source, segments, request);
} catch (DocumentReadException error) {
throw error;
} catch (Exception error) {
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_CORRUPTED,
"Failed to extract PPT", error);
}
}
/**
* 获取读取器优先级。
*
* @return 优先级
*/
@Override
public int getOrder() {
return 12;
}
}

View File

@@ -15,6 +15,12 @@
*/
package com.easyagents.core.file2text.extractor.impl;
import com.easyagents.core.file2text.DocumentReadErrorCode;
import com.easyagents.core.file2text.DocumentReadException;
import com.easyagents.core.file2text.DocumentReadSupport;
import com.easyagents.core.file2text.DocumentTextSegment;
import com.easyagents.core.file2text.LightweightDocumentReadRequest;
import com.easyagents.core.file2text.LightweightDocumentReadResult;
import com.easyagents.core.file2text.extractor.FileExtractor;
import com.easyagents.core.file2text.source.DocumentSource;
import org.apache.poi.xslf.usermodel.*;
@@ -77,21 +83,37 @@ public class PptxExtractor implements FileExtractor {
@Override
public String extractText(DocumentSource source) throws IOException {
StringBuilder text = new StringBuilder();
return read(new LightweightDocumentReadRequest(source)).getText();
}
/**
* 按幻灯片读取 PPTX 文本。
*
* @param request 读取请求
* @return 幻灯片结构化结果
* @throws IOException 文档 I/O 失败
*/
@Override
public LightweightDocumentReadResult read(LightweightDocumentReadRequest request) throws IOException {
DocumentSource source = request.getSource();
try (InputStream is = source.openStream();
XMLSlideShow slideShow = new XMLSlideShow(is)) {
List<XSLFSlide> slides = slideShow.getSlides();
if (slides.size() > request.getMaxSlides()) {
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_STRUCTURE_LIMIT_EXCEEDED,
"Slide count exceeds " + request.getMaxSlides());
}
List<DocumentTextSegment> segments = new ArrayList<>();
int offset = 0;
for (int i = 0; i < slides.size(); i++) {
request.checkCancelled();
XSLFSlide slide = slides.get(i);
text.append("\n--- Slide ").append(i + 1).append(" ---\n");
StringBuilder text = new StringBuilder();
// 提取所有形状中的文本
for (XSLFShape shape : slide.getShapes()) {
if (shape instanceof XSLFTextShape) {
XSLFTextShape textShape = (XSLFTextShape) shape;
if (shape instanceof XSLFTextShape textShape && !(shape instanceof XSLFTable)) {
String shapeText = textShape.getText();
if (shapeText != null && !shapeText.trim().isEmpty()) {
text.append(shapeText).append("\n");
@@ -101,15 +123,24 @@ public class PptxExtractor implements FileExtractor {
// 可选:提取表格
extractTablesFromSlide(slide, text);
if (!text.toString().isBlank()) {
DocumentTextSegment segment = DocumentReadSupport.segment(
"slide-" + (i + 1), text.toString(), "SLIDE",
"" + (i + 1) + " 张幻灯片", offset, List.of());
segments.add(segment);
offset = segment.getEndIndex() + 1;
}
}
return DocumentReadSupport.result(source, segments, request);
} catch (DocumentReadException e) {
throw e;
} catch (XmlException e) {
throw new IOException("Invalid PPTX structure: " + e.getMessage(), e);
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_CORRUPTED,
"Invalid PPTX structure", e);
} catch (Exception e) {
throw new IOException("Failed to extract PPTX: " + e.getMessage(), e);
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_CORRUPTED,
"Failed to extract PPTX", e);
}
return text.toString().trim();
}
/**

View File

@@ -0,0 +1,76 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0.
*/
package com.easyagents.core.file2text.extractor.impl;
import com.easyagents.core.file2text.DocumentReadSupport;
import com.easyagents.core.file2text.DocumentTextSegment;
import java.util.ArrayList;
import java.util.List;
/**
* 表格行到稳定片段的内部转换工具。
*/
final class SpreadsheetReadSupport {
private static final int ROWS_PER_SEGMENT = 25;
private SpreadsheetReadSupport() {
}
/**
* 将工作表行按固定区间组成片段。
*
* @param sheets 工作表数据
* @return 文本片段
*/
static List<DocumentTextSegment> toSegments(List<SheetRows> sheets) {
List<DocumentTextSegment> segments = new ArrayList<>();
int offset = 0;
for (SheetRows sheet : sheets) {
for (int start = 0; start < sheet.rows().size(); start += ROWS_PER_SEGMENT) {
int end = Math.min(sheet.rows().size(), start + ROWS_PER_SEGMENT);
List<RowText> rows = sheet.rows().subList(start, end);
StringBuilder text = new StringBuilder();
for (RowText row : rows) {
if (!text.isEmpty()) {
text.append('\n');
}
text.append("").append(row.rowNumber()).append(" 行: ").append(row.text());
}
int startRow = rows.get(0).rowNumber();
int endRow = rows.get(rows.size() - 1).rowNumber();
DocumentTextSegment segment = DocumentReadSupport.segment(
"sheet-" + sheet.sheetIndex() + "-rows-" + startRow + "-" + endRow,
text.toString(), "SHEET_ROW_RANGE",
sheet.sheetName() + "" + startRow + "-" + endRow + "",
offset, List.of(sheet.sheetName()));
segments.add(segment);
offset = segment.getEndIndex() + 1;
}
}
return segments;
}
/**
* 单个工作表的有效行。
*
* @param sheetIndex 工作表序号
* @param sheetName 工作表名
* @param rows 有效行
*/
record SheetRows(int sheetIndex, String sheetName, List<RowText> rows) {
}
/**
* 单行格式化文本。
*
* @param rowNumber 行号
* @param text 文本
*/
record RowText(int rowNumber, String text) {
}
}

View File

@@ -0,0 +1,256 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0.
*/
package com.easyagents.core.file2text.extractor.impl;
import com.easyagents.core.file2text.DocumentReadErrorCode;
import com.easyagents.core.file2text.DocumentReadException;
import com.easyagents.core.file2text.DocumentReadSupport;
import com.easyagents.core.file2text.LightweightDocumentReadRequest;
import com.easyagents.core.file2text.LightweightDocumentReadResult;
import com.easyagents.core.file2text.extractor.FileExtractor;
import com.easyagents.core.file2text.source.DocumentSource;
import org.apache.poi.hssf.eventusermodel.FormatTrackingHSSFListener;
import org.apache.poi.hssf.eventusermodel.HSSFEventFactory;
import org.apache.poi.hssf.eventusermodel.HSSFListener;
import org.apache.poi.hssf.eventusermodel.HSSFRequest;
import org.apache.poi.hssf.record.BOFRecord;
import org.apache.poi.hssf.record.BoolErrRecord;
import org.apache.poi.hssf.record.BoundSheetRecord;
import org.apache.poi.hssf.record.FormulaRecord;
import org.apache.poi.hssf.record.LabelRecord;
import org.apache.poi.hssf.record.LabelSSTRecord;
import org.apache.poi.hssf.record.NumberRecord;
import org.apache.poi.hssf.record.Record;
import org.apache.poi.hssf.record.SSTRecord;
import org.apache.poi.hssf.record.StringRecord;
import org.apache.poi.poifs.filesystem.POIFSFileSystem;
import org.apache.poi.ss.util.CellReference;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* 基于 HSSF Event API 的 XLS 文档提取器。
*/
public class XlsExtractor implements FileExtractor {
private static final Set<String> MIME_TYPES = Set.of(
"application/vnd.ms-excel",
"application/msexcel",
"application/x-msexcel");
/**
* 判断是否支持 XLS。
*
* @param source 文档来源
* @return 是否支持
*/
@Override
public boolean supports(DocumentSource source) {
if (source.getMimeType() != null && MIME_TYPES.contains(source.getMimeType().toLowerCase(Locale.ROOT))) {
return true;
}
String name = source.getFileName();
return name != null && (name.toLowerCase(Locale.ROOT).endsWith(".xls")
|| name.toLowerCase(Locale.ROOT).endsWith(".xlt"));
}
/**
* 提取兼容纯文本。
*
* @param source 文档来源
* @return 文本
* @throws IOException 文档 I/O 失败
*/
@Override
public String extractText(DocumentSource source) throws IOException {
return read(new LightweightDocumentReadRequest(source)).getText();
}
/**
* 使用 HSSF 事件模型按工作表和行读取 XLS。
*
* @param request 读取请求
* @return 表格结构化结果
* @throws IOException 文档 I/O 失败
*/
@Override
public LightweightDocumentReadResult read(LightweightDocumentReadRequest request) throws IOException {
DocumentSource source = request.getSource();
try (InputStream input = source.openStream();
POIFSFileSystem fileSystem = new POIFSFileSystem(input)) {
XlsListener listener = new XlsListener(request);
FormatTrackingHSSFListener formatter = new FormatTrackingHSSFListener(listener);
listener.setFormatter(formatter);
HSSFRequest hssfRequest = new HSSFRequest();
hssfRequest.addListenerForAllRecords(formatter);
new HSSFEventFactory().processWorkbookEvents(hssfRequest, fileSystem);
listener.finish();
return DocumentReadSupport.result(source,
SpreadsheetReadSupport.toSegments(listener.sheets()), request);
} catch (StructureLimitRuntimeException error) {
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_STRUCTURE_LIMIT_EXCEEDED,
error.getMessage(), error);
} catch (DocumentReadException error) {
throw error;
} catch (Exception error) {
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_CORRUPTED,
"Failed to extract XLS", error);
}
}
/**
* 获取读取器优先级。
*
* @return 优先级
*/
@Override
public int getOrder() {
return 10;
}
/**
* HSSF 二进制记录监听器。
*/
private static final class XlsListener implements HSSFListener {
private final LightweightDocumentReadRequest request;
private final List<BoundSheetRecord> boundSheets = new ArrayList<>();
private final List<SpreadsheetReadSupport.SheetRows> sheets = new ArrayList<>();
private final List<SpreadsheetReadSupport.RowText> currentRows = new ArrayList<>();
private final Map<Integer, String> currentCells = new LinkedHashMap<>();
private FormatTrackingHSSFListener formatter;
private SSTRecord sharedStrings;
private int sheetIndex;
private int currentRow = -1;
private int nonEmptyCells;
private int pendingFormulaRow = -1;
private int pendingFormulaColumn = -1;
private XlsListener(LightweightDocumentReadRequest request) {
this.request = request;
}
private void setFormatter(FormatTrackingHSSFListener formatter) {
this.formatter = formatter;
}
/**
* 处理一个 HSSF 记录。
*
* @param record 工作簿记录
*/
@Override
public void processRecord(Record record) {
checkCancelled();
if (record instanceof BoundSheetRecord boundSheet) {
boundSheets.add(boundSheet);
} else if (record instanceof SSTRecord sstRecord) {
sharedStrings = sstRecord;
} else if (record instanceof BOFRecord bofRecord
&& bofRecord.getType() == BOFRecord.TYPE_WORKSHEET) {
startSheet();
} else if (record instanceof LabelSSTRecord label) {
String value = sharedStrings == null ? "" : sharedStrings.getString(label.getSSTIndex()).toString();
putCell(label.getRow(), label.getColumn(), value);
} else if (record instanceof LabelRecord label) {
putCell(label.getRow(), label.getColumn(), label.getValue());
} else if (record instanceof NumberRecord number) {
putCell(number.getRow(), number.getColumn(), formatter.formatNumberDateCell(number));
} else if (record instanceof FormulaRecord formula) {
if (formula.hasCachedResultString()) {
pendingFormulaRow = formula.getRow();
pendingFormulaColumn = formula.getColumn();
} else {
putCell(formula.getRow(), formula.getColumn(), formatter.formatNumberDateCell(formula));
}
} else if (record instanceof StringRecord string && pendingFormulaRow >= 0) {
putCell(pendingFormulaRow, pendingFormulaColumn, string.getString());
pendingFormulaRow = -1;
pendingFormulaColumn = -1;
} else if (record instanceof BoolErrRecord boolError && boolError.isBoolean()) {
putCell(boolError.getRow(), boolError.getColumn(),
Boolean.toString(boolError.getBooleanValue()));
}
}
private void startSheet() {
flushSheet();
sheetIndex++;
if (sheetIndex > request.getMaxSheets()) {
throw new StructureLimitRuntimeException(
"Sheet count exceeds " + request.getMaxSheets());
}
}
private void putCell(int row, int column, String value) {
if (value == null || value.isBlank()) {
return;
}
if (currentRow >= 0 && row != currentRow) {
flushRow();
}
currentRow = row;
nonEmptyCells++;
if (nonEmptyCells > request.getMaxNonEmptyCells()) {
throw new StructureLimitRuntimeException(
"Non-empty cell count exceeds " + request.getMaxNonEmptyCells());
}
currentCells.put(column,
CellReference.convertNumToColString(column) + (row + 1) + "=" + value.trim());
}
private void flushRow() {
if (!currentCells.isEmpty() && currentRow >= 0) {
String text = String.join(" | ", currentCells.values());
currentRows.add(new SpreadsheetReadSupport.RowText(currentRow + 1, text));
}
currentCells.clear();
currentRow = -1;
}
private void flushSheet() {
flushRow();
if (sheetIndex <= 0) {
return;
}
String name = sheetIndex <= boundSheets.size()
? boundSheets.get(sheetIndex - 1).getSheetname()
: "Sheet " + sheetIndex;
sheets.add(new SpreadsheetReadSupport.SheetRows(
sheetIndex, name, new ArrayList<>(currentRows)));
currentRows.clear();
}
private void finish() {
flushSheet();
}
private void checkCancelled() {
request.checkCancelled();
}
private List<SpreadsheetReadSupport.SheetRows> sheets() {
return sheets;
}
}
/**
* HSSF 回调中传递读取中止或结构上限异常。
*/
private static final class StructureLimitRuntimeException extends RuntimeException {
private StructureLimitRuntimeException(String message) {
super(message);
}
}
}

View File

@@ -0,0 +1,228 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0.
*/
package com.easyagents.core.file2text.extractor.impl;
import com.easyagents.core.file2text.DocumentReadErrorCode;
import com.easyagents.core.file2text.DocumentReadException;
import com.easyagents.core.file2text.DocumentReadSupport;
import com.easyagents.core.file2text.LightweightDocumentReadRequest;
import com.easyagents.core.file2text.LightweightDocumentReadResult;
import com.easyagents.core.file2text.extractor.FileExtractor;
import com.easyagents.core.file2text.source.DocumentSource;
import org.apache.poi.openxml4j.opc.OPCPackage;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.util.CellReference;
import org.apache.poi.util.XMLHelper;
import org.apache.poi.xssf.eventusermodel.ReadOnlySharedStringsTable;
import org.apache.poi.xssf.eventusermodel.XSSFReader;
import org.apache.poi.xssf.eventusermodel.XSSFSheetXMLHandler;
import org.apache.poi.xssf.model.SharedStrings;
import org.apache.poi.xssf.model.Styles;
import org.apache.poi.xssf.usermodel.XSSFComment;
import org.xml.sax.InputSource;
import org.xml.sax.XMLReader;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* 基于 XSSF SAX 的 XLSX 文档提取器。
*/
public class XlsxExtractor implements FileExtractor {
private static final Set<String> MIME_TYPES = Set.of(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.spreadsheetml.template");
/**
* 判断是否支持 XLSX。
*
* @param source 文档来源
* @return 是否支持
*/
@Override
public boolean supports(DocumentSource source) {
if (source.getMimeType() != null && MIME_TYPES.contains(source.getMimeType().toLowerCase(Locale.ROOT))) {
return true;
}
String name = source.getFileName();
return name != null && (name.toLowerCase(Locale.ROOT).endsWith(".xlsx")
|| name.toLowerCase(Locale.ROOT).endsWith(".xltx"));
}
/**
* 提取兼容纯文本。
*
* @param source 文档来源
* @return 文本
* @throws IOException 文档 I/O 失败
*/
@Override
public String extractText(DocumentSource source) throws IOException {
return read(new LightweightDocumentReadRequest(source)).getText();
}
/**
* 使用 SAX 按工作表和行读取 XLSX。
*
* @param request 读取请求
* @return 表格结构化结果
* @throws IOException 文档 I/O 失败
*/
@Override
public LightweightDocumentReadResult read(LightweightDocumentReadRequest request) throws IOException {
DocumentSource source = request.getSource();
try (InputStream input = source.openStream();
OPCPackage opcPackage = OPCPackage.open(input)) {
XSSFReader reader = new XSSFReader(opcPackage);
Styles styles = reader.getStylesTable();
SharedStrings sharedStrings = new ReadOnlySharedStringsTable(opcPackage);
XSSFReader.SheetIterator sheets = (XSSFReader.SheetIterator) reader.getSheetsData();
List<SpreadsheetReadSupport.SheetRows> resultSheets = new ArrayList<>();
int[] nonEmptyCells = {0};
int sheetIndex = 0;
while (sheets.hasNext()) {
request.checkCancelled();
sheetIndex++;
if (sheetIndex > request.getMaxSheets()) {
throw limit("Sheet count exceeds " + request.getMaxSheets());
}
try (InputStream sheetInput = sheets.next()) {
String sheetName = sheets.getSheetName();
SheetHandler handler = new SheetHandler(request, nonEmptyCells);
XMLReader parser = XMLHelper.newXMLReader();
parser.setContentHandler(new XSSFSheetXMLHandler(
styles, null, sharedStrings, handler, new DataFormatter(), false));
parser.parse(new InputSource(sheetInput));
resultSheets.add(new SpreadsheetReadSupport.SheetRows(
sheetIndex, sheetName, handler.rows()));
}
}
return DocumentReadSupport.result(source,
SpreadsheetReadSupport.toSegments(resultSheets), request);
} catch (StructureLimitRuntimeException error) {
throw limit(error.getMessage());
} catch (DocumentReadException error) {
throw error;
} catch (Exception error) {
throw new DocumentReadException(DocumentReadErrorCode.DOCUMENT_CORRUPTED,
"Failed to extract XLSX", error);
}
}
/**
* 获取读取器优先级。
*
* @return 优先级
*/
@Override
public int getOrder() {
return 10;
}
private DocumentReadException limit(String message) {
return new DocumentReadException(DocumentReadErrorCode.DOCUMENT_STRUCTURE_LIMIT_EXCEEDED, message);
}
/**
* SAX 工作表内容处理器。
*/
private static final class SheetHandler implements XSSFSheetXMLHandler.SheetContentsHandler {
private final LightweightDocumentReadRequest request;
private final int[] nonEmptyCells;
private final List<SpreadsheetReadSupport.RowText> rows = new ArrayList<>();
private final Map<String, String> currentCells = new LinkedHashMap<>();
private int currentRow;
private SheetHandler(LightweightDocumentReadRequest request, int[] nonEmptyCells) {
this.request = request;
this.nonEmptyCells = nonEmptyCells;
}
/**
* 开始读取一行。
*
* @param rowNum 零基行号
*/
@Override
public void startRow(int rowNum) {
currentRow = rowNum + 1;
currentCells.clear();
}
/**
* 完成一行并保存有效单元格。
*
* @param rowNum 零基行号
*/
@Override
public void endRow(int rowNum) {
if (currentCells.isEmpty()) {
return;
}
String text = currentCells.entrySet().stream()
.map(item -> item.getKey() + "=" + item.getValue())
.reduce((left, right) -> left + " | " + right)
.orElse("");
rows.add(new SpreadsheetReadSupport.RowText(currentRow, text));
}
/**
* 接收一个格式化单元格值。
*
* @param cellReference 单元格引用
* @param formattedValue 格式化显示值
* @param comment 批注
*/
@Override
public void cell(String cellReference, String formattedValue, XSSFComment comment) {
if (formattedValue == null || formattedValue.isBlank()) {
return;
}
request.checkCancelled();
nonEmptyCells[0]++;
if (nonEmptyCells[0] > request.getMaxNonEmptyCells()) {
throw new StructureLimitRuntimeException(
"Non-empty cell count exceeds " + request.getMaxNonEmptyCells());
}
String column = cellReference == null ? "?" : CellReference.convertNumToColString(
new CellReference(cellReference).getCol());
currentCells.put(column + currentRow, formattedValue.trim());
}
/**
* 接收页眉页脚;轻量读取不纳入正文。
*
* @param text 文本
* @param isHeader 是否页眉
* @param tagName 标签名
*/
@Override
public void headerFooter(String text, boolean isHeader, String tagName) {
}
private List<SpreadsheetReadSupport.RowText> rows() {
return rows;
}
}
/**
* SAX 回调跨层传递结构上限异常。
*/
private static final class StructureLimitRuntimeException extends RuntimeException {
private StructureLimitRuntimeException(String message) {
super(message);
}
}
}

View File

@@ -19,6 +19,9 @@ import com.easyagents.core.util.StringUtil;
import java.util.*;
/**
* 表示模型生成的文本、推理内容和工具调用消息。
*/
public class AiMessage extends AbstractTextMessage<AiMessage> {
private Integer index;
@@ -192,8 +195,13 @@ public class AiMessage extends AbstractTextMessage<AiMessage> {
this.localTotalTokens = localTotalTokens;
}
/**
* 获取当前消息的完整回复文本。
*
* @return 已聚合的完整文本;未聚合时返回当前文本
*/
public String getFullContent() {
return fullContent;
return fullContent != null ? fullContent : content;
}
public void setFullContent(String fullContent) {
@@ -226,7 +234,7 @@ public class AiMessage extends AbstractTextMessage<AiMessage> {
@Override
public String getTextContent() {
return fullContent;
return getFullContent();
}
/**
@@ -283,8 +291,13 @@ public class AiMessage extends AbstractTextMessage<AiMessage> {
this.toolCalls = toolCalls;
}
/**
* 获取当前消息的完整推理文本。
*
* @return 已聚合的完整推理文本;未聚合时返回当前推理文本
*/
public String getFullReasoningContent() {
return fullReasoningContent;
return fullReasoningContent != null ? fullReasoningContent : reasoningContent;
}
public void setFullReasoningContent(String fullReasoningContent) {

View File

@@ -33,7 +33,7 @@ public interface ChatModel {
if (response != null && response.isError()) {
throw new ModelException(response.getErrorMessage());
}
return response != null && response.getMessage() != null ? response.getMessage().getContent() : null;
return response != null && response.getMessage() != null ? response.getMessage().getTextContent() : null;
}
default AiMessageResponse chat(Prompt prompt) {

View File

@@ -177,7 +177,7 @@ public class ChatObservabilityInterceptor implements ChatInterceptor {
private void enrichSpan(Span span, AiMessage msg) {
if (msg != null) {
span.setAttribute("llm.total_tokens", msg.getEffectiveTotalTokens());
String content = msg.getContent();
String content = msg.getTextContent();
if (content != null) {
span.setAttribute("llm.response",
content.substring(0, Math.min(content.length(), MAX_RESPONSE_LENGTH_FOR_SPAN)));

View File

@@ -0,0 +1,72 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
*/
package com.easyagents.core.model.chat.log;
/**
* 清理聊天请求日志中的大体积或敏感媒体内容。
*/
final class ChatLogSanitizer {
private static final String DATA_URI_PREFIX = "data:image/";
private static final String BASE64_MARKER = ";base64,";
private ChatLogSanitizer() {
}
/**
* 隐去图片 Data URI 的编码正文,仅保留 MIME 和字符长度。
*
* @param message 原始日志消息
* @return 脱敏后的日志消息
*/
static String redactImageDataUris(String message) {
if (message == null || message.indexOf(DATA_URI_PREFIX) < 0) {
return message;
}
StringBuilder sanitized = new StringBuilder(message.length());
int cursor = 0;
while (cursor < message.length()) {
int dataUriStart = message.indexOf(DATA_URI_PREFIX, cursor);
if (dataUriStart < 0) {
sanitized.append(message, cursor, message.length());
break;
}
int base64Marker = message.indexOf(BASE64_MARKER, dataUriStart);
if (base64Marker < 0) {
sanitized.append(message, cursor, message.length());
break;
}
int payloadStart = base64Marker + BASE64_MARKER.length();
int payloadEnd = payloadStart;
while (payloadEnd < message.length() && isBase64Character(message.charAt(payloadEnd))) {
payloadEnd++;
}
sanitized.append(message, cursor, payloadStart);
sanitized.append("<已脱敏,编码长度=").append(payloadEnd - payloadStart).append('>');
cursor = payloadEnd;
}
return sanitized.toString();
}
/**
* 判断字符是否可能属于 Base64 正文。
*
* @param value 待判断字符
* @return 是否属于 Base64 字符集
*/
private static boolean isBase64Character(char value) {
return value >= 'A' && value <= 'Z'
|| value >= 'a' && value <= 'z'
|| value >= '0' && value <= '9'
|| value == '+'
|| value == '/'
|| value == '='
|| value == '-'
|| value == '_';
}
}

View File

@@ -36,7 +36,8 @@ public class DefaultChatMessageLogger implements IChatMessageLogger {
if (shouldLog(config)) {
String provider = getProviderName(config);
String model = getModelName(config);
logConsumer.accept(String.format("[%s/%s] >>>> request: %s", provider, model, message));
logConsumer.accept(String.format("[%s/%s] >>>> request: %s",
provider, model, ChatLogSanitizer.redactImageDataUris(message)));
}
}

View File

@@ -32,6 +32,9 @@ import java.util.List;
import java.util.Map;
/**
* 解析 OpenAI-compatible 同步响应和流式增量消息。
*/
public class DefaultAiMessageParser implements AiMessageParser<JSONObject> {
private JSONPath contentPath;
@@ -171,11 +174,16 @@ public class DefaultAiMessageParser implements AiMessageParser<JSONObject> {
}
} else {
if (this.contentPath != null) {
aiMessage.setContent((String) this.contentPath.eval(rootJson));
String content = (String) this.contentPath.eval(rootJson);
// 非流式响应已经是完整结果,同时填充当前内容和完整内容。
aiMessage.setContent(content);
aiMessage.setFullContent(content);
}
if (this.reasoningContentPath != null) {
aiMessage.setReasoningContent((String) this.reasoningContentPath.eval(rootJson));
String reasoningContent = (String) this.reasoningContentPath.eval(rootJson);
aiMessage.setReasoningContent(reasoningContent);
aiMessage.setFullReasoningContent(reasoningContent);
}
if (this.toolCallsJsonPath != null) {
toolCallsJsonArray = (JSONArray) this.toolCallsJsonPath.eval(rootJson);

View File

@@ -50,7 +50,7 @@ public class ImageUtil {
}
/**
* 将图片 URL 转换为 Data URI 格式的字符串例如image/jpeg;base64,...
* 将图片 URL 转换为 Data URI 格式的字符串(例如:data:image/jpeg;base64,...
*
* @throws IllegalArgumentException 如果 URL 无效或无法获取内容
*/
@@ -108,6 +108,6 @@ public class ImageUtil {
public static String imageBytesToDataUri(byte[] data, String mimeType) {
String base64 = Base64.getEncoder().encodeToString(data);
return mimeType + ";base64," + base64;
return "data:" + mimeType + ";base64," + base64;
}
}

View File

@@ -0,0 +1,162 @@
package com.easyagents.core.file2text;
import com.easyagents.core.file2text.extractor.impl.DocExtractor;
import com.easyagents.core.file2text.extractor.impl.PdfTextExtractor;
import com.easyagents.core.file2text.extractor.impl.XlsExtractor;
import com.easyagents.core.file2text.extractor.impl.XlsxExtractor;
import com.easyagents.core.file2text.source.ByteArrayDocumentSource;
import org.apache.poi.hslf.usermodel.HSLFSlide;
import org.apache.poi.hslf.usermodel.HSLFSlideShow;
import org.apache.poi.hslf.usermodel.HSLFTextBox;
import org.apache.poi.hssf.usermodel.HSSFWorkbook;
import org.apache.poi.xslf.usermodel.XMLSlideShow;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.Assert;
import org.junit.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
/**
* 常规文档格式轻量读取回归测试。
*/
public class File2TextServiceLightweightReadTest {
private final File2TextService service = new File2TextService();
/**
* 验证 TXT 与 Markdown 会保留行区间和标题结构。
*/
@Test
public void shouldReadTextAndMarkdownWithStableSegments() {
LightweightDocumentReadResult text = read(
"sample.txt", "text/plain", "first line\nsecond line".getBytes(StandardCharsets.UTF_8));
LightweightDocumentReadResult markdown = read(
"sample.md", "text/markdown", "# Chapter\nbody".getBytes(StandardCharsets.UTF_8));
Assert.assertTrue(text.getText().contains("second line"));
Assert.assertEquals("LINE_RANGE", text.getSegments().get(0).getLocatorType());
Assert.assertTrue(markdown.getText().contains("Chapter"));
Assert.assertEquals("Chapter", markdown.getSegments().get(0).getHeadingPath().get(0));
}
/**
* 验证 DOCX、PPTX、PPT、XLSX 与 XLS 均可直接读取正文。
*
* @throws Exception 测试文档生成失败时抛出
*/
@Test
public void shouldReadGeneratedOfficeAndPdfDocuments() throws Exception {
assertReadable("sample.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
docxBytes(), "DOCX sample");
assertReadable("sample.pptx",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
pptxBytes(), "PPTX sample");
assertReadable("sample.ppt", "application/vnd.ms-powerpoint", pptBytes(), "PPT sample");
assertReadable("sample.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
xlsxBytes(), "XLSX sample");
assertReadable("sample.xls", "application/vnd.ms-excel", xlsBytes(), "XLS sample");
}
/**
* 验证 PDF 与旧版 DOC 扩展名仍路由到专用读取器。
*/
@Test
public void shouldRegisterPdfAndLegacyDocReaders() {
DocExtractor docExtractor = new DocExtractor();
PdfTextExtractor pdfExtractor = new PdfTextExtractor();
Assert.assertTrue(docExtractor.supports(new ByteArrayDocumentSource(
new byte[0], "legacy.doc", "application/msword")));
Assert.assertTrue(pdfExtractor.supports(new ByteArrayDocumentSource(
new byte[0], "sample.pdf", "application/pdf")));
}
/**
* 验证表格读取取消会保留明确错误码。
*
* @throws Exception 测试文档生成失败时抛出
*/
@Test
public void shouldPreserveCancellationErrorForSpreadsheets() throws Exception {
assertCancelled(new XlsxExtractor(), "sample.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", xlsxBytes());
assertCancelled(new XlsExtractor(), "sample.xls",
"application/vnd.ms-excel", xlsBytes());
}
private void assertCancelled(com.easyagents.core.file2text.extractor.FileExtractor extractor,
String fileName,
String mimeType,
byte[] bytes) throws Exception {
LightweightDocumentReadRequest request = new LightweightDocumentReadRequest(
new ByteArrayDocumentSource(bytes, fileName, mimeType));
request.setCancelled(() -> true);
try {
extractor.read(request);
Assert.fail("Expected document read cancellation");
} catch (DocumentReadException error) {
Assert.assertEquals(DocumentReadErrorCode.DOCUMENT_READ_CANCELLED, error.getErrorCode());
}
}
private void assertReadable(String fileName, String mimeType, byte[] bytes, String expected) {
Assert.assertTrue(read(fileName, mimeType, bytes).getText().contains(expected));
}
private LightweightDocumentReadResult read(String fileName, String mimeType, byte[] bytes) {
return service.readFromStream(new ByteArrayInputStream(bytes), fileName, mimeType);
}
private byte[] docxBytes() throws Exception {
try (XWPFDocument document = new XWPFDocument();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
document.createParagraph().createRun().setText("DOCX sample");
document.write(output);
return output.toByteArray();
}
}
private byte[] pptxBytes() throws Exception {
try (XMLSlideShow presentation = new XMLSlideShow();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
presentation.createSlide().createTextBox().setText("PPTX sample");
presentation.write(output);
return output.toByteArray();
}
}
private byte[] pptBytes() throws Exception {
try (HSLFSlideShow presentation = new HSLFSlideShow();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
HSLFSlide slide = presentation.createSlide();
HSLFTextBox textBox = new HSLFTextBox();
textBox.setText("PPT sample");
slide.addShape(textBox);
presentation.write(output);
return output.toByteArray();
}
}
private byte[] xlsxBytes() throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
workbook.createSheet("Sheet1").createRow(0).createCell(0).setCellValue("XLSX sample");
workbook.write(output);
return output.toByteArray();
}
}
private byte[] xlsBytes() throws Exception {
try (HSSFWorkbook workbook = new HSSFWorkbook();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
workbook.createSheet("Sheet1").createRow(0).createCell(0).setCellValue("XLS sample");
workbook.write(output);
return output.toByteArray();
}
}
}

View File

@@ -0,0 +1,51 @@
package com.easyagents.core.message;
import com.easyagents.core.util.LocalTokenCounter;
import org.junit.Assert;
import org.junit.Test;
/**
* AI 消息文本语义测试。
*/
public class AiMessageTest {
/**
* 验证仅设置当前内容时,完整文本接口仍能返回模型回复。
*/
@Test
public void currentContentShouldBeAvailableAsFullText() {
AiMessage message = new AiMessage();
message.setContent("2026");
Assert.assertEquals("2026", message.getContent());
Assert.assertEquals("2026", message.getFullContent());
Assert.assertEquals("2026", message.getTextContent());
Assert.assertTrue(LocalTokenCounter.countCompletionTokens(message)
> LocalTokenCounter.countCompletionTokens(new AiMessage()));
}
/**
* 验证流式消息保留增量内容,同时完整文本接口返回聚合内容。
*/
@Test
public void aggregatedContentShouldNotReplaceStreamingDelta() {
AiMessage message = new AiMessage();
message.setContent("26");
message.setFullContent("2026");
Assert.assertEquals("26", message.getContent());
Assert.assertEquals("2026", message.getFullContent());
Assert.assertEquals("2026", message.getTextContent());
}
/**
* 验证仅设置当前推理内容时,完整推理接口能够正确回退。
*/
@Test
public void reasoningContentShouldBeAvailableAsFullReasoningText() {
AiMessage message = new AiMessage();
message.setReasoningContent("识别图片中的数字");
Assert.assertEquals("识别图片中的数字", message.getFullReasoningContent());
}
}

View File

@@ -0,0 +1,32 @@
package com.easyagents.core.model.chat.log;
import com.easyagents.core.model.chat.ChatConfig;
import org.junit.Assert;
import org.junit.Test;
import java.util.concurrent.atomic.AtomicReference;
/**
* 默认聊天日志记录器测试。
*/
public class DefaultChatMessageLoggerTest {
/**
* 验证请求日志不会输出完整图片 Base64。
*/
@Test
public void shouldRedactImageDataUriPayload() {
AtomicReference<String> logged = new AtomicReference<>();
DefaultChatMessageLogger logger = new DefaultChatMessageLogger(logged::set);
ChatConfig config = new ChatConfig();
config.setProvider("test");
config.setModel("vision");
logger.logRequest(config,
"{\"url\":\"data:image/png;base64,AQIDBA==\",\"text\":\"ok\"}");
Assert.assertNotNull(logged.get());
Assert.assertTrue(logged.get().contains("data:image/png;base64,<已脱敏,编码长度=8>"));
Assert.assertFalse(logged.get().contains("AQIDBA=="));
}
}

View File

@@ -0,0 +1,36 @@
package com.easyagents.core.test.model.client;
import com.easyagents.core.message.UserMessage;
import com.easyagents.core.model.chat.ChatConfig;
import com.easyagents.core.model.client.OpenAIChatMessageSerializer;
import org.junit.Assert;
import org.junit.Test;
import java.util.List;
import java.util.Map;
/**
* OpenAI-compatible 多模态消息序列化测试。
*/
public class OpenAIChatMessageSerializerTest {
/**
* 验证 Data URI 会写入标准的 image_url.url 字段。
*/
@Test
public void shouldSerializeImageDataUriIntoImageUrlField() {
String dataUri = "data:image/png;base64,AQID";
UserMessage message = new UserMessage("识别图片");
message.addImageUrl(dataUri);
List<Map<String, Object>> messages = new OpenAIChatMessageSerializer()
.serializeMessages(List.of(message), new ChatConfig());
Assert.assertNotNull(messages);
List<?> content = (List<?>) messages.get(0).get("content");
Map<?, ?> imageContent = (Map<?, ?>) content.get(1);
Map<?, ?> imageUrl = (Map<?, ?>) imageContent.get("image_url");
Assert.assertEquals("image_url", imageContent.get("type"));
Assert.assertEquals(dataUri, imageUrl.get("url"));
}
}

View File

@@ -0,0 +1,87 @@
package com.easyagents.core.test.parser;
import com.alibaba.fastjson2.JSON;
import com.easyagents.core.message.AiMessage;
import com.easyagents.core.model.chat.ChatContext;
import com.easyagents.core.model.chat.ChatOptions;
import com.easyagents.core.parser.impl.DefaultAiMessageParser;
import org.junit.Assert;
import org.junit.Test;
/**
* OpenAI-compatible AI 消息解析测试。
*/
public class DefaultAiMessageParserTest {
/**
* 验证非流式响应同时填充当前内容和完整内容。
*/
@Test
public void nonStreamingResponseShouldPopulateCompleteContent() {
String response = """
{
"choices": [{
"message": {
"content": "2026",
"reasoning_content": "读取图片中的数字"
},
"index": 0,
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 221,
"completion_tokens": 51,
"total_tokens": 272
}
}
""";
AiMessage message = DefaultAiMessageParser.getOpenAIMessageParser()
.parse(JSON.parseObject(response), context(false));
Assert.assertEquals("2026", message.getContent());
Assert.assertEquals("2026", message.getFullContent());
Assert.assertEquals("2026", message.getTextContent());
Assert.assertEquals("读取图片中的数字", message.getReasoningContent());
Assert.assertEquals("读取图片中的数字", message.getFullReasoningContent());
Assert.assertEquals(Integer.valueOf(272), message.getTotalTokens());
}
/**
* 验证流式解析继续暴露原始增量内容。
*/
@Test
public void streamingResponseShouldKeepDeltaContent() {
String response = """
{
"choices": [{
"delta": {
"content": "20",
"reasoning_content": "读取"
},
"index": 0
}]
}
""";
AiMessage message = DefaultAiMessageParser.getOpenAIMessageParser()
.parse(JSON.parseObject(response), context(true));
Assert.assertEquals("20", message.getContent());
Assert.assertEquals("读取", message.getReasoningContent());
}
/**
* 创建指定流式模式的解析上下文。
*
* @param streaming 是否解析流式增量
* @return 解析上下文
*/
private ChatContext context(boolean streaming) {
ChatOptions options = new ChatOptions();
options.setStreaming(streaming);
ChatContext context = new ChatContext();
context.setOptions(options);
return context;
}
}

View File

@@ -0,0 +1,36 @@
package com.easyagents.core.test.util;
import com.easyagents.core.message.UserMessage;
import com.easyagents.core.util.ImageUtil;
import org.junit.Assert;
import org.junit.Test;
import java.util.List;
/**
* 图片 Data URI 转换测试。
*/
public class ImageUtilTest {
/**
* 验证图片字节会生成符合标准的完整 Data URI。
*/
@Test
public void shouldAddDataSchemeWhenEncodingImageBytes() {
String dataUri = ImageUtil.imageBytesToDataUri(new byte[]{1, 2, 3}, "image/png");
Assert.assertEquals("data:image/png;base64,AQID", dataUri);
}
/**
* 验证用户消息添加图片字节时保留完整 Data URI。
*/
@Test
public void shouldStoreCompleteDataUriInUserMessage() {
UserMessage message = new UserMessage();
message.addImageBytes(new byte[]{1, 2, 3}, "image/png");
Assert.assertEquals(List.of("data:image/png;base64,AQID"), message.getImageUrls());
}
}

View File

@@ -5,6 +5,7 @@ import com.easyagents.core.util.StringUtil;
import com.easyagents.document.core.exception.DocumentParseException;
import com.easyagents.document.core.entity.ParseFile;
import com.easyagents.document.core.entity.ParseRequest;
import okhttp3.Call;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.OkHttpClient;
@@ -29,10 +30,17 @@ import java.util.concurrent.TimeUnit;
public class MineruClient {
private static final MediaType DEFAULT_MEDIA_TYPE = MediaType.parse("application/octet-stream");
private static final int DEFAULT_SUBMIT_TIMEOUT_MS = 120000;
private static final int AVAILABILITY_PROBE_TIMEOUT_MS = 3000;
private static final long AVAILABILITY_CACHE_TTL_MS = 5000L;
private final String baseUrl;
private final OkHttpClient okHttpClient;
private final MineruMapper mineruMapper;
private final int submitTimeoutMs;
private final Object availabilityProbeMonitor = new Object();
private volatile long availabilityCacheDeadlineMs;
private volatile String availabilityFailureMessage;
/**
* 创建客户端。
@@ -66,6 +74,7 @@ public class MineruClient {
this.baseUrl = normalizeBaseUrl(properties.getBaseUrl());
this.okHttpClient = okHttpClient;
this.mineruMapper = mineruMapper;
this.submitTimeoutMs = positiveOrDefault(properties.getSubmitTimeoutMs(), DEFAULT_SUBMIT_TIMEOUT_MS);
}
/**
@@ -85,7 +94,10 @@ public class MineruClient {
* @return 原始任务状态
*/
public MineruTaskStatus submit(ParseRequest request) {
return mineruMapper.toTaskStatus(executeJsonMultipart("/tasks", request, buildAsyncFormFields(request)));
assertServiceAvailable();
return mineruMapper.toTaskStatus(
executeJsonMultipart("/tasks", request, buildAsyncFormFields(request), submitTimeoutMs)
);
}
/**
@@ -131,6 +143,22 @@ public class MineruClient {
}
protected JSONObject executeJsonMultipart(String path, ParseRequest request, Map<String, List<String>> fields) {
return executeJsonMultipart(path, request, fields, 0);
}
/**
* 执行带整次调用超时的 Multipart JSON 请求。
*
* @param path 接口路径
* @param request 解析请求
* @param fields 表单字段
* @param callTimeoutMs 整次调用超时时间,单位毫秒;小于等于 0 时沿用客户端阶段超时
* @return JSON 响应
*/
protected JSONObject executeJsonMultipart(String path,
ParseRequest request,
Map<String, List<String>> fields,
long callTimeoutMs) {
MultipartBody.Builder formBuilder = new MultipartBody.Builder().setType(MultipartBody.FORM);
appendFiles(formBuilder, request.getFiles());
appendStringFields(formBuilder, fields);
@@ -138,7 +166,9 @@ public class MineruClient {
.url(baseUrl + path)
.post(formBuilder.build())
.build();
return executeJsonRequest(path, httpRequest);
return callTimeoutMs > 0
? executeJsonRequest(path, httpRequest, callTimeoutMs)
: executeJsonRequest(path, httpRequest);
}
protected JSONObject executeJsonGet(String path) {
@@ -147,7 +177,28 @@ public class MineruClient {
}
protected JSONObject executeJsonRequest(String path, Request request) {
try (Response response = okHttpClient.newCall(request).execute()) {
return executeJsonRequest(path, request, 0);
}
/**
* 执行 JSON 请求,并可限制连接、写入和读取在内的整次调用时长。
*
* @param path 接口路径
* @param request HTTP 请求
* @param callTimeoutMs 整次调用超时时间,单位毫秒;小于等于 0 时不额外限制
* @return JSON 响应
*/
protected JSONObject executeJsonRequest(String path, Request request, long callTimeoutMs) {
Call call = okHttpClient.newCall(request);
if (callTimeoutMs > 0) {
// OkHttp 的 Call timeout 到期后会取消底层请求,避免线程长期阻塞在文件上传或响应等待。
call.timeout().timeout(callTimeoutMs, TimeUnit.MILLISECONDS);
}
try (Response response = call.execute()) {
if (response.code() >= 500) {
// 服务端错误在响应头到达时立即失败,避免异常响应体未结束时继续占用提交线程。
throw buildHttpException(path, response.code(), new byte[0]);
}
ResponseBody body = response.body();
String bodyText = body == null ? "" : body.string();
if (!response.isSuccessful()) {
@@ -163,6 +214,83 @@ public class MineruClient {
}
}
/**
* 在上传文件前探测 MinerU 网关是否可用,避免服务已返回 5xx 时仍传输大文件。
*
* <p>探测结果短暂缓存,限制批量导入期间的额外请求量。健康检查返回 4xx 说明网关仍可达,
* 实际任务接口会继续完成业务校验。</p>
*
* @throws DocumentParseException 网关返回 5xx 或探测请求失败
*/
private void assertServiceAvailable() {
long now = System.currentTimeMillis();
if (now < availabilityCacheDeadlineMs) {
throwCachedAvailabilityFailure();
return;
}
synchronized (availabilityProbeMonitor) {
now = System.currentTimeMillis();
if (now < availabilityCacheDeadlineMs) {
throwCachedAvailabilityFailure();
return;
}
String healthPath = "/health";
Request request = new Request.Builder().url(baseUrl + healthPath).get().build();
Call call = okHttpClient.newCall(request);
call.timeout().timeout(
Math.min(submitTimeoutMs, AVAILABILITY_PROBE_TIMEOUT_MS),
TimeUnit.MILLISECONDS
);
try (Response response = call.execute()) {
if (response.code() >= 500) {
cacheAvailabilityFailure(
"MinerU service unavailable: path=" + healthPath + ", status=" + response.code(),
now
);
throw new DocumentParseException(availabilityFailureMessage);
}
availabilityFailureMessage = null;
availabilityCacheDeadlineMs = now + AVAILABILITY_CACHE_TTL_MS;
} catch (IOException exception) {
cacheAvailabilityFailure("MinerU service unavailable: availability probe failed", now);
throw new DocumentParseException(availabilityFailureMessage, exception);
}
}
}
/**
* 缓存 MinerU 不可用状态。
*
* @param message 失败信息
* @param detectedAtMs 检测时间,单位毫秒
*/
private void cacheAvailabilityFailure(String message, long detectedAtMs) {
availabilityFailureMessage = message;
availabilityCacheDeadlineMs = detectedAtMs + AVAILABILITY_CACHE_TTL_MS;
}
/**
* 若缓存记录为不可用则抛出稳定异常。
*
* @throws DocumentParseException MinerU 仍处于不可用缓存窗口
*/
private void throwCachedAvailabilityFailure() {
if (availabilityFailureMessage != null) {
throw new DocumentParseException(availabilityFailureMessage);
}
}
/**
* 返回正整数配置,非法配置回退到缺省值。
*
* @param value 配置值
* @param defaultValue 缺省值
* @return 可用的正整数
*/
private int positiveOrDefault(Integer value, int defaultValue) {
return value == null || value <= 0 ? defaultValue : value;
}
private void appendFiles(MultipartBody.Builder formBuilder, List<ParseFile> files) {
if (files == null || files.isEmpty()) {
throw new IllegalArgumentException("Parse request must contain at least one file");

View File

@@ -16,6 +16,7 @@ public class MineruProperties {
private Integer connectTimeoutMs = 3000;
private Integer readTimeoutMs = 600000;
private Integer writeTimeoutMs = 600000;
private Integer submitTimeoutMs = 120000;
private Integer pollIntervalMs = 1000;
private Integer resultTimeoutMs = 1800000;
private String defaultBackend = "vlm-http-client";
@@ -56,6 +57,24 @@ public class MineruProperties {
this.writeTimeoutMs = writeTimeoutMs;
}
/**
* 获取异步任务提交总超时时间。
*
* @return 提交总超时时间,单位毫秒
*/
public Integer getSubmitTimeoutMs() {
return submitTimeoutMs;
}
/**
* 设置异步任务提交总超时时间。
*
* @param submitTimeoutMs 提交总超时时间,单位毫秒
*/
public void setSubmitTimeoutMs(Integer submitTimeoutMs) {
this.submitTimeoutMs = submitTimeoutMs;
}
public Integer getPollIntervalMs() {
return pollIntervalMs;
}

View File

@@ -6,14 +6,30 @@ import com.easyagents.document.core.entity.ParseRequest;
import com.easyagents.document.core.entity.ParseResponse;
import com.easyagents.document.core.entity.ParseTaskInfo;
import com.easyagents.document.core.entity.ParseTaskStatus;
import com.easyagents.document.core.exception.DocumentParseException;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Protocol;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okio.Buffer;
import okio.BufferedSource;
import okio.Okio;
import okio.Source;
import okio.Timeout;
import org.junit.Assert;
import org.junit.Test;
import java.io.ByteArrayOutputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -89,6 +105,191 @@ public class MineruDocumentParseServiceTest {
Assert.assertTrue(client.lastMultipartBody.contains("\r\nen\r\n"));
}
/**
* 验证异步任务提交总超时会取消无响应的 HTTP 调用。
*
* @throws Exception 本地测试套接字异常
*/
@Test
public void submitShouldCancelUnresponsiveCallAtConfiguredTimeout() throws Exception {
AtomicReference<Socket> acceptedSocket = new AtomicReference<Socket>();
try (ServerSocket serverSocket = new ServerSocket(0)) {
Thread serverThread = new Thread(() -> {
try {
Socket socket = serverSocket.accept();
acceptedSocket.set(socket);
while (socket.getInputStream().read() >= 0) {
// 持续读取请求但不返回响应,模拟 MinerU 提交接口失去响应。
}
} catch (IOException ignore) {
// 客户端超时取消或测试关闭套接字后结束服务线程。
}
}, "mineru-submit-timeout-test-server");
serverThread.setDaemon(true);
serverThread.start();
MineruProperties properties = defaultProperties();
properties.setBaseUrl("http://127.0.0.1:" + serverSocket.getLocalPort());
properties.setSubmitTimeoutMs(200);
properties.setConnectTimeoutMs(5000);
properties.setReadTimeoutMs(5000);
properties.setWriteTimeoutMs(5000);
MineruClient client = new MineruClient(properties, new MineruMapper(properties));
long startedAt = System.currentTimeMillis();
try {
client.submit(buildRequest());
Assert.fail("Expected MinerU submit timeout");
} catch (DocumentParseException expected) {
long elapsed = System.currentTimeMillis() - startedAt;
Assert.assertTrue("Submit call should be cancelled promptly, elapsed=" + elapsed, elapsed < 2000);
} finally {
Socket socket = acceptedSocket.get();
if (socket != null) {
socket.close();
}
serverThread.join(1000);
}
}
}
/**
* 验证 MinerU 网关返回 5xx 时会在上传文件前立即失败,并复用短期不可用缓存。
*
* @throws Exception 本地测试套接字异常
*/
@Test
public void submitShouldFailBeforeMultipartUploadWhenProbeReturnsServerError() throws Exception {
AtomicInteger requestCount = new AtomicInteger();
AtomicReference<String> requestLine = new AtomicReference<String>();
Thread serverThread;
try (ServerSocket serverSocket = new ServerSocket(0)) {
serverThread = new Thread(() -> {
while (!serverSocket.isClosed()) {
try (Socket socket = serverSocket.accept();
BufferedReader reader = new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) {
requestCount.incrementAndGet();
requestLine.set(reader.readLine());
String header;
while ((header = reader.readLine()) != null && !header.isEmpty()) {
// 读取完整请求头后再返回网关错误。
}
byte[] body = "Service Unavailable".getBytes(StandardCharsets.UTF_8);
String responseHeaders = "HTTP/1.1 503 Service Unavailable\r\n"
+ "Content-Type: text/plain\r\n"
+ "Content-Length: " + body.length + "\r\n"
+ "Connection: close\r\n\r\n";
socket.getOutputStream().write(responseHeaders.getBytes(StandardCharsets.UTF_8));
socket.getOutputStream().write(body);
socket.getOutputStream().flush();
} catch (IOException ignore) {
// 测试结束关闭 ServerSocket 后退出服务线程。
}
}
}, "mineru-availability-probe-test-server");
serverThread.setDaemon(true);
serverThread.start();
MineruProperties properties = defaultProperties();
properties.setBaseUrl("http://127.0.0.1:" + serverSocket.getLocalPort());
MineruClient client = new MineruClient(properties, new MineruMapper(properties));
long startedAt = System.currentTimeMillis();
for (int attempt = 0; attempt < 2; attempt++) {
try {
client.submit(buildRequest());
Assert.fail("Expected MinerU availability failure");
} catch (DocumentParseException expected) {
Assert.assertTrue(expected.getMessage().contains("status=503"));
}
}
long elapsed = System.currentTimeMillis() - startedAt;
Assert.assertTrue("Server error should fail promptly, elapsed=" + elapsed, elapsed < 2000);
}
serverThread.join(1000);
Assert.assertEquals("Cached failure should avoid repeated probes", 1, requestCount.get());
Assert.assertTrue(requestLine.get().startsWith("GET /health HTTP/1.1"));
}
/**
* 验证任务接口返回 5xx 后不会等待或读取异常响应体。
*/
@Test
public void submitShouldThrowServerErrorWithoutReadingResponseBody() {
AtomicInteger requestCount = new AtomicInteger();
AtomicReference<Boolean> errorBodyRead = new AtomicReference<Boolean>(false);
OkHttpClient httpClient = new OkHttpClient.Builder()
.addInterceptor(chain -> {
int currentRequest = requestCount.incrementAndGet();
Response.Builder responseBuilder = new Response.Builder()
.request(chain.request())
.protocol(Protocol.HTTP_1_1);
if (currentRequest == 1) {
return responseBuilder
.code(200)
.message("OK")
.body(ResponseBody.create((MediaType) null, new byte[0]))
.build();
}
ResponseBody trackingBody = new ResponseBody() {
private final BufferedSource source = Okio.buffer(new Source() {
@Override
public long read(Buffer sink, long byteCount) {
errorBodyRead.set(true);
return -1;
}
@Override
public Timeout timeout() {
return Timeout.NONE;
}
@Override
public void close() {
// 无底层资源需要关闭。
}
});
@Override
public MediaType contentType() {
return MediaType.parse("text/plain");
}
@Override
public long contentLength() {
return 19;
}
@Override
public BufferedSource source() {
return source;
}
};
return responseBuilder
.code(500)
.message("Internal Server Error")
.body(trackingBody)
.build();
})
.build();
MineruProperties properties = defaultProperties();
MineruClient client = new MineruClient(properties, httpClient, new MineruMapper(properties));
try {
client.submit(buildRequest());
Assert.fail("Expected MinerU server error");
} catch (DocumentParseException expected) {
Assert.assertTrue(expected.getMessage().contains("status=500"));
}
Assert.assertEquals(2, requestCount.get());
Assert.assertFalse("5xx response body should not be read", errorBodyRead.get());
}
private ParseRequest buildRequest() {
ParseRequest request = new ParseRequest();
request.addFile(ParseFile.of("demo.pptx", "ppt".getBytes(StandardCharsets.UTF_8)));

View File

@@ -21,6 +21,11 @@
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-core</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -1,20 +1,7 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.easyagents.embedding.openai;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.easyagents.core.document.Document;
import com.easyagents.core.model.client.HttpClient;
import com.easyagents.core.model.embedding.BaseEmbeddingModel;
@@ -24,14 +11,19 @@ import com.easyagents.core.store.VectorData;
import com.easyagents.core.util.JSONUtil;
import com.easyagents.core.util.Maps;
import com.easyagents.core.util.StringUtil;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
public class OpenAIEmbeddingModel extends BaseEmbeddingModel<OpenAIEmbeddingConfig> {
private static final Logger LOG = LoggerFactory.getLogger(OpenAIEmbeddingModel.class);
private static final String REDACTED_HEADER_VALUE = "[REDACTED]";
private HttpClient httpClient = new HttpClient();
public OpenAIEmbeddingModel(OpenAIEmbeddingConfig config) {
@@ -54,34 +46,127 @@ public class OpenAIEmbeddingModel extends BaseEmbeddingModel<OpenAIEmbeddingConf
String payload = promptToEmbeddingsPayload(document, options, config);
String endpoint = config.getEndpoint();
String requestUrl = endpoint + config.getRequestPath();
// https://platform.openai.com/docs/api-reference/embeddings/create
String response = httpClient.post(endpoint + config.getRequestPath(), headers, payload);
String response = httpClient.post(requestUrl, headers, payload);
if (StringUtil.noText(response)) {
logResponseParsingFailure(
"response is null or empty",
requestUrl,
headers,
payload,
response
);
throw new ModelException("response is null or empty.");
}
JSONObject jsonObject = JSON.parseObject(response);
String errorMessage = JSONUtil.detectErrorMessage(jsonObject);
if (errorMessage != null) {
throw new ModelException(errorMessage);
try {
JSONObject jsonObject = JSON.parseObject(response);
String errorMessage = JSONUtil.detectErrorMessage(jsonObject);
if (errorMessage != null) {
logResponseParsingFailure(errorMessage, requestUrl, headers, payload, response);
throw new ModelException(errorMessage);
}
VectorData vectorData = new VectorData();
double[] embedding = JSONUtil.readDoubleArray(jsonObject, "$.data[0].embedding");
if (embedding == null || embedding.length == 0) {
String missingEmbeddingMessage = buildMissingEmbeddingMessage();
logResponseParsingFailure(missingEmbeddingMessage, requestUrl, headers, payload, response);
throw new ModelException(missingEmbeddingMessage);
}
vectorData.setVector(embedding);
return vectorData;
} catch (ModelException e) {
throw e;
} catch (RuntimeException e) {
logResponseParsingFailure(e.getMessage(), requestUrl, headers, payload, response);
throw new ModelException("Failed to parse embedding response.", e);
}
VectorData vectorData = new VectorData();
double[] embedding = JSONUtil.readDoubleArray(jsonObject, "$.data[0].embedding");
vectorData.setVector(embedding);
return vectorData;
}
/**
* Builds the embeddings request payload for OpenAI-compatible providers.
*
* @param text document to embed
* @param options embedding request options
* @param config model configuration
* @return JSON payload for the embeddings endpoint
*/
public static String promptToEmbeddingsPayload(Document text, EmbeddingOptions options, OpenAIEmbeddingConfig config) {
// https://platform.openai.com/docs/api-reference/making-requests
return Maps.of("model", options.getModelOrDefault(config.getModel()))
String model = options.getModelOrDefault(config.getModel());
return Maps.of("model", model)
.set("encoding_format", options.getEncodingFormatOrDefault("float"))
.set("input", text.getContent())
.setIfNotEmpty("user", options.getUser())
.setIfNotEmpty("dimensions", options.getDimensions())
.setIf(
supportsDimensionsParameter(model) && options.getDimensions() != null,
"dimensions",
options.getDimensions()
)
.toJSON();
}
/**
* Determines whether the upstream embeddings endpoint supports dynamic dimensions.
*
* @param model model name sent to the provider
* @return true when dimensions should be sent
*/
static boolean supportsDimensionsParameter(String model) {
if (StringUtil.noText(model)) {
return false;
}
String normalizedModel = model.toLowerCase(Locale.ROOT);
return normalizedModel.contains("qwen3-embedding")
|| normalizedModel.contains("qwen");
}
/**
* Builds a safe diagnostic message for malformed embeddings responses.
*
* @return diagnostic message without secrets or raw response content
*/
private String buildMissingEmbeddingMessage() {
return "Embedding response does not contain data[0].embedding."
+ " Please check provider, model, request path, dimensions, or response format."
+ " provider=" + config.getProvider()
+ ", model=" + config.getModel()
+ ", endpoint=" + config.getEndpoint()
+ ", requestPath=" + config.getRequestPath();
}
private void logResponseParsingFailure(String reason,
String requestUrl,
Map<String, String> requestHeaders,
String requestBody,
String responseBody) {
LOG.error(
"Embedding response parsing failed: reason={}\n"
+ "requestUrl={}\n"
+ "requestHeaders={}\n"
+ "requestBody={}\n"
+ "responseBody={}",
reason,
requestUrl,
sanitizeHeadersForLogging(requestHeaders),
requestBody,
responseBody
);
}
static Map<String, String> sanitizeHeadersForLogging(Map<String, String> headers) {
Map<String, String> sanitizedHeaders = new LinkedHashMap<>();
if (headers == null) {
return sanitizedHeaders;
}
headers.forEach((name, value) -> sanitizedHeaders.put(
name,
"Authorization".equalsIgnoreCase(name) ? REDACTED_HEADER_VALUE : value
));
return sanitizedHeaders;
}
}

View File

@@ -0,0 +1,114 @@
package com.easyagents.embedding.openai;
import com.easyagents.core.document.Document;
import com.easyagents.core.model.client.HttpClient;
import com.easyagents.core.model.embedding.EmbeddingOptions;
import com.easyagents.core.model.exception.ModelException;
import org.junit.Assert;
import org.junit.Test;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Tests for OpenAI-compatible embeddings request payload generation.
*/
public class OpenAIEmbeddingModelTest {
/**
* Verifies that fixed-dimension embeddings models do not receive dimensions.
*/
@Test
public void shouldNotSendDimensionsForBgeModel() {
OpenAIEmbeddingConfig config = new OpenAIEmbeddingConfig();
config.setModel("BAAI/bge-m3");
EmbeddingOptions options = new EmbeddingOptions();
options.setDimensions(1024);
String payload = OpenAIEmbeddingModel.promptToEmbeddingsPayload(Document.of("hello"), options, config);
Assert.assertFalse(payload.contains("\"dimensions\""));
}
/**
* Verifies that Qwen3 embedding models keep the dynamic dimensions parameter.
*/
@Test
public void shouldSendDimensionsForQwen3EmbeddingModel() {
OpenAIEmbeddingConfig config = new OpenAIEmbeddingConfig();
config.setModel("Qwen/Qwen3-Embedding-8B");
EmbeddingOptions options = new EmbeddingOptions();
options.setDimensions(1024);
String payload = OpenAIEmbeddingModel.promptToEmbeddingsPayload(Document.of("hello"), options, config);
Assert.assertTrue(payload.contains("\"dimensions\":1024"));
}
/**
* Verifies that malformed embeddings responses fail with a clear model exception.
*/
@Test
public void shouldThrowModelExceptionWhenEmbeddingMissing() {
OpenAIEmbeddingConfig config = new OpenAIEmbeddingConfig();
config.setProvider("test-provider");
config.setModel("BAAI/bge-m3");
config.setApiKey("test-key");
OpenAIEmbeddingModel model = new OpenAIEmbeddingModel(config);
model.setHttpClient(new HttpClient() {
@Override
public String post(String url, Map<String, String> headers, String payload) {
return "{\"data\":[{}]}";
}
});
ModelException exception = Assert.assertThrows(
ModelException.class,
() -> model.embed(Document.of("hello"))
);
Assert.assertTrue(exception.getMessage().contains("data[0].embedding"));
}
/**
* Verifies that invalid JSON responses are reported as embedding response parsing failures.
*/
@Test
public void shouldThrowModelExceptionWhenResponseIsInvalidJson() {
OpenAIEmbeddingConfig config = new OpenAIEmbeddingConfig();
config.setProvider("test-provider");
config.setModel("BAAI/bge-m3");
config.setApiKey("test-key");
OpenAIEmbeddingModel model = new OpenAIEmbeddingModel(config);
model.setHttpClient(new HttpClient() {
@Override
public String post(String url, Map<String, String> headers, String payload) {
return "not-json";
}
});
ModelException exception = Assert.assertThrows(
ModelException.class,
() -> model.embed(Document.of("hello"))
);
Assert.assertEquals("Failed to parse embedding response.", exception.getMessage());
Assert.assertNotNull(exception.getCause());
}
/**
* Verifies that diagnostic headers retain ordinary values without exposing credentials.
*/
@Test
public void shouldRedactAuthorizationHeaderForFailureLogging() {
Map<String, String> headers = new LinkedHashMap<>();
headers.put("Content-Type", "application/json");
headers.put("Authorization", "Bearer test-key");
Map<String, String> sanitizedHeaders = OpenAIEmbeddingModel.sanitizeHeadersForLogging(headers);
Assert.assertEquals("application/json", sanitizedHeaders.get("Content-Type"));
Assert.assertEquals("[REDACTED]", sanitizedHeaders.get("Authorization"));
Assert.assertEquals("Bearer test-key", headers.get("Authorization"));
}
}

View File

@@ -20,16 +20,25 @@ import com.easyagents.flow.core.util.StringUtil;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
public class ChainDefinition implements Serializable {
private static final long serialVersionUID = -3183115191738959423L;
protected String id;
protected String name;
protected String description;
protected List<Node> nodes;
protected List<Edge> edges;
/**
* 由节点和边派生的只读图索引,不参与序列化。
*/
private transient volatile GraphIndex graphIndex;
public ChainDefinition() {
}
@@ -64,6 +73,7 @@ public class ChainDefinition implements Serializable {
public void setNodes(List<Node> nodes) {
this.nodes = nodes;
invalidateGraphIndex();
}
public List<Edge> getEdges() {
@@ -72,27 +82,45 @@ public class ChainDefinition implements Serializable {
public void setEdges(List<Edge> edges) {
this.edges = edges;
invalidateGraphIndex();
}
/**
* 获取指定节点的全部出边。
*
* @param nodeId 节点 ID
* @return 保持定义顺序的出边副本
*/
public List<Edge> getOutwardEdge(String nodeId) {
List<Edge> result = new ArrayList<>();
for (Edge edge : edges) {
if (nodeId.equals(edge.getSource())) {
result.add(edge);
}
}
return result;
List<Edge> outwardEdges = graphIndex().outwardEdgesByNode.get(nodeId);
return outwardEdges == null ? Collections.emptyList() : new ArrayList<>(outwardEdges);
}
/**
* 获取指定节点的全部入边。
*
* @param nodeId 节点 ID
* @return 保持定义顺序的入边副本
*/
public List<Edge> getInwardEdge(String nodeId) {
List<Edge> result = new ArrayList<>();
for (Edge edge : edges) {
if (nodeId.equals(edge.getTarget())) {
result.add(edge);
}
}
return result;
List<Edge> inwardEdges = graphIndex().inwardEdgesByNode.get(nodeId);
return inwardEdges == null ? Collections.emptyList() : new ArrayList<>(inwardEdges);
}
/**
* 获取循环节点已编译的直属分支调度描述。
*
* @param loopNodeId 循环节点 ID
* @return 保持定义顺序的不可变调度描述
*/
public List<LoopChildDispatch> getLoopChildDispatches(
String loopNodeId) {
List<LoopChildDispatch> dispatches =
graphIndex().loopChildrenByNode.get(loopNodeId);
return dispatches == null
? Collections.emptyList()
: dispatches;
}
public void addNode(Node node) {
@@ -105,31 +133,21 @@ public class ChainDefinition implements Serializable {
}
nodes.add(node);
// if (this.edges != null) {
// for (Edge edge : edges) {
// if (node.getId().equals(edge.getSource())) {
// node.addOutwardEdge(edge);
// } else if (node.getId().equals(edge.getTarget())) {
// node.addInwardEdge(edge);
// }
// }
// }
invalidateGraphIndex();
}
/**
* 按 ID 获取节点。
*
* @param id 节点 ID
* @return 对应节点,不存在时返回 {@code null}
*/
public Node getNodeById(String id) {
if (id == null || StringUtil.noText(id)) {
return null;
}
for (Node node : this.nodes) {
if (id.equals(node.getId())) {
return node;
}
}
return null;
return graphIndex().nodeById.get(id);
}
@@ -138,49 +156,33 @@ public class ChainDefinition implements Serializable {
this.edges = new ArrayList<>();
}
this.edges.add(edge);
// boolean findSource = false, findTarget = false;
// for (Node node : this.nodes) {
// if (node.getId().equals(edge.getSource())) {
// node.addOutwardEdge(edge);
// findSource = true;
// } else if (node.getId().equals(edge.getTarget())) {
// node.addInwardEdge(edge);
// findTarget = true;
// }
// if (findSource && findTarget) {
// break;
// }
// }
invalidateGraphIndex();
}
/**
* 按 ID 获取边。
*
* @param edgeId 边 ID
* @return 对应边,不存在时返回 {@code null}
*/
public Edge getEdgeById(String edgeId) {
for (Edge edge : this.edges) {
if (edgeId.equals(edge.getId())) {
return edge;
}
if (StringUtil.noText(edgeId)) {
return null;
}
return null;
return graphIndex().edgeById.get(edgeId);
}
/**
* 获取没有入边的开始节点。
*
* @return 保持定义顺序的开始节点副本
*/
public List<Node> getStartNodes() {
if (nodes == null || nodes.isEmpty()) {
return null;
}
List<Node> result = new ArrayList<>();
for (Node node : nodes) {
// if (CollectionUtil.noItems(node.getInwardEdges())) {
// result.add(node);
// }
List<Edge> inwardEdge = getInwardEdge(node.getId());
if (inwardEdge == null || inwardEdge.isEmpty()) {
result.add(node);
}
}
return result;
return new ArrayList<>(graphIndex().startNodes);
}
@@ -198,6 +200,210 @@ public class ChainDefinition implements Serializable {
return parameters;
}
/**
* 使派生图索引失效。
*/
private void invalidateGraphIndex() {
graphIndex = null;
}
/**
* 获取当前节点和边对应的只读图索引。
*
* @return 图索引
*/
private GraphIndex graphIndex() {
GraphIndex current = graphIndex;
if (current != null) {
return current;
}
synchronized (this) {
current = graphIndex;
if (current == null) {
current = GraphIndex.build(nodes, edges);
graphIndex = current;
}
return current;
}
}
/**
* 工作流定义的派生图索引。
*/
private static final class GraphIndex {
private final Map<String, Node> nodeById;
private final Map<String, Edge> edgeById;
private final Map<String, List<Edge>> outwardEdgesByNode;
private final Map<String, List<Edge>> inwardEdgesByNode;
private final Map<String, List<LoopChildDispatch>>
loopChildrenByNode;
private final List<Node> startNodes;
/**
* 创建不可变图索引。
*
* @param nodeById 节点索引
* @param edgeById 边索引
* @param outwardEdgesByNode 出边索引
* @param inwardEdgesByNode 入边索引
* @param startNodes 开始节点
*/
private GraphIndex(Map<String, Node> nodeById,
Map<String, Edge> edgeById,
Map<String, List<Edge>> outwardEdgesByNode,
Map<String, List<Edge>> inwardEdgesByNode,
Map<String, List<LoopChildDispatch>>
loopChildrenByNode,
List<Node> startNodes) {
this.nodeById = nodeById;
this.edgeById = edgeById;
this.outwardEdgesByNode = outwardEdgesByNode;
this.inwardEdgesByNode = inwardEdgesByNode;
this.loopChildrenByNode = loopChildrenByNode;
this.startNodes = startNodes;
}
/**
* 根据节点和边构建索引。
*
* @param nodes 节点列表
* @param edges 边列表
* @return 构建完成的图索引
*/
private static GraphIndex build(List<Node> nodes, List<Edge> edges) {
Map<String, Node> nodeById = new HashMap<>();
Map<String, Edge> edgeById = new HashMap<>();
Map<String, List<Edge>> outwardEdgesByNode = new HashMap<>();
Map<String, List<Edge>> inwardEdgesByNode = new HashMap<>();
if (nodes != null) {
for (Node node : nodes) {
if (node != null && StringUtil.hasText(node.getId())) {
// 保持旧实现遇到重复 ID 时返回第一个节点的行为。
nodeById.putIfAbsent(node.getId(), node);
}
}
}
if (edges != null) {
for (Edge edge : edges) {
if (edge == null) {
continue;
}
if (StringUtil.hasText(edge.getId())) {
edgeById.putIfAbsent(edge.getId(), edge);
}
if (StringUtil.hasText(edge.getSource())) {
outwardEdgesByNode
.computeIfAbsent(edge.getSource(), ignored -> new ArrayList<>())
.add(edge);
}
if (StringUtil.hasText(edge.getTarget())) {
inwardEdgesByNode
.computeIfAbsent(edge.getTarget(), ignored -> new ArrayList<>())
.add(edge);
}
}
}
List<Node> startNodes = new ArrayList<>();
if (nodes != null) {
for (Node node : nodes) {
if (node != null && !inwardEdgesByNode.containsKey(node.getId())) {
startNodes.add(node);
}
}
}
freezeEdgeLists(outwardEdgesByNode);
freezeEdgeLists(inwardEdgesByNode);
Map<String, List<LoopChildDispatch>>
loopChildrenByNode = new HashMap<>();
for (Map.Entry<String, List<Edge>> entry :
outwardEdgesByNode.entrySet()) {
List<LoopChildDispatch> dispatches = new ArrayList<>();
for (Edge edge : entry.getValue()) {
Node child = nodeById.get(edge.getTarget());
if (child != null
&& Objects.equals(
entry.getKey(), child.getParentId())) {
String branchId = edge.getId() == null
? child.getId()
: edge.getId();
dispatches.add(new LoopChildDispatch(
child, edge.getId(), branchId));
}
}
if (!dispatches.isEmpty()) {
loopChildrenByNode.put(
entry.getKey(),
Collections.unmodifiableList(dispatches));
}
}
return new GraphIndex(
Collections.unmodifiableMap(nodeById),
Collections.unmodifiableMap(edgeById),
Collections.unmodifiableMap(outwardEdgesByNode),
Collections.unmodifiableMap(inwardEdgesByNode),
Collections.unmodifiableMap(loopChildrenByNode),
Collections.unmodifiableList(startNodes));
}
/**
* 将邻接表中的边列表转换为只读列表。
*
* @param edgesByNode 邻接表
*/
private static void freezeEdgeLists(Map<String, List<Edge>> edgesByNode) {
edgesByNode.replaceAll((ignored, value) -> Collections.unmodifiableList(value));
}
}
/**
* 循环直属分支的预编译调度描述。
*/
public static final class LoopChildDispatch {
private final Node node;
private final String edgeId;
private final String branchId;
/**
* 创建调度描述。
*
* @param node 目标节点
* @param edgeId 边 ID
* @param branchId 稳定分支 ID
*/
private LoopChildDispatch(
Node node, String edgeId, String branchId) {
this.node = node;
this.edgeId = edgeId;
this.branchId = branchId;
}
/**
* @return 目标节点
*/
public Node getNode() {
return node;
}
/**
* @return 边 ID
*/
public String getEdgeId() {
return edgeId;
}
/**
* @return 稳定分支 ID
*/
public String getBranchId() {
return branchId;
}
}
@Override
public String toString() {

View File

@@ -37,8 +37,14 @@ import java.util.stream.Collectors;
public class ChainState implements Serializable {
private static final long serialVersionUID = -7958235553581638052L;
private String instanceId;
private String parentInstanceId;
/**
* 节点审计应归属的顶级执行实例 ID。
*/
private String auditInstanceId;
private String chainDefinitionId;
private ConcurrentHashMap<String, Object> memory = new ConcurrentHashMap<>();
@@ -59,9 +65,18 @@ public class ChainState implements Serializable {
private String message;
private ExceptionSummary error;
private long version;
/**
* 工作流实例首次启动时间,用于跨线程和跨进程执行时长保护。
*/
private long startedAt;
/**
* 已进入业务执行的节点次数,用于全局执行预算。
*/
private long childExecutionCount;
public ChainState() {
this.instanceId = UUID.randomUUID().toString();
this.auditInstanceId = this.instanceId;
this.status = ChainStatus.READY;
this.computeCost = 0;
}
@@ -71,7 +86,15 @@ public class ChainState implements Serializable {
}
public void setInstanceId(String instanceId) {
String previousInstanceId =
this.instanceId;
this.instanceId = instanceId;
if (auditInstanceId == null
|| Objects.equals(
auditInstanceId,
previousInstanceId)) {
auditInstanceId = instanceId;
}
}
public String getParentInstanceId() {
@@ -80,6 +103,30 @@ public class ChainState implements Serializable {
public void setParentInstanceId(String parentInstanceId) {
this.parentInstanceId = parentInstanceId;
if (StringUtil.hasText(parentInstanceId)
&& Objects.equals(
auditInstanceId, instanceId)) {
auditInstanceId = null;
}
}
/**
* 获取节点审计归属实例 ID。
*
* @return 顶级审计实例 ID
*/
public String getAuditInstanceId() {
return auditInstanceId;
}
/**
* 设置节点审计归属实例 ID。
*
* @param auditInstanceId 顶级审计实例 ID
*/
public void setAuditInstanceId(
String auditInstanceId) {
this.auditInstanceId = auditInstanceId;
}
public String getChainDefinitionId() {
@@ -127,7 +174,9 @@ public class ChainState implements Serializable {
if (triggerEdgeIds == null) {
triggerEdgeIds = new ArrayList<>();
}
triggerEdgeIds.add(edgeId);
if (!triggerEdgeIds.contains(edgeId)) {
triggerEdgeIds.add(edgeId);
}
}
public List<String> getTriggerNodeIds() {
@@ -142,7 +191,9 @@ public class ChainState implements Serializable {
if (triggerNodeIds == null) {
triggerNodeIds = new ArrayList<>();
}
triggerNodeIds.add(nodeId);
if (!triggerNodeIds.contains(nodeId)) {
triggerNodeIds.add(nodeId);
}
}
public List<String> getUncheckedEdgeIds() {
@@ -150,21 +201,31 @@ public class ChainState implements Serializable {
}
public void setUncheckedEdgeIds(List<String> uncheckedEdgeIds) {
this.uncheckedEdgeIds = uncheckedEdgeIds;
this.uncheckedEdgeIds = uncheckedEdgeIds == null
? new ArrayList<>()
: new ArrayList<>(new LinkedHashSet<>(uncheckedEdgeIds));
}
public void addUncheckedEdgeId(String edgeId) {
public boolean addUncheckedEdgeId(String edgeId) {
if (uncheckedEdgeIds == null) {
uncheckedEdgeIds = new ArrayList<>();
}
if (uncheckedEdgeIds.contains(edgeId)) {
return false;
}
uncheckedEdgeIds.add(edgeId);
return true;
}
public boolean removeUncheckedEdgeId(String edgeId) {
if (uncheckedEdgeIds == null) {
return false;
}
return uncheckedEdgeIds.remove(edgeId);
boolean removed = false;
while (uncheckedEdgeIds.remove(edgeId)) {
removed = true;
}
return removed;
}
public List<String> getUncheckedNodeIds() {
@@ -172,21 +233,31 @@ public class ChainState implements Serializable {
}
public void setUncheckedNodeIds(List<String> uncheckedNodeIds) {
this.uncheckedNodeIds = uncheckedNodeIds;
this.uncheckedNodeIds = uncheckedNodeIds == null
? new ArrayList<>()
: new ArrayList<>(new LinkedHashSet<>(uncheckedNodeIds));
}
public void addUncheckedNodeId(String nodeId) {
public boolean addUncheckedNodeId(String nodeId) {
if (uncheckedNodeIds == null) {
uncheckedNodeIds = new ArrayList<>();
}
if (uncheckedNodeIds.contains(nodeId)) {
return false;
}
uncheckedNodeIds.add(nodeId);
return true;
}
public boolean removeUncheckedNodeId(String nodeId) {
if (uncheckedNodeIds == null) {
return false;
}
return uncheckedNodeIds.remove(nodeId);
boolean removed = false;
while (uncheckedNodeIds.remove(nodeId)) {
removed = true;
}
return removed;
}
public Long getComputeCost() {
@@ -281,6 +352,22 @@ public class ChainState implements Serializable {
this.version = version;
}
public long getStartedAt() {
return startedAt;
}
public void setStartedAt(long startedAt) {
this.startedAt = startedAt;
}
public long getChildExecutionCount() {
return childExecutionCount;
}
public void setChildExecutionCount(long childExecutionCount) {
this.childExecutionCount = childExecutionCount;
}
public static ChainState fromJSON(String jsonString) {
ParserConfig config = new ParserConfig();
config.putDeserializer(ChainState.class, new ChainDeserializer());
@@ -305,6 +392,8 @@ public class ChainState implements Serializable {
this.status = ChainStatus.READY;
this.message = null;
this.error = null;
this.startedAt = 0L;
this.childExecutionCount = 0L;
}
@@ -340,10 +429,46 @@ public class ChainState implements Serializable {
public Object resolveValue(String path) {
return resolveValue(path, false);
}
/**
* 解析参数路径,并可在直接命中时保留大型结果引用。
*
* @param path 参数路径
* @param preserveDirectReference 是否保留直接命中的引用
* @return 参数值
*/
private Object resolveValue(
String path,
boolean preserveDirectReference) {
Object result = MapUtil.getByPath(getMemory(), path);
if (result == null) result = MapUtil.getByPath(getEnvironment(), path);
// if (result == null) result = MapUtil.getByPath(getTriggerVariables(), path);
return result;
Chain chain = Chain.currentChain();
if (result != null || chain == null || memory == null || path == null) {
return chain == null
|| preserveDirectReference
? result
: chain.resolveResultReferences(result);
}
// MapUtil 无法直接穿透轻量引用,先解析最长命中的作用域值,再继续解析剩余路径。
String[] parts = path.split("\\.");
for (int length = parts.length - 1; length > 0; length--) {
String prefix = String.join(".", Arrays.copyOf(parts, length));
Object referenced = memory.get(prefix);
if (referenced == null) {
continue;
}
Object resolved = chain.resolveResultReferences(referenced);
String remaining = String.join(
".", Arrays.copyOfRange(parts, length, parts.length));
return MapUtil.getByPath(
Collections.singletonMap("value", resolved),
"value." + remaining);
}
return null;
}
public Map<String, Object> resolveParameters(Node node) {
@@ -381,7 +506,28 @@ public class ChainState implements Serializable {
* @return 模板渲染上下文列表
*/
public List<Map<String, Object>> buildTemplateRootMaps(Map<String, Object> formatArgs) {
return Arrays.asList(getMemory(), formatArgs, getEnvMap());
Chain chain = Chain.currentChain();
Map<String, Object> runtimeMemory = getMemory();
if (chain != null && runtimeMemory != null && !runtimeMemory.isEmpty()) {
runtimeMemory = new LazyReferenceMap(
runtimeMemory, chain);
}
return Arrays.asList(runtimeMemory, formatArgs, getEnvMap());
}
/**
* 构建审计参数使用的惰性模板上下文。
*
* <p>仅在模板实际读取某个 memory 顶级值时还原其中的轻量引用,
* 避免无关固定参数同步物化大型结果。</p>
*
* @param formatArgs 当前节点参与模板渲染的参数
* @return 惰性模板上下文列表
*/
private List<Map<String, Object>>
buildLazyTemplateRootMaps(
Map<String, Object> formatArgs) {
return buildTemplateRootMaps(formatArgs);
}
/**
@@ -403,23 +549,76 @@ public class ChainState implements Serializable {
}
public Map<String, Object> resolveParameters(Node node, List<? extends Parameter> parameters, Map<String, Object> formatArgs, boolean ignoreRequired) {
return resolveParameters(
node,
parameters,
formatArgs,
ignoreRequired,
false);
}
/**
* 解析节点审计输入,直接引用保持轻量形式,由审计消费者异步还原。
*
* @param node 当前节点
* @return 兼容既有输入字段结构的参数快照
*/
public Map<String, Object> resolveParametersPreservingReferences(
Node node) {
return resolveParameters(
node,
node.getParameters(),
null,
false,
true);
}
/**
* 解析节点参数。
*
* @param node 当前节点
* @param parameters 参数定义
* @param formatArgs 模板附加参数
* @param ignoreRequired 是否忽略必填校验
* @param preserveDirectReferences 是否保留直接结果引用
* @return 已解析参数
*/
private Map<String, Object> resolveParameters(
Node node,
List<? extends Parameter> parameters,
Map<String, Object> formatArgs,
boolean ignoreRequired,
boolean preserveDirectReferences) {
if (parameters == null || parameters.isEmpty()) {
return Collections.emptyMap();
}
Map<String, Object> variables = new LinkedHashMap<>();
List<Parameter> suspendParameters = null;
List<Map<String, Object>> templateRootMaps = null;
for (Parameter parameter : parameters) {
RefType refType = parameter.getRefType();
Object value = null;
if (refType == RefType.FIXED) {
if (templateRootMaps == null) {
templateRootMaps =
preserveDirectReferences
? buildLazyTemplateRootMaps(
formatArgs)
: buildTemplateRootMaps(
formatArgs);
}
value = TextTemplate.of(parameter.getValue())
.formatToString(buildTemplateRootMaps(formatArgs));
.formatToString(templateRootMaps);
} else if (refType == RefType.REF) {
value = this.resolveValue(parameter.getRef());
value = this.resolveValue(
parameter.getRef(),
preserveDirectReferences);
}
// 单节点执行时,参数只会传入 name 内容。
if (value == null) {
value = this.resolveValue(parameter.getName());
value = this.resolveValue(
parameter.getName(),
preserveDirectReferences);
}
if (value == null && parameter.getDefaultValue() != null) {
@@ -475,6 +674,166 @@ public class ChainState implements Serializable {
return variables;
}
/**
* 按实际访问惰性还原顶级 memory 值的只读映射。
*/
private static final class LazyReferenceMap
extends AbstractMap<String, Object> {
private final Map<String, Object> delegate;
private final Chain chain;
/**
* 同一模板渲染内已经还原的顶级值,避免重复引用触发重复分块读取。
*/
private final Map<Object, Object> resolvedValues =
new HashMap<>();
/**
* 创建惰性引用映射。
*
* @param delegate 原始运行时 memory
* @param chain 当前工作流链路
*/
private LazyReferenceMap(
Map<String, Object> delegate,
Chain chain) {
this.delegate = delegate;
this.chain = chain;
}
/**
* {@inheritDoc}
*/
@Override
public synchronized Object get(Object key) {
if (!delegate.containsKey(key)) {
return null;
}
if (resolvedValues.containsKey(key)) {
return resolvedValues.get(key);
}
Object resolved =
chain.resolveResultReferences(
delegate.get(key));
resolvedValues.put(key, resolved);
return resolved;
}
/**
* {@inheritDoc}
*/
@Override
public boolean containsKey(Object key) {
return delegate.containsKey(key);
}
/**
* {@inheritDoc}
*/
@Override
public boolean isEmpty() {
return delegate.isEmpty();
}
/**
* {@inheritDoc}
*/
@Override
public int size() {
return delegate.size();
}
/**
* {@inheritDoc}
*/
@Override
public Set<String> keySet() {
return Collections.unmodifiableSet(
delegate.keySet());
}
/**
* {@inheritDoc}
*/
@Override
public Set<Entry<String, Object>> entrySet() {
Set<String> keys = keySet();
return new AbstractSet<>() {
@Override
public Iterator<Entry<String, Object>>
iterator() {
Iterator<String> iterator =
keys.iterator();
return new Iterator<>() {
@Override
public boolean hasNext() {
return iterator
.hasNext();
}
@Override
public Entry<String, Object>
next() {
String key =
iterator.next();
return lazyEntry(key);
}
};
}
@Override
public int size() {
return keys.size();
}
};
}
/**
* 创建仅在读取值时还原引用的不可变条目。
*
* @param key memory 键
* @return 惰性条目
*/
private Entry<String, Object> lazyEntry(
String key) {
return new Entry<>() {
@Override
public String getKey() {
return key;
}
@Override
public Object getValue() {
return LazyReferenceMap.this
.get(key);
}
@Override
public Object setValue(Object value) {
throw new UnsupportedOperationException(
"read-only runtime memory");
}
@Override
public boolean equals(Object value) {
return value instanceof Entry<?, ?> entry
&& Objects.equals(
key, entry.getKey())
&& Objects.equals(
getValue(),
entry.getValue());
}
@Override
public int hashCode() {
return Objects.hashCode(key)
^ Objects.hashCode(
getValue());
}
};
}
}
public static class ChainSerializer implements ObjectSerializer {
@Override
@@ -513,6 +872,8 @@ public class ChainState implements Serializable {
", message='" + message + '\'' +
", error=" + error +
", version=" + version +
", startedAt=" + startedAt +
", childExecutionCount=" + childExecutionCount +
'}';
}
}

View File

@@ -16,7 +16,14 @@
package com.easyagents.flow.core.chain;
public class Edge {
import java.io.Serializable;
/**
* 工作流节点之间的有向边定义。
*/
public class Edge implements Serializable {
private static final long serialVersionUID = 1L;
private String id;
private String source;
private String target;

View File

@@ -16,10 +16,22 @@
package com.easyagents.flow.core.chain;
import java.io.Serializable;
import java.util.Map;
public interface EdgeCondition {
/**
* 工作流边的执行条件。
*/
public interface EdgeCondition extends Serializable {
/**
* 判断边是否允许继续执行。
*
* @param chain 当前工作流实例
* @param edge 待检查的边
* @param executeResult 上游节点执行结果
* @return 允许执行时返回 {@code true}
*/
boolean check(Chain chain, Edge edge, Map<String, Object> executeResult);
}

View File

@@ -20,23 +20,31 @@ import com.easyagents.flow.core.chain.listener.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* 管理工作流执行过程中的事件、输出及错误监听器。
*
* <p>监听器的注册与移除频率远低于事件分发频率,使用写时复制集合保证分发过程可以无锁遍历,
* 同时允许其他线程安全地注册或移除监听器。</p>
*/
public class EventManager {
private static final Logger log = LoggerFactory.getLogger(EventManager.class);
protected final Map<Class<?>, List<ChainEventListener>> eventListeners = new ConcurrentHashMap<>();
protected final List<ChainOutputListener> outputListeners = Collections.synchronizedList(new ArrayList<>());
protected final List<ChainErrorListener> chainErrorListeners = Collections.synchronizedList(new ArrayList<>());
protected final List<NodeErrorListener> nodeErrorListeners = Collections.synchronizedList(new ArrayList<>());
protected final List<ChainOutputListener> outputListeners = new CopyOnWriteArrayList<>();
protected final List<ChainErrorListener> chainErrorListeners = new CopyOnWriteArrayList<>();
protected final List<NodeErrorListener> nodeErrorListeners = new CopyOnWriteArrayList<>();
/**
* ---------- 通用事件监听器 ----------
*/
public void addEventListener(Class<? extends Event> eventClass, ChainEventListener listener) {
eventListeners.computeIfAbsent(eventClass, k -> Collections.synchronizedList(new ArrayList<>())).add(listener);
eventListeners.computeIfAbsent(eventClass, key -> new CopyOnWriteArrayList<>()).add(listener);
}
public void addEventListener(ChainEventListener listener) {

View File

@@ -20,6 +20,7 @@ import java.io.Serializable;
import java.io.StringWriter;
public class ExceptionSummary implements Serializable {
private static final long serialVersionUID = 1L;
private String exceptionClass;
private String message;
@@ -134,4 +135,3 @@ public class ExceptionSummary implements Serializable {
this.timestamp = timestamp;
}
}

View File

@@ -25,7 +25,12 @@ import java.util.List;
import java.util.Map;
public abstract class Node implements Serializable {
private static final long serialVersionUID = 1L;
private static final Logger log = org.slf4j.LoggerFactory.getLogger(Node.class);
/** 可配置的最小循环次数。 */
public static final int MIN_LOOP_COUNT = 1;
/** 单个节点允许的最大循环次数。 */
public static final int MAX_LOOP_COUNT = 300;
protected String id;
protected String parentId;
@@ -42,7 +47,7 @@ public abstract class Node implements Serializable {
protected boolean loopEnable = false; // 是否启用循环执行
protected long loopIntervalMs = 3000; // 循环间隔时间(毫秒)
protected NodeCondition loopBreakCondition; // 跳出循环的条件
protected int maxLoopCount = 0; // 0 表示不限制循环次数
protected int maxLoopCount = MIN_LOOP_COUNT; // 循环总执行次数,取值范围 1300
protected boolean retryEnable = false;
protected boolean resetRetryCountAfterNormal = false;
@@ -158,7 +163,22 @@ public abstract class Node implements Serializable {
return maxLoopCount;
}
/**
* 设置节点循环的总执行次数。
*
* @param maxLoopCount 总执行次数,范围为 1300
* @throws IllegalArgumentException 循环次数超出允许范围
*/
public void setMaxLoopCount(int maxLoopCount) {
if (maxLoopCount < MIN_LOOP_COUNT || maxLoopCount > MAX_LOOP_COUNT) {
throw new IllegalArgumentException(
"maxLoopCount must be between "
+ MIN_LOOP_COUNT
+ " and "
+ MAX_LOOP_COUNT
+ ", but was "
+ maxLoopCount);
}
this.maxLoopCount = maxLoopCount;
}
@@ -237,7 +257,12 @@ public abstract class Node implements Serializable {
protected long doCalculateComputeCost(String expr, Chain chain, Map<String, Object> result) {
// Map<String, Object> parameterValues = chain.getState().getParameterValuesOnly(this, this.getParameters(), null);
Map<String, Object> parameterValues = chain.getState().resolveParameters(this, this.getParameters(), null,true);
Map<String, Object> parameterValues =
chain.getExecutionState().resolveParameters(
this,
this.getParameters(),
null,
true);
Map<String, Object> newMap = new HashMap<>(result);
newMap.putAll(parameterValues);
return JsConditionUtil.evalLong(expr, chain, newMap);

View File

@@ -16,10 +16,22 @@
package com.easyagents.flow.core.chain;
import java.io.Serializable;
import java.util.Map;
public interface NodeCondition {
/**
* 工作流节点的执行条件。
*/
public interface NodeCondition extends Serializable {
/**
* 判断节点是否允许继续执行。
*
* @param chain 当前工作流实例
* @param context 当前节点状态
* @param executeResult 上一次执行结果
* @return 允许执行时返回 {@code true}
*/
boolean check(Chain chain, NodeState context, Map<String, Object> executeResult);
}

View File

@@ -20,10 +20,11 @@ import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
public class NodeState implements Serializable {
private static final long serialVersionUID = -6727481826462129573L;
private String nodeId;
private String chainInstanceId;
@@ -39,6 +40,11 @@ public class NodeState implements Serializable {
private AtomicInteger executeCount = new AtomicInteger(0);
private List<String> executeEdgeIds = new ArrayList<>();
/**
* 当前节点生命周期对应的稳定业务尝试键。
*/
private String executionAttemptKey;
ExceptionSummary error;
private long version;
@@ -135,6 +141,26 @@ public class NodeState implements Serializable {
this.executeEdgeIds = executeEdgeIds;
}
/**
* 获取当前节点生命周期的稳定业务尝试键。
*
* @return 稳定业务尝试键
*/
public String getExecutionAttemptKey() {
return executionAttemptKey;
}
/**
* 设置当前节点生命周期的稳定业务尝试键。
*
* @param executionAttemptKey 稳定业务尝试键
*/
public void setExecutionAttemptKey(
String executionAttemptKey) {
this.executionAttemptKey =
executionAttemptKey;
}
public ExceptionSummary getError() {
return error;
}
@@ -158,10 +184,16 @@ public class NodeState implements Serializable {
return true;
}
List<String> shouldBeTriggerIds = inwardEdges.stream().map(Edge::getId).collect(Collectors.toList());
List<String> triggerEdgeIds = this.triggerEdgeIds;
return triggerEdgeIds.size() >= shouldBeTriggerIds.size()
&& shouldBeTriggerIds.parallelStream().allMatch(triggerEdgeIds::contains);
if (triggerEdgeIds.size() < inwardEdges.size()) {
return false;
}
java.util.Set<String> triggeredEdges = new java.util.HashSet<>(triggerEdgeIds);
for (Edge inwardEdge : inwardEdges) {
if (!triggeredEdges.contains(inwardEdge.getId())) {
return false;
}
}
return true;
}
public void recordTrigger(String fromEdgeId) {
@@ -169,7 +201,9 @@ public class NodeState implements Serializable {
if (fromEdgeId == null) {
fromEdgeId = "none";
}
triggerEdgeIds.add(fromEdgeId);
if (!triggerEdgeIds.contains(fromEdgeId)) {
triggerEdgeIds.add(fromEdgeId);
}
}
public void recordExecute(String fromEdgeId) {
@@ -177,6 +211,7 @@ public class NodeState implements Serializable {
if (fromEdgeId == null) {
fromEdgeId = "none";
}
executeEdgeIds.clear();
executeEdgeIds.add(fromEdgeId);
}

View File

@@ -16,6 +16,18 @@
package com.easyagents.flow.core.chain;
public interface NodeValidator {
import java.io.Serializable;
/**
* 工作流节点定义校验器。
*/
public interface NodeValidator extends Serializable {
/**
* 校验节点定义。
*
* @param node 待校验节点
* @return 校验结果
*/
NodeValidResult validate(Node node);
}

View File

@@ -23,6 +23,7 @@ import java.util.Collection;
import java.util.List;
public class Parameter implements Serializable, Cloneable {
private static final long serialVersionUID = 1L;
protected String id;
protected String name;
protected String description;
@@ -37,6 +38,10 @@ public class Parameter implements Serializable, Cloneable {
protected String value;
protected boolean required;
protected String defaultValue;
/**
* 是否在循环节点完成后将各轮数组输出合并一层。
*/
protected boolean flattenAggregation;
protected List<Parameter> children;
/**
@@ -166,6 +171,24 @@ public class Parameter implements Serializable, Cloneable {
this.defaultValue = defaultValue;
}
/**
* 判断循环输出是否启用一层扁平聚合。
*
* @return 启用时返回 {@code true}
*/
public boolean isFlattenAggregation() {
return flattenAggregation;
}
/**
* 设置循环输出的一层扁平聚合开关。
*
* @param flattenAggregation 是否启用
*/
public void setFlattenAggregation(boolean flattenAggregation) {
this.flattenAggregation = flattenAggregation;
}
public boolean isRequired() {
return required;
}
@@ -272,6 +295,7 @@ public class Parameter implements Serializable, Cloneable {
", value='" + value + '\'' +
", required=" + required +
", defaultValue='" + defaultValue + '\'' +
", flattenAggregation=" + flattenAggregation +
", children=" + children +
", enums=" + enums +
", formType='" + formType + '\'' +

View File

@@ -0,0 +1,126 @@
/**
* Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com).
* <p>
* Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl-3.0.txt
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.easyagents.flow.core.chain.event;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.Node;
/**
* LLM 节点生成文本时发布的增量事件。
*/
public class LlmStreamEvent extends BaseEvent {
private final Node node;
private final String streamId;
private final String delta;
private final ContentType contentType;
/**
* LLM 流式内容类型。
*/
public enum ContentType {
/**
* 模型正式回答。
*/
TEXT,
/**
* 模型思考过程。
*/
REASONING
}
/**
* 创建 LLM 文本增量事件。
*
* @param chain 当前工作流
* @param node 当前 LLM 节点
* @param streamId 当前节点本次调用的流标识
* @param delta 本次新增文本
*/
public LlmStreamEvent(Chain chain, Node node, String streamId, String delta) {
this(chain, node, streamId, delta, ContentType.TEXT);
}
/**
* 创建指定内容类型的 LLM 增量事件。
*
* @param chain 当前工作流
* @param node 当前 LLM 节点
* @param streamId 当前节点本次调用的流标识
* @param delta 本次新增内容
* @param contentType 增量内容类型
*/
public LlmStreamEvent(
Chain chain,
Node node,
String streamId,
String delta,
ContentType contentType
) {
super(chain);
this.node = node;
this.streamId = streamId;
this.delta = delta;
this.contentType = contentType == null
? ContentType.TEXT
: contentType;
}
/**
* 获取当前 LLM 节点。
*
* @return 当前节点
*/
public Node getNode() {
return node;
}
/**
* 获取当前节点本次调用的流标识。
*
* @return 流标识
*/
public String getStreamId() {
return streamId;
}
/**
* 获取本次新增文本。
*
* @return 文本增量
*/
public String getDelta() {
return delta;
}
/**
* 获取本次增量的内容类型。
*
* @return 内容类型
*/
public ContentType getContentType() {
return contentType;
}
/**
* 判断本次增量是否为模型思考内容。
*
* @return {@code true} 表示思考内容
*/
public boolean isReasoning() {
return contentType == ContentType.REASONING;
}
}

View File

@@ -18,40 +18,133 @@ package com.easyagents.flow.core.chain.event;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.Node;
import com.easyagents.flow.core.chain.NodeStatus;
import java.util.Map;
/**
* 节点结束执行事件。
*/
public class NodeEndEvent extends BaseEvent {
private final Node node;
private final Map<String, Object> result;
private final Throwable error;
private final NodeStatus status;
private final String executionAttemptKey;
/**
* 创建节点结束事件。
*
* @param chain 当前工作流
* @param node 当前节点
* @param result 节点输出
* @param error 节点异常
*/
public NodeEndEvent(Chain chain, Node node, Map<String, Object> result, Throwable error) {
this(chain, node, result, error, null, null);
}
/**
* 创建携带不可变业务尝试键的节点结束事件。
*
* @param chain 当前工作流
* @param node 当前节点
* @param result 节点输出
* @param error 节点异常
* @param executionAttemptKey 节点本次业务尝试键
*/
public NodeEndEvent(Chain chain,
Node node,
Map<String, Object> result,
Throwable error,
String executionAttemptKey) {
this(
chain,
node,
result,
error,
null,
executionAttemptKey);
}
/**
* 创建携带不可变节点终态和业务尝试键的节点结束事件。
*
* @param chain 当前工作流
* @param node 当前节点
* @param result 节点输出
* @param error 节点异常
* @param status 节点本次业务尝试终态
* @param executionAttemptKey 节点本次业务尝试键
*/
public NodeEndEvent(Chain chain,
Node node,
Map<String, Object> result,
Throwable error,
NodeStatus status,
String executionAttemptKey) {
super(chain);
this.node = node;
this.result = result;
this.error = error;
this.status = status;
this.executionAttemptKey = executionAttemptKey;
}
/**
* 获取当前节点。
*
* @return 当前节点
*/
public Node getNode() {
return node;
}
/**
* 获取节点输出。
*
* @return 节点输出
*/
public Map<String, Object> getResult() {
return result;
}
/**
* 获取节点异常。
*
* @return 节点异常;成功时为 {@code null}
*/
public Throwable getError() {
return error;
}
/**
* 获取事件创建时捕获的节点终态。
*
* @return 节点终态;旧调用方未提供时为 {@code null}
*/
public NodeStatus getStatus() {
return status;
}
/**
* 获取事件创建时捕获的业务尝试键。
*
* @return 业务尝试键;旧调用方未提供时为 {@code null}
*/
public String getExecutionAttemptKey() {
return executionAttemptKey;
}
@Override
public String toString() {
return "NodeEndEvent{" +
"node=" + node +
", result=" + result +
", error=" + error +
", status=" + status +
", executionAttemptKey='" + executionAttemptKey + '\'' +
", chain=" + chain +
'}';
}

View File

@@ -18,25 +18,123 @@ package com.easyagents.flow.core.chain.event;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.Node;
import com.easyagents.flow.core.chain.NodeStatus;
/**
* 节点开始执行事件。
*/
public class NodeStartEvent extends BaseEvent {
private final Node node;
private final String executionAttemptKey;
private final NodeStatus status;
private final String auditInstanceId;
/**
* 创建节点开始事件。
*
* @param chain 当前工作流
* @param node 当前节点
*/
public NodeStartEvent(Chain chain, Node node) {
super(chain);
this.node = node;
this(chain, node, null, null,
chain.getStateInstanceId());
}
/**
* 创建携带不可变业务尝试键的节点开始事件。
*
* @param chain 当前工作流
* @param node 当前节点
* @param executionAttemptKey 节点本次业务尝试键
*/
public NodeStartEvent(Chain chain,
Node node,
String executionAttemptKey) {
this(chain, node, executionAttemptKey, null,
chain.getStateInstanceId());
}
/**
* 创建携带不可变业务尝试键和节点状态的开始事件。
*
* @param chain 当前工作流
* @param node 当前节点
* @param executionAttemptKey 节点本次业务尝试键
* @param status 事件创建时的节点状态
*/
public NodeStartEvent(Chain chain,
Node node,
String executionAttemptKey,
NodeStatus status) {
this(chain, node, executionAttemptKey, status,
chain.getStateInstanceId());
}
/**
* 创建携带完整不可变审计上下文的节点开始事件。
*
* @param chain 当前工作流
* @param node 当前节点
* @param executionAttemptKey 节点本次业务尝试键
* @param status 事件创建时的节点状态
* @param auditInstanceId 节点审计应关联的顶级执行实例 ID
*/
public NodeStartEvent(Chain chain,
Node node,
String executionAttemptKey,
NodeStatus status,
String auditInstanceId) {
super(chain);
this.node = node;
this.executionAttemptKey = executionAttemptKey;
this.status = status;
this.auditInstanceId = auditInstanceId;
}
/**
* 获取当前节点。
*
* @return 当前节点
*/
public Node getNode() {
return node;
}
/**
* 获取事件创建时捕获的业务尝试键。
*
* @return 业务尝试键;旧调用方未提供时为 {@code null}
*/
public String getExecutionAttemptKey() {
return executionAttemptKey;
}
/**
* 获取事件创建时捕获的节点状态。
*
* @return 节点状态;旧调用方未提供时为 {@code null}
*/
public NodeStatus getStatus() {
return status;
}
/**
* 获取节点审计关联的顶级执行实例 ID。
*
* @return 顶级执行实例 ID
*/
public String getAuditInstanceId() {
return auditInstanceId;
}
@Override
public String toString() {
return "NodeStartEvent{" +
"node=" + node +
", executionAttemptKey='" + executionAttemptKey + '\'' +
", status=" + status +
", auditInstanceId='" + auditInstanceId + '\'' +
", chain=" + chain +
'}';
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com).
* Licensed under the GNU Lesser General Public License (LGPL), Version 3.0.
*/
package com.easyagents.flow.core.chain.repository;
import com.easyagents.flow.core.chain.ChainDefinition;
/**
* 工作流实例级定义快照仓储。
*/
public interface ChainDefinitionSnapshotRepository {
/**
* 保存实例启动时的定义快照。
*
* @param instanceId 工作流实例 ID
* @param definition 定义快照
*/
void save(String instanceId, ChainDefinition definition);
/**
* 加载实例启动时的定义快照。
*
* @param instanceId 工作流实例 ID
* @return 定义快照;不存在时返回 null
*/
ChainDefinition load(String instanceId);
/**
* 删除定义快照。
*
* @param instanceId 工作流实例 ID
*/
void remove(String instanceId);
}

View File

@@ -25,9 +25,27 @@ public interface ChainLock extends AutoCloseable {
*/
boolean isAcquired();
/**
* 锁是否仍由当前 owner 持有。
*
* @return 锁仍有效时为 true
*/
default boolean isValid() {
return isAcquired();
}
/**
* 获取本次锁持有期对应的 fencing token。
*
* @return 分布式仓储生成的单实例单调递增 token本地锁返回 {@code 0}
*/
default long getFencingToken() {
return 0L;
}
/**
* 释放锁(幂等)
*/
@Override
void close();
}
}

View File

@@ -31,8 +31,12 @@ public enum ChainStateField {
ENVIRONMENT,
CHILD_STATE_IDS,
PARENT_INSTANCE_ID,
AUDIT_INSTANCE_ID,
TRIGGER_NODE_IDS,
TRIGGER_EDGE_IDS,
UNCHECKED_EDGE_IDS,
UNCHECKED_NODE_IDS;
UNCHECKED_NODE_IDS,
STARTED_AT,
CHILD_EXECUTION_COUNT,
VERSION;
}

View File

@@ -24,8 +24,70 @@ public interface ChainStateRepository {
ChainState load(String instanceId);
/**
* 轻量读取工作流状态版本。
*
* <p>分布式仓储应覆盖本方法并只读取版本字段,避免节点状态提交前反序列化完整
* 工作流热状态。</p>
*
* @param instanceId 工作流实例 ID
* @return 当前版本;状态不存在时返回 {@code null}
*/
default Long loadVersion(String instanceId) {
ChainState state = load(instanceId);
return state == null ? null : state.getVersion();
}
/**
* 创建工作流实例状态。
*
* @param instanceId 工作流实例 ID
* @return 已存在或新创建的状态
*/
default ChainState create(String instanceId) {
return load(instanceId);
}
boolean tryUpdate(ChainState newState, EnumSet<ChainStateField> fields);
/**
* 在当前实例锁 fencing token 仍有效时提交状态。
*
* <p>单进程仓储可沿用普通乐观锁;分布式仓储应覆盖本方法并在同一原子操作中校验
* token。</p>
*
* @param newState 待提交状态
* @param fields 变化字段
* @param fencingToken 当前实例锁 token本地仓储调用为 {@code 0}
* @return 提交成功时为 {@code true}
*/
default boolean tryUpdate(
ChainState newState, EnumSet<ChainStateField> fields, long fencingToken) {
return tryUpdate(newState, fields);
}
/**
* 在实例锁和当前触发器认领租约均有效时提交状态。
*
* <p>分布式仓储应在同一原子操作中校验实例锁 fencing token 与 claim generation
* 同时拒绝锁过期后的旧执行者和租约过期后的旧 owner。</p>
*
* @param newState 待提交状态
* @param fields 变化字段
* @param lockFencingToken 当前实例锁 token本地仓储调用为 {@code 0}
* @param claimId 当前触发器 ID非触发器调用为 {@code null}
* @param claimGeneration 当前认领代际;非触发器调用为 {@code 0}
* @return 提交成功时为 {@code true}
*/
default boolean tryUpdate(
ChainState newState,
EnumSet<ChainStateField> fields,
long lockFencingToken,
String claimId,
long claimGeneration) {
return tryUpdate(newState, fields, lockFencingToken);
}
/**
* 获取指定 instanceId 的分布式锁
*

View File

@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com).
* Licensed under the GNU Lesser General Public License (LGPL), Version 3.0.
*/
package com.easyagents.flow.core.chain.repository;
import com.easyagents.flow.core.chain.ChainDefinition;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 进程内工作流定义快照仓储。
*/
public class InMemoryChainDefinitionSnapshotRepository
implements ChainDefinitionSnapshotRepository {
private final Map<String, ChainDefinition> snapshots = new ConcurrentHashMap<>();
/**
* {@inheritDoc}
*/
@Override
public void save(String instanceId, ChainDefinition definition) {
snapshots.put(instanceId, definition);
}
/**
* {@inheritDoc}
*/
@Override
public ChainDefinition load(String instanceId) {
return snapshots.get(instanceId);
}
/**
* {@inheritDoc}
*/
@Override
public void remove(String instanceId) {
snapshots.remove(instanceId);
}
}

View File

@@ -16,24 +16,41 @@
package com.easyagents.flow.core.chain.repository;
import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.util.MapUtil;
import java.util.EnumSet;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 进程内工作流状态仓储。
*/
public class InMemoryChainStateRepository implements ChainStateRepository {
private static final Map<String, ChainState> chainStateMap = new ConcurrentHashMap<>();
/**
* {@inheritDoc}
*/
@Override
public ChainState load(String instanceId) {
return MapUtil.computeIfAbsent(chainStateMap, instanceId, k -> {
// 保留进程内仓储原有的惰性初始化语义,兼容直接构造 Chain 的调用方式。
return create(instanceId);
}
/**
* {@inheritDoc}
*/
@Override
public ChainState create(String instanceId) {
return chainStateMap.computeIfAbsent(instanceId, ignored -> {
ChainState state = new ChainState();
state.setInstanceId(instanceId);
return state;
});
}
/**
* {@inheritDoc}
*/
@Override
public boolean tryUpdate(ChainState chainState, EnumSet<ChainStateField> fields) {
chainStateMap.put(chainState.getInstanceId(), chainState);

View File

@@ -0,0 +1,111 @@
/**
* Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com).
* <p>
* Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl-3.0.txt
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.easyagents.flow.core.chain.repository;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 进程内循环累计结果仓储,适用于单机运行和测试。
*/
public class InMemoryLoopResultRepository implements LoopResultRepository {
private final Map<String, Map<String, List<Object>>> results = new ConcurrentHashMap<>();
private final Map<String, List<Object>> inputs = new ConcurrentHashMap<>();
/**
* {@inheritDoc}
*/
@Override
public int storeInput(String resultId, Iterable<?> items) {
List<Object> stored = new ArrayList<>();
for (Object item : items) {
stored.add(item);
}
List<Object> existing = inputs.putIfAbsent(resultId, stored);
return existing == null ? stored.size() : existing.size();
}
/**
* {@inheritDoc}
*/
@Override
public Object loadInputItem(String resultId, int index) {
List<Object> stored = inputs.get(resultId);
if (stored == null) {
throw new IllegalStateException("Loop input not found: " + resultId);
}
return stored.get(index);
}
/**
* {@inheritDoc}
*/
@Override
public void removeInput(String resultId) {
inputs.remove(resultId);
}
/**
* {@inheritDoc}
*/
@Override
public void append(String resultId, int iterationIndex, Map<String, Object> outputValues) {
if (outputValues == null || outputValues.isEmpty()) {
return;
}
Map<String, List<Object>> result = results.computeIfAbsent(
resultId, ignored -> Collections.synchronizedMap(new LinkedHashMap<>()));
synchronized (result) {
for (Map.Entry<String, Object> entry : outputValues.entrySet()) {
List<Object> values = result.computeIfAbsent(entry.getKey(), ignored -> new ArrayList<>());
if (values.size() != iterationIndex) {
throw new IllegalStateException("Unexpected loop result index: " + iterationIndex);
}
values.add(entry.getValue());
}
}
}
/**
* {@inheritDoc}
*/
@Override
public Map<String, Object> load(String resultId, int iterationCount, List<String> outputNames) {
Map<String, List<Object>> result = results.get(resultId);
Map<String, Object> snapshot = new LinkedHashMap<>();
if (iterationCount == 0 || outputNames == null || outputNames.isEmpty()) {
return snapshot;
}
if (result == null) {
throw new IllegalStateException("Loop result not found: " + resultId);
}
synchronized (result) {
for (String outputName : outputNames) {
List<Object> values = result.get(outputName);
if (values == null || values.size() != iterationCount) {
throw new IllegalStateException("Incomplete loop result: " + outputName);
}
snapshot.put(outputName, new ArrayList<>(values));
}
}
return snapshot;
}
}

View File

@@ -16,20 +16,33 @@
package com.easyagents.flow.core.chain.repository;
import com.easyagents.flow.core.chain.NodeState;
import com.easyagents.flow.core.util.MapUtil;
import java.util.EnumSet;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 进程内节点状态仓储。
*/
public class InMemoryNodeStateRepository implements NodeStateRepository {
private static final Map<String, NodeState> chainStateMap = new ConcurrentHashMap<>();
/**
* {@inheritDoc}
*/
@Override
public NodeState load(String instanceId, String nodeId) {
String key = instanceId + "." + nodeId;
return MapUtil.computeIfAbsent(chainStateMap, key, k -> {
// 保留进程内仓储原有的惰性初始化语义,避免改变既有直接读取行为。
return create(instanceId, nodeId, 0L);
}
/**
* {@inheritDoc}
*/
@Override
public NodeState create(String instanceId, String nodeId, long chainStateVersion) {
return chainStateMap.computeIfAbsent(key(instanceId, nodeId), ignored -> {
NodeState nodeState = new NodeState();
nodeState.setChainInstanceId(instanceId);
nodeState.setNodeId(nodeId);
@@ -37,9 +50,23 @@ public class InMemoryNodeStateRepository implements NodeStateRepository {
});
}
/**
* {@inheritDoc}
*/
@Override
public boolean tryUpdate(NodeState newState, EnumSet<NodeStateField> fields, long version) {
chainStateMap.put(newState.getChainInstanceId() + "." + newState.getNodeId(), newState);
chainStateMap.put(key(newState.getChainInstanceId(), newState.getNodeId()), newState);
return true;
}
/**
* 构建进程内节点状态键。
*
* @param instanceId 工作流实例 ID
* @param nodeId 节点 ID
* @return 节点状态键
*/
private String key(String instanceId, String nodeId) {
return instanceId + "." + nodeId;
}
}

View File

@@ -0,0 +1,62 @@
package com.easyagents.flow.core.chain.repository;
import java.io.Serializable;
import java.util.Objects;
/**
* 已分块保存的循环输入轻量引用。
*
* <p>循环节点按序读取分块;其他业务节点在参数读取边界会透明还原为与原输入等价的列表。</p>
*/
public final class LoopInputReference implements Serializable {
private static final long serialVersionUID = 1L;
private static final String REFERENCE_TYPE =
"easyflow.loop-input.v1";
private final String resultId;
private final int itemCount;
/**
* 创建循环输入引用。
*
* @param resultId 循环输入结果 ID
* @param itemCount 输入元素数量
*/
public LoopInputReference(String resultId, int itemCount) {
this.resultId = Objects.requireNonNull(
resultId, "resultId must not be null");
if (itemCount < 0) {
throw new IllegalArgumentException(
"itemCount must not be negative");
}
this.itemCount = itemCount;
}
/**
* 获取循环输入结果 ID。
*
* @return 结果 ID
*/
public String getResultId() {
return resultId;
}
/**
* 获取输入元素数量。
*
* @return 元素数量
*/
public int getItemCount() {
return itemCount;
}
/**
* 获取跨异步审计边界使用的稳定引用类型。
*
* @return 引用类型
*/
public String getReferenceType() {
return REFERENCE_TYPE;
}
}

View File

@@ -0,0 +1,94 @@
package com.easyagents.flow.core.chain.repository;
import java.io.Serializable;
import java.util.Objects;
/**
* 循环累计输出的轻量引用,避免完整列表回写到高频热状态。
*/
public final class LoopResultReference implements Serializable {
private static final long serialVersionUID = 1L;
private static final String REFERENCE_TYPE =
"easyflow.loop-result.v1";
private final String resultId;
private final int iterationCount;
private final String outputName;
private final boolean flattenAggregation;
/**
* 创建循环结果引用。
*
* @param resultId 循环结果 ID
* @param iterationCount 迭代次数
* @param outputName 输出名称
*/
public LoopResultReference(String resultId, int iterationCount, String outputName) {
this(resultId, iterationCount, outputName, false);
}
/**
* 创建带聚合策略的循环结果引用。
*
* @param resultId 循环结果 ID
* @param iterationCount 迭代次数
* @param outputName 输出名称
* @param flattenAggregation 是否将各轮数组合并一层
*/
public LoopResultReference(
String resultId,
int iterationCount,
String outputName,
boolean flattenAggregation) {
this.resultId = Objects.requireNonNull(resultId, "resultId must not be null");
this.iterationCount = iterationCount;
this.outputName = Objects.requireNonNull(outputName, "outputName must not be null");
this.flattenAggregation = flattenAggregation;
}
/**
* 获取循环结果 ID。
*
* @return 循环结果 ID
*/
public String getResultId() {
return resultId;
}
/**
* 获取累计迭代次数。
*
* @return 累计迭代次数
*/
public int getIterationCount() {
return iterationCount;
}
/**
* 获取输出名称。
*
* @return 输出名称
*/
public String getOutputName() {
return outputName;
}
/**
* 判断是否将各轮数组输出合并一层。
*
* @return 启用时返回 {@code true}
*/
public boolean isFlattenAggregation() {
return flattenAggregation;
}
/**
* 获取跨异步审计边界使用的稳定引用类型。
*
* @return 引用类型
*/
public String getReferenceType() {
return REFERENCE_TYPE;
}
}

View File

@@ -0,0 +1,602 @@
/**
* Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com).
* <p>
* Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl-3.0.txt
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.easyagents.flow.core.chain.repository;
import com.easyagents.flow.core.chain.runtime.ExecutionBudgetExceededException;
import java.util.Iterator;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
/**
* 循环节点累计结果仓储。
* <p>
* 累计结果独立于高频更新的节点状态保存,避免每轮迭代重复序列化全部历史结果。
*/
public interface LoopResultRepository {
/**
* 流式保存不可随机访问的循环输入。
*
* @param resultId 循环结果 ID
* @param items 原始输入
* @return 输入元素数量
*/
int storeInput(String resultId, Iterable<?> items);
/**
* 在已启用的迭代预算内流式保存循环输入。
*
* <p>实现会在读取第 {@code maxItems + 1} 个元素前终止,避免超大或无限 Iterable
* 先产生无界 I/O。具体仓储应在下游写入异常时清理已落盘的部分分块。</p>
*
* @param resultId 循环结果 ID
* @param items 原始输入
* @param maxItems 最大元素数;小于等于零表示不限制
* @return 输入元素数量
*/
default int storeInput(String resultId, Iterable<?> items, long maxItems) {
if (maxItems <= 0L) {
return storeInput(resultId, items);
}
Iterable<?> bounded = () -> new Iterator<Object>() {
private final Iterator<?> delegate = items.iterator();
private long count;
@Override
public boolean hasNext() {
return delegate.hasNext();
}
@Override
public Object next() {
if (count >= maxItems) {
throw new ExecutionBudgetExceededException(
"Loop iteration budget exceeded while storing input "
+ resultId
+ ": more than "
+ maxItems);
}
count++;
return delegate.next();
}
};
return storeInput(resultId, bounded);
}
/**
* 在实例锁和触发器认领均有效时流式保存循环输入。
*
* @param instanceId 工作流实例 ID
* @param lockFencingToken 当前实例锁 token
* @param claimId 当前触发器 ID
* @param claimGeneration 当前触发器认领代际
* @param resultId 循环结果 ID
* @param items 原始输入
* @param maxItems 最大元素数;小于等于零表示不限制
* @return 输入元素数量
*/
default int storeInput(
String instanceId,
long lockFencingToken,
String claimId,
long claimGeneration,
String resultId,
Iterable<?> items,
long maxItems) {
return storeInput(resultId, items, maxItems);
}
/**
* 在生产者主动推送数据时流式保存循环输入。
*
* <p>缺省实现用于本地兼容仓储;分布式仓储应覆盖此方法并边接收边分块写入,
* 避免先构造完整列表。</p>
*
* @param instanceId 工作流实例 ID
* @param lockFencingToken 当前实例锁 token
* @param claimId 当前触发器 ID
* @param claimGeneration 当前触发器认领代际
* @param resultId 循环结果 ID
* @param producer 输入生产者
* @param maxItems 最大元素数;小于等于零表示不限制
* @return 输入元素数量
*/
default int storeProducedInput(
String instanceId,
long lockFencingToken,
String claimId,
long claimGeneration,
String resultId,
InputProducer producer,
long maxItems) {
List<Object> items = new java.util.ArrayList<>();
producer.produce(item -> {
if (maxItems > 0L
&& items.size() >= maxItems) {
throw new ExecutionBudgetExceededException(
"Loop iteration budget exceeded while storing input "
+ resultId
+ ": more than "
+ maxItems);
}
items.add(item);
});
return storeInput(
instanceId,
lockFencingToken,
claimId,
claimGeneration,
resultId,
items,
0L);
}
/**
* 主动向循环输入仓储推送元素的生产者。
*/
@FunctionalInterface
interface InputProducer {
/**
* 生产并按原顺序推送输入元素。
*
* @param sink 单元素接收器
*/
void produce(Consumer<Object> sink);
}
/**
* 按序号读取已保存的循环输入。
*
* @param resultId 循环结果 ID
* @param index 从零开始的序号
* @return 输入元素
*/
Object loadInputItem(String resultId, int index);
/**
* 在业务参数读取边界透明还原完整循环输入。
*
* @param reference 循环输入引用
* @return 与原输入顺序一致的列表
*/
default List<Object> loadInput(LoopInputReference reference) {
List<Object> items =
new java.util.ArrayList<>(
reference.getItemCount());
for (int index = 0;
index < reference.getItemCount();
index++) {
items.add(loadInputItem(
reference.getResultId(), index));
}
return items;
}
/**
* 清理循环输入。
*
* @param resultId 循环结果 ID
*/
default void removeInput(String resultId) {
}
/**
* 释放指定循环结果的进程内活跃缓存。
*
* <p>该操作不得删除已经持久化的输入、输出分块或改变结果引用语义,仅用于在
* 循环完成后及时归还本机缓存空间。</p>
*
* @param resultId 循环结果 ID
*/
default void releaseActiveCache(String resultId) {
}
/**
* 在实例锁和触发器认领均有效时清理循环输入。
*
* @param instanceId 工作流实例 ID
* @param lockFencingToken 当前实例锁 token
* @param claimId 当前触发器 ID
* @param claimGeneration 当前触发器认领代际
* @param resultId 循环结果 ID
*/
default void removeInput(
String instanceId,
long lockFencingToken,
String claimId,
long claimGeneration,
String resultId) {
removeInput(resultId);
}
/**
* 追加一轮循环输出。
*
* @param resultId 循环结果 ID
* @param iterationIndex 从零开始的迭代序号
* @param outputValues 本轮输出
*/
void append(String resultId, int iterationIndex, Map<String, Object> outputValues);
/**
* 在当前触发器 fencing token 仍有效时追加循环输出。
*
* @param instanceId 工作流实例 ID
* @param fencingToken 当前触发器 token非持久化执行为 {@code 0}
* @param resultId 循环结果 ID
* @param iterationIndex 迭代序号
* @param outputValues 本轮输出
*/
default void append(
String instanceId,
long fencingToken,
String resultId,
int iterationIndex,
Map<String, Object> outputValues) {
append(resultId, iterationIndex, outputValues);
}
/**
* 在实例锁和当前触发器认领租约均有效时追加循环输出。
*
* @param instanceId 工作流实例 ID
* @param lockFencingToken 当前实例锁 token本地仓储调用为 {@code 0}
* @param claimId 当前触发器 ID非持久化执行为 {@code null}
* @param claimGeneration 当前认领代际;非持久化执行为 {@code 0}
* @param resultId 循环结果 ID
* @param iterationIndex 迭代序号
* @param outputValues 本轮输出
*/
default void append(
String instanceId,
long lockFencingToken,
String claimId,
long claimGeneration,
String resultId,
int iterationIndex,
Map<String, Object> outputValues) {
append(instanceId, lockFencingToken, resultId, iterationIndex, outputValues);
}
/**
* 加载完整循环累计结果。
*
* @param resultId 循环结果 ID
* @param iterationCount 已累计的迭代数
* @param outputNames 输出名称,顺序与工作流定义一致
* @return 按输出名称聚合的结果列表
*/
Map<String, Object> load(String resultId, int iterationCount, List<String> outputNames);
/**
* 为每个循环输出创建轻量引用。
*
* @param resultId 循环结果 ID
* @param iterationCount 已累计迭代数
* @param outputNames 输出名称
* @return 输出名称到轻量引用的映射
*/
default Map<String, Object> references(
String resultId, int iterationCount, List<String> outputNames) {
return references(
resultId,
iterationCount,
outputNames,
java.util.Collections.emptySet());
}
/**
* 为每个循环输出创建带聚合策略的轻量引用。
*
* @param resultId 循环结果 ID
* @param iterationCount 已累计迭代数
* @param outputNames 输出名称
* @param flattenedOutputNames 启用一层扁平聚合的输出名称
* @return 输出名称到轻量引用的映射
*/
default Map<String, Object> references(
String resultId,
int iterationCount,
List<String> outputNames,
Set<String> flattenedOutputNames) {
Map<String, Object> references = new LinkedHashMap<>();
if (outputNames != null) {
for (String outputName : outputNames) {
references.put(
outputName,
new LoopResultReference(
resultId,
iterationCount,
outputName,
flattenedOutputNames != null
&& flattenedOutputNames.contains(outputName)));
}
}
return references;
}
/**
* 解析单个循环输出引用。
*
* @param reference 循环输出引用
* @return 未启用时返回按轮次累计的列表,启用时返回只扁平一层的数组
*/
default Object resolve(LoopResultReference reference) {
Object output = load(
reference.getResultId(),
reference.getIterationCount(),
List.of(reference.getOutputName()))
.get(reference.getOutputName());
return ReferenceResolver.applyAggregation(reference, output);
}
/**
* 递归解析业务输出中的循环引用,供参数读取和 API 边界透明还原。
*
* @param value 待解析值
* @return 不包含循环引用的业务值
*/
default Object resolveReferences(Object value) {
return ReferenceResolver.resolve(this, value);
}
/**
* 单次递归解析中的批量读取器。
*/
final class ReferenceResolver {
private ReferenceResolver() {
}
/**
* 收集同一循环结果的全部输出名称,并按结果组批量加载一次。
*
* @param repository 循环结果仓储
* @param value 待解析值
* @return 已透明还原的值
*/
static Object resolve(LoopResultRepository repository, Object value) {
Map<GroupKey, java.util.LinkedHashSet<String>> outputNames =
new LinkedHashMap<>();
java.util.LinkedHashMap<String, LoopInputReference> inputReferences =
new java.util.LinkedHashMap<>();
collect(value, outputNames, inputReferences);
Map<GroupKey, Map<String, Object>> loaded = new LinkedHashMap<>();
outputNames.forEach((key, names) -> loaded.put(
key,
repository.load(
key.resultId,
key.iterationCount,
new java.util.ArrayList<>(names))));
Map<String, List<Object>> loadedInputs =
new LinkedHashMap<>();
inputReferences.forEach((resultId, reference) ->
loadedInputs.put(
resultId,
repository.loadInput(reference)));
return replace(value, loaded, loadedInputs);
}
/**
* 递归收集循环结果引用。
*
* @param value 当前值
* @param outputNames 分组后的输出名称
*/
private static void collect(
Object value,
Map<GroupKey, java.util.LinkedHashSet<String>> outputNames,
Map<String, LoopInputReference> inputReferences) {
if (value instanceof LoopResultReference) {
LoopResultReference reference = (LoopResultReference) value;
GroupKey key = new GroupKey(
reference.getResultId(), reference.getIterationCount());
outputNames.computeIfAbsent(
key, ignored -> new java.util.LinkedHashSet<>())
.add(reference.getOutputName());
return;
}
if (value instanceof LoopInputReference) {
LoopInputReference reference =
(LoopInputReference) value;
inputReferences.putIfAbsent(
reference.getResultId(), reference);
return;
}
if (value instanceof Map<?, ?>) {
((Map<?, ?>) value).values().forEach(
item -> collect(
item, outputNames, inputReferences));
return;
}
if (value instanceof List<?>) {
((List<?>) value).forEach(item -> collect(
item, outputNames, inputReferences));
}
}
/**
* 使用已批量加载的结果递归替换引用。
*
* @param value 当前值
* @param loaded 已加载结果
* @return 替换后的值
*/
private static Object replace(
Object value,
Map<GroupKey, Map<String, Object>> loaded,
Map<String, List<Object>> loadedInputs) {
if (value instanceof LoopResultReference) {
LoopResultReference reference = (LoopResultReference) value;
Map<String, Object> outputs = loaded.get(new GroupKey(
reference.getResultId(), reference.getIterationCount()));
Object output = outputs == null
? null
: outputs.get(reference.getOutputName());
return applyAggregation(reference, output);
}
if (value instanceof LoopInputReference) {
return loadedInputs.get(
((LoopInputReference) value)
.getResultId());
}
if (value instanceof Map<?, ?>) {
Map<Object, Object> resolved = new LinkedHashMap<>();
((Map<?, ?>) value).forEach(
(key, item) -> resolved.put(
key,
replace(
item,
loaded,
loadedInputs)));
return resolved;
}
if (value instanceof List<?>) {
List<?> list = (List<?>) value;
java.util.ArrayList<Object> resolved =
new java.util.ArrayList<>(list.size());
for (Object item : list) {
resolved.add(replace(
item, loaded, loadedInputs));
}
return resolved;
}
return value;
}
/**
* 根据循环结果引用对累计输出执行一次线性聚合。
*
* @param reference 循环结果引用
* @param output 按轮次累计的原始输出
* @return 原始输出或只扁平一层后的数组
* @throws IllegalStateException 启用扁平聚合但某轮值不是数组
*/
private static Object applyAggregation(
LoopResultReference reference, Object output) {
if (!reference.isFlattenAggregation()) {
return output;
}
if (!(output instanceof List<?>)) {
throw invalidFlattenValue(reference, -1, output);
}
List<?> iterationValues = (List<?>) output;
int flattenedSize = 0;
int iterationIndex = 0;
// 使用顺序迭代兼容链表,避免按索引读取退化为 O(n²)。
for (Object iterationValue : iterationValues) {
int currentSize;
if (iterationValue instanceof List<?>) {
currentSize = ((List<?>) iterationValue).size();
} else if (iterationValue != null
&& iterationValue.getClass().isArray()) {
currentSize = java.lang.reflect.Array.getLength(iterationValue);
} else {
throw invalidFlattenValue(
reference, iterationIndex, iterationValue);
}
try {
flattenedSize = Math.addExact(flattenedSize, currentSize);
} catch (ArithmeticException exception) {
throw new IllegalStateException(
"Loop output '" + reference.getOutputName()
+ "' is too large to flatten",
exception);
}
iterationIndex++;
}
// 先统计容量再顺序追加,避免大数组扩容复制,整体复杂度保持 O(n)。
java.util.ArrayList<Object> flattened =
new java.util.ArrayList<>(flattenedSize);
for (Object iterationValue : iterationValues) {
if (iterationValue instanceof List<?>) {
flattened.addAll((List<?>) iterationValue);
continue;
}
int length = java.lang.reflect.Array.getLength(iterationValue);
for (int index = 0; index < length; index++) {
flattened.add(
java.lang.reflect.Array.get(iterationValue, index));
}
}
return flattened;
}
/**
* 构造扁平聚合类型错误。
*
* @param reference 循环结果引用
* @param iterationIndex 错误轮次;负数表示累计输出结构错误
* @param value 实际值
* @return 类型错误
*/
private static IllegalStateException invalidFlattenValue(
LoopResultReference reference,
int iterationIndex,
Object value) {
String actualType = value == null
? "null"
: value.getClass().getName();
String iteration = iterationIndex < 0
? ""
: ", iteration " + iterationIndex;
return new IllegalStateException(
"Loop output '" + reference.getOutputName()
+ "' requires an array for flatten aggregation"
+ iteration
+ ", but got " + actualType);
}
/**
* 循环结果批量读取分组键。
*/
private static final class GroupKey {
private final String resultId;
private final int iterationCount;
private GroupKey(String resultId, int iterationCount) {
this.resultId = resultId;
this.iterationCount = iterationCount;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof GroupKey)) {
return false;
}
GroupKey that = (GroupKey) other;
return iterationCount == that.iterationCount
&& java.util.Objects.equals(resultId, that.resultId);
}
@Override
public int hashCode() {
return java.util.Objects.hash(resultId, iterationCount);
}
}
}
}

View File

@@ -27,5 +27,7 @@ public enum NodeStateField {
SUSPEND_NODE_IDS,
SUSPEND_FOR_PARAMETERS,
EXECUTE_RESULT,
RETRY_COUNT, EXECUTE_COUNT, EXECUTE_EDGE_IDS, LOOP_COUNT, TRIGGER_COUNT, TRIGGER_EDGE_IDS, ENVIRONMENT
RETRY_COUNT, EXECUTE_COUNT, EXECUTE_EDGE_IDS, EXECUTION_ATTEMPT_KEY,
LOOP_COUNT, TRIGGER_COUNT, TRIGGER_EDGE_IDS, ENVIRONMENT,
VERSION
}

View File

@@ -21,7 +21,123 @@ import java.util.EnumSet;
public interface NodeStateRepository {
/**
* 加载已存在的节点状态。
*
* @param instanceId 工作流实例 ID
* @param nodeId 节点 ID
* @return 节点状态;纯读取实现可在状态缺失时返回 {@code null},兼容实现可惰性创建
*/
NodeState load(String instanceId, String nodeId);
/**
* 显式创建节点状态。
*
* <p>缺省实现兼容旧仓储中由 {@link #load(String, String)} 完成首次创建的行为。
* 支持持久化或分布式执行的实现应覆盖本方法并原子创建状态。</p>
*
* @param instanceId 工作流实例 ID
* @param nodeId 节点 ID
* @param chainStateVersion 创建时关联的工作流状态版本
* @return 已存在或新创建的节点状态
*/
default NodeState create(String instanceId, String nodeId, long chainStateVersion) {
NodeState existing = load(instanceId, nodeId);
if (existing != null) {
return existing;
}
NodeState created = new NodeState();
created.setChainInstanceId(instanceId);
created.setNodeId(nodeId);
if (tryUpdate(
created,
EnumSet.noneOf(NodeStateField.class),
chainStateVersion)) {
return created;
}
return load(instanceId, nodeId);
}
/**
* 在当前触发器 fencing token 仍有效时显式创建节点状态。
*
* @param instanceId 工作流实例 ID
* @param nodeId 节点 ID
* @param chainStateVersion 工作流状态版本
* @param fencingToken 当前触发器 token非触发器调用为 {@code 0}
* @return 已存在或新创建的节点状态
*/
default NodeState create(
String instanceId, String nodeId, long chainStateVersion, long fencingToken) {
return create(instanceId, nodeId, chainStateVersion);
}
/**
* 在实例锁和当前触发器认领租约均有效时显式创建节点状态。
*
* @param instanceId 工作流实例 ID
* @param nodeId 节点 ID
* @param chainStateVersion 工作流状态版本
* @param lockFencingToken 当前实例锁 token本地仓储调用为 {@code 0}
* @param claimId 当前触发器 ID非触发器调用为 {@code null}
* @param claimGeneration 当前认领代际;非触发器调用为 {@code 0}
* @return 已存在或新创建的节点状态
*/
default NodeState create(
String instanceId,
String nodeId,
long chainStateVersion,
long lockFencingToken,
String claimId,
long claimGeneration) {
return create(instanceId, nodeId, chainStateVersion, lockFencingToken);
}
/**
* 按版本尝试提交节点状态。
*
* @param newState 待提交的新状态
* @param fields 本次变更字段
* @param chainStateVersion 本次提交依赖的工作流状态版本
* @return 提交成功时为 {@code true},版本冲突时为 {@code false}
*/
boolean tryUpdate(NodeState newState, EnumSet<NodeStateField> fields, long chainStateVersion);
/**
* 在工作流版本和 fencing token 同时有效时提交节点状态。
*
* @param newState 待提交节点状态
* @param fields 变化字段
* @param chainStateVersion 工作流状态版本
* @param fencingToken 当前触发器 token非触发器调用为 {@code 0}
* @return 提交成功时为 {@code true}
*/
default boolean tryUpdate(
NodeState newState,
EnumSet<NodeStateField> fields,
long chainStateVersion,
long fencingToken) {
return tryUpdate(newState, fields, chainStateVersion);
}
/**
* 在工作流版本、实例锁和当前触发器认领租约同时有效时提交节点状态。
*
* @param newState 待提交节点状态
* @param fields 变化字段
* @param chainStateVersion 工作流状态版本
* @param lockFencingToken 当前实例锁 token本地仓储调用为 {@code 0}
* @param claimId 当前触发器 ID非触发器调用为 {@code null}
* @param claimGeneration 当前认领代际;非触发器调用为 {@code 0}
* @return 提交成功时为 {@code true}
*/
default boolean tryUpdate(
NodeState newState,
EnumSet<NodeStateField> fields,
long chainStateVersion,
long lockFencingToken,
String claimId,
long claimGeneration) {
return tryUpdate(newState, fields, chainStateVersion, lockFencingToken);
}
}

View File

@@ -0,0 +1,188 @@
package com.easyagents.flow.core.chain.runtime;
import java.io.Serializable;
/**
* 工作流执行的全局资源保护预算。
* <p>
* 所有默认值均为宽松的失控保护值。小于等于 {@code 0} 的配置表示关闭对应保护,
* 节点自身的循环次数、退出条件和重试配置仍按原有语义优先生效。
*/
public final class ExecutionBudget implements Serializable {
private static final long serialVersionUID = 1L;
public static final long DEFAULT_MAX_ITERATIONS = 100_000L;
/**
* 缺省不限制墙钟时长,避免人工确认或长期挂起时间被误计为执行耗时。
*/
public static final long DEFAULT_MAX_DURATION_MILLIS = 0L;
public static final long DEFAULT_MAX_CHILD_EXECUTIONS = 1_000_000L;
public static final long DEFAULT_MAX_ACCUMULATED_BYTES = 512L * 1024L * 1024L;
public static final int DEFAULT_MAX_NESTED_DEPTH = 32;
/**
* 热状态硬限制缺省关闭。循环历史已通过引用隔离,开启限制时由部署方按实际负载设置,
* 避免估算误差改变既有业务语义。
*/
public static final long DEFAULT_MAX_HOT_STATE_BYTES = 0L;
private final long maxIterations;
private final long maxDurationMillis;
private final long maxChildExecutions;
private final long maxAccumulatedBytes;
private final int maxNestedDepth;
private final long maxHotStateBytes;
/**
* 创建执行预算。
*
* @param maxIterations 单循环最大迭代次数
* @param maxDurationMillis 单实例最大运行毫秒数
* @param maxChildExecutions 单实例最大节点执行次数
* @param maxAccumulatedBytes 单循环最大累计结果字节数
* @param maxNestedDepth 最大循环嵌套深度
* @param maxHotStateBytes 单实例热状态建议最大字节数
*/
public ExecutionBudget(long maxIterations,
long maxDurationMillis,
long maxChildExecutions,
long maxAccumulatedBytes,
int maxNestedDepth,
long maxHotStateBytes) {
this.maxIterations = maxIterations;
this.maxDurationMillis = maxDurationMillis;
this.maxChildExecutions = maxChildExecutions;
this.maxAccumulatedBytes = maxAccumulatedBytes;
this.maxNestedDepth = maxNestedDepth;
this.maxHotStateBytes = maxHotStateBytes;
}
/**
* 创建使用宽松缺省值的执行预算。
*
* @return 默认执行预算
*/
public static ExecutionBudget defaults() {
return new ExecutionBudget(
DEFAULT_MAX_ITERATIONS,
DEFAULT_MAX_DURATION_MILLIS,
DEFAULT_MAX_CHILD_EXECUTIONS,
DEFAULT_MAX_ACCUMULATED_BYTES,
DEFAULT_MAX_NESTED_DEPTH,
DEFAULT_MAX_HOT_STATE_BYTES);
}
public long getMaxIterations() {
return maxIterations;
}
public long getMaxDurationMillis() {
return maxDurationMillis;
}
public long getMaxChildExecutions() {
return maxChildExecutions;
}
public long getMaxAccumulatedBytes() {
return maxAccumulatedBytes;
}
public int getMaxNestedDepth() {
return maxNestedDepth;
}
public long getMaxHotStateBytes() {
return maxHotStateBytes;
}
/**
* 校验循环迭代总数。
*
* @param nodeId 循环节点 ID
* @param iterations 计划迭代次数
* @throws ExecutionBudgetExceededException 超过启用的迭代预算时抛出
*/
public void checkIterations(String nodeId, long iterations) {
if (maxIterations > 0 && iterations > maxIterations) {
throw new ExecutionBudgetExceededException(
"Loop iteration budget exceeded for node " + nodeId
+ ": " + iterations + " > " + maxIterations);
}
}
/**
* 校验循环嵌套深度。
*
* @param nodeId 节点 ID
* @param depth 当前深度
* @throws ExecutionBudgetExceededException 超过启用的深度预算时抛出
*/
public void checkNestedDepth(String nodeId, int depth) {
if (maxNestedDepth > 0 && depth > maxNestedDepth) {
throw new ExecutionBudgetExceededException(
"Loop nested depth budget exceeded for node " + nodeId
+ ": " + depth + " > " + maxNestedDepth);
}
}
/**
* 校验循环累计结果大小。
*
* @param nodeId 循环节点 ID
* @param accumulatedBytes 当前累计估算字节数
* @throws ExecutionBudgetExceededException 超过启用的累计结果预算时抛出
*/
public void checkAccumulatedBytes(String nodeId, long accumulatedBytes) {
if (maxAccumulatedBytes > 0 && accumulatedBytes > maxAccumulatedBytes) {
throw new ExecutionBudgetExceededException(
"Loop accumulated result budget exceeded for node " + nodeId
+ ": " + accumulatedBytes + " > " + maxAccumulatedBytes);
}
}
/**
* 校验单实例节点执行次数。
*
* @param executions 当前节点执行次数
* @throws ExecutionBudgetExceededException 超过启用的执行预算时抛出
*/
public void checkChildExecutions(long executions) {
if (maxChildExecutions > 0 && executions > maxChildExecutions) {
throw new ExecutionBudgetExceededException(
"Workflow child execution budget exceeded: "
+ executions + " > " + maxChildExecutions);
}
}
/**
* 校验单实例运行时长。
*
* @param startedAtMillis 实例开始时间
* @param nowMillis 当前时间
* @throws ExecutionBudgetExceededException 超过启用的时长预算时抛出
*/
public void checkDuration(long startedAtMillis, long nowMillis) {
if (maxDurationMillis > 0
&& startedAtMillis > 0
&& nowMillis - startedAtMillis > maxDurationMillis) {
throw new ExecutionBudgetExceededException(
"Workflow duration budget exceeded: "
+ (nowMillis - startedAtMillis) + "ms > " + maxDurationMillis + "ms");
}
}
/**
* 校验工作流热状态估算大小。
*
* @param estimatedBytes 当前热状态估算字节数
* @throws ExecutionBudgetExceededException 超过启用的热状态预算时抛出
*/
public void checkHotStateBytes(long estimatedBytes) {
if (maxHotStateBytes > 0 && estimatedBytes > maxHotStateBytes) {
throw new ExecutionBudgetExceededException(
"Workflow hot state budget exceeded: "
+ estimatedBytes + " > " + maxHotStateBytes);
}
}
}

View File

@@ -0,0 +1,18 @@
package com.easyagents.flow.core.chain.runtime;
import com.easyagents.flow.core.chain.ChainException;
/**
* 工作流实例超过平台资源保护预算时抛出的异常。
*/
public class ExecutionBudgetExceededException extends ChainException {
/**
* 创建预算超限异常。
*
* @param message 可审计的超限原因
*/
public ExecutionBudgetExceededException(String message) {
super(message);
}
}

View File

@@ -17,13 +17,16 @@ package com.easyagents.flow.core.chain.runtime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
public class InMemoryTriggerStore implements TriggerStore {
private final ConcurrentHashMap<String, Trigger> store = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, AtomicLong> fencingTokens = new ConcurrentHashMap<>();
@Override
public Trigger save(Trigger trigger) {
@@ -34,6 +37,18 @@ public class InMemoryTriggerStore implements TriggerStore {
return trigger;
}
/**
* {@inheritDoc}
*/
@Override
public boolean saveIfAbsent(Trigger trigger) {
if (trigger.getId() == null || trigger.getId().isBlank()) {
throw new IllegalArgumentException("Stable trigger ID required");
}
return store.putIfAbsent(
trigger.getId(), trigger) == null;
}
@Override
public boolean remove(String triggerId) {
return store.remove(triggerId) != null;
@@ -46,12 +61,35 @@ public class InMemoryTriggerStore implements TriggerStore {
@Override
public List<Trigger> findDue(long uptoTimestamp) {
return null;
List<Trigger> due = new ArrayList<>();
for (Trigger trigger : store.values()) {
if (trigger.getTriggerAt() <= uptoTimestamp) {
due.add(trigger);
}
}
due.sort(Comparator.comparingLong(Trigger::getTriggerAt));
return due;
}
@Override
public List<Trigger> findAllPending() {
return new ArrayList<>(store.values());
}
}
/**
* {@inheritDoc}
*/
@Override
public Trigger claim(String triggerId, long leaseMillis) {
Trigger trigger = store.remove(triggerId);
if (trigger != null) {
String fencingScope = trigger.getStateInstanceId() == null
? "__trigger__:" + trigger.getId()
: trigger.getStateInstanceId();
trigger.setFencingToken(fencingTokens
.computeIfAbsent(fencingScope, ignored -> new AtomicLong())
.incrementAndGet());
}
return trigger;
}
}

View File

@@ -0,0 +1,16 @@
package com.easyagents.flow.core.chain.runtime;
/**
* 表示触发器内容已经无法继续执行,应进入死信而非无限重放。
*/
public class NonRetryableTriggerException extends RuntimeException {
/**
* 创建不可重试触发器异常。
*
* @param message 异常说明
*/
public NonRetryableTriggerException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,17 @@
package com.easyagents.flow.core.chain.runtime;
/**
* 表示当前节点遇到短暂基础设施冲突,应重新投递同一触发器且不消耗业务重试次数。
*/
public class RetryableTriggerException extends RuntimeException {
/**
* 创建可重新投递异常。
*
* @param message 异常说明
* @param cause 原始异常
*/
public RetryableTriggerException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -16,14 +16,65 @@
package com.easyagents.flow.core.chain.runtime;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;
public class Trigger implements Serializable {
private static final long serialVersionUID = 3165037658498721088L;
private String id;
private String stateInstanceId;
private String edgeId;
private String nodeId; // 可以为 null代表触发整个 chain
private TriggerType type;
private long triggerAt; // epoch ms
/**
* 当前运行时分配的触发器认领代际。
*
* <p>字段名为兼容既有序列化数据保留。分布式仓储在触发器认领成功时分配,
* 并与该触发器租约共同续期和失效;该值不代表实例锁 fencing token。</p>
*/
private long fencingToken;
/**
* 创建派生触发器时必须仍然有效的父触发器 fencing token。
*/
private long requiredFencingToken;
/**
* 创建派生触发器时必须仍然有效的父实例锁 fencing token。
*/
private long requiredLockFencingToken;
/**
* 创建派生触发器时必须仍然有效的父触发器 claim ID。
*/
private String requiredFencingClaimId;
/**
* 基础设施投递失败次数,不占用业务节点重试次数。
*/
private int deliveryAttempt;
/**
* 已完成业务终态收敛、等待可靠写入死信的标记。
*/
private boolean deadLetterPending;
/**
* 待写入死信的稳定失败原因。
*/
private String deadLetterReason;
/**
* 跨重试保持不变的逻辑执行 ID用于副作用幂等键。
*/
private String logicalExecutionId;
/**
* 可选执行通道,用于把会同步等待的子工作流与普通节点工作线程隔离。
*/
private String executionLane;
/**
* 首个稳定入口意图携带的初始变量。
*
* <p>仅用于实例仍处于 READY 时的崩溃恢复;正常启动提交后,运行时变量仍以
* {@code ChainState.memory} 为唯一业务数据源。</p>
*/
private Map<String, Object> startVariables;
private Map<String, LoopCursor> loopCursors;
public Trigger() {
}
@@ -77,6 +128,260 @@ public class Trigger implements Serializable {
this.triggerAt = triggerAt;
}
/**
* 获取当前节点逻辑执行 ID。
*
* @return 跨重试保持不变的逻辑执行 ID
*/
public String getLogicalExecutionId() {
return logicalExecutionId;
}
/**
* 设置当前节点逻辑执行 ID。
*
* @param logicalExecutionId 跨重试保持不变的逻辑执行 ID
*/
public void setLogicalExecutionId(String logicalExecutionId) {
this.logicalExecutionId = logicalExecutionId;
}
/**
* 获取执行通道。
*
* @return 通道名;{@code null} 表示默认通道
*/
public String getExecutionLane() {
return executionLane;
}
/**
* 设置执行通道。
*
* @param executionLane 通道名
*/
public void setExecutionLane(String executionLane) {
this.executionLane = executionLane;
}
/**
* 获取崩溃恢复所需的初始变量。
*
* @return 初始变量快照;未携带时为 {@code null}
*/
public Map<String, Object> getStartVariables() {
return startVariables;
}
/**
* 设置崩溃恢复所需的初始变量。
*
* @param startVariables 初始变量;仅首个稳定入口意图需要携带
*/
public void setStartVariables(Map<String, Object> startVariables) {
this.startVariables = startVariables == null
? null
: new LinkedHashMap<>(startVariables);
}
/**
* 获取本次认领代际。
*
* @return 单触发器认领代际;未认领时为 {@code 0}
*/
public long getFencingToken() {
return fencingToken;
}
/**
* 设置本次认领代际。
*
* @param fencingToken 单触发器认领代际
*/
public void setFencingToken(long fencingToken) {
this.fencingToken = fencingToken;
}
/**
* 获取保存派生触发器所依赖的父实例锁 fencing token。
*
* @return 父实例锁 token无锁约束时为 {@code 0}
*/
public long getRequiredLockFencingToken() {
return requiredLockFencingToken;
}
/**
* 设置保存派生触发器所依赖的父实例锁 fencing token。
*
* @param requiredLockFencingToken 父实例锁 token
*/
public void setRequiredLockFencingToken(long requiredLockFencingToken) {
this.requiredLockFencingToken = requiredLockFencingToken;
}
/**
* 获取保存派生触发器所依赖的父 fencing token。
*
* @return 父 fencing token无父认领约束时为 {@code 0}
*/
public long getRequiredFencingToken() {
return requiredFencingToken;
}
/**
* 设置保存派生触发器所依赖的父 fencing token。
*
* @param requiredFencingToken 父 fencing token
*/
public void setRequiredFencingToken(long requiredFencingToken) {
this.requiredFencingToken = requiredFencingToken;
}
/**
* 获取保存派生触发器所依赖的父 claim ID。
*
* @return 父触发器 ID无父认领约束时为 {@code null}
*/
public String getRequiredFencingClaimId() {
return requiredFencingClaimId;
}
/**
* 设置保存派生触发器所依赖的父 claim ID。
*
* @param requiredFencingClaimId 父触发器 ID
*/
public void setRequiredFencingClaimId(String requiredFencingClaimId) {
this.requiredFencingClaimId = requiredFencingClaimId;
}
/**
* 获取基础设施投递失败次数。
*
* @return 失败次数
*/
public int getDeliveryAttempt() {
return deliveryAttempt;
}
/**
* 设置基础设施投递失败次数。
*
* @param deliveryAttempt 失败次数
*/
public void setDeliveryAttempt(int deliveryAttempt) {
this.deliveryAttempt = deliveryAttempt;
}
/**
* 判断触发器是否正在补写死信终态。
*
* @return 等待死信持久化时为 {@code true}
*/
public boolean isDeadLetterPending() {
return deadLetterPending;
}
/**
* 设置死信补写标记。
*
* @param deadLetterPending 是否等待死信持久化
*/
public void setDeadLetterPending(boolean deadLetterPending) {
this.deadLetterPending = deadLetterPending;
}
/**
* 获取稳定死信原因。
*
* @return 死信原因
*/
public String getDeadLetterReason() {
return deadLetterReason;
}
/**
* 设置稳定死信原因。
*
* @param deadLetterReason 死信原因
*/
public void setDeadLetterReason(String deadLetterReason) {
this.deadLetterReason = deadLetterReason;
}
/**
* 获取触发器携带的循环代际游标。
*
* @return 循环节点 ID 到游标的映射
*/
public Map<String, LoopCursor> getLoopCursors() {
if (loopCursors == null) {
loopCursors = new LinkedHashMap<>();
}
return loopCursors;
}
/**
* 设置循环代际游标。
*
* @param loopCursors 循环节点 ID 到游标的映射
*/
public void setLoopCursors(Map<String, LoopCursor> loopCursors) {
this.loopCursors = loopCursors;
}
/**
* 循环分支代际游标,用于拒绝过期或重复的父节点回调。
*/
public static class LoopCursor implements Serializable {
private static final long serialVersionUID = 1L;
private String resultId;
private int iterationIndex;
private String branchId;
public LoopCursor() {
}
/**
* 创建循环游标。
*
* @param resultId 循环代际 ID
* @param iterationIndex 迭代序号
* @param branchId 直属分支 ID
*/
public LoopCursor(String resultId, int iterationIndex, String branchId) {
this.resultId = resultId;
this.iterationIndex = iterationIndex;
this.branchId = branchId;
}
public String getResultId() {
return resultId;
}
public void setResultId(String resultId) {
this.resultId = resultId;
}
public int getIterationIndex() {
return iterationIndex;
}
public void setIterationIndex(int iterationIndex) {
this.iterationIndex = iterationIndex;
}
public String getBranchId() {
return branchId;
}
public void setBranchId(String branchId) {
this.branchId = branchId;
}
}
@Override
public String toString() {
return "Trigger{" +
@@ -89,4 +394,3 @@ public class Trigger implements Serializable {
'}';
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com).
* Licensed under the GNU Lesser General Public License (LGPL), Version 3.0.
*/
package com.easyagents.flow.core.chain.runtime;
/**
* 表示当前工作线程已经失去触发器租约,不再允许提交执行结果。
*/
public class TriggerClaimLostException extends RuntimeException {
/**
* 创建租约丢失异常。
*
* @param triggerId 已失去租约的触发器 ID
*/
public TriggerClaimLostException(String triggerId) {
super("Workflow trigger claim ownership lost: " + triggerId);
}
}

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