Compare commits
25 Commits
v1.0
...
f13e24751a
| Author | SHA1 | Date | |
|---|---|---|---|
| f13e24751a | |||
| bdb69a2250 | |||
| 5fd4d845af | |||
| 15adcfff42 | |||
| f0a5aacc92 | |||
| fd3d9ad419 | |||
| 4af2d7cd34 | |||
| fcc36dc699 | |||
| e74d229de2 | |||
| 851dd1be01 | |||
| 12491b3724 | |||
| c72a167633 | |||
| a7e89cee3d | |||
| c48d9a9da6 | |||
| 6fa93bd671 | |||
| e995088d79 | |||
| 5b6b2db5d8 | |||
| fbeece2d89 | |||
| 7e59f0e638 | |||
| f057900f7a | |||
| 66da0c9039 | |||
| 9d0d148415 | |||
| 3bd346ea77 | |||
| 848197b556 | |||
| 13e848ddf4 |
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 Upgrade;HTTPS
|
||||
* 保持 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,未传入时使用供应商默认地址。
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.easyagents.agent.runtime.media;
|
||||
|
||||
/**
|
||||
* 在模型调用边界解析业务侧稳定媒体引用。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface AgentMediaResolver {
|
||||
|
||||
/**
|
||||
* 解析媒体引用。
|
||||
*
|
||||
* @param reference 业务侧稳定媒体引用
|
||||
* @return 媒体资源
|
||||
* @throws RuntimeException 引用无效、越权或资源读取失败时抛出
|
||||
*/
|
||||
AgentMediaResource resolve(String reference);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.easyagents.agent.runtime.model;
|
||||
|
||||
/**
|
||||
* Agent 模型调用使用的 HTTP 版本策略。
|
||||
*/
|
||||
public enum AgentHttpVersionPolicy {
|
||||
|
||||
/** 按基础 URL 协议自动选择:HTTP 使用 1.1,HTTPS 优先使用 2。 */
|
||||
AUTO,
|
||||
|
||||
/** 强制使用 HTTP/1.1。 */
|
||||
HTTP_1_1,
|
||||
|
||||
/** 优先使用 HTTP/2,并允许 JDK 客户端按协议能力回退。 */
|
||||
HTTP_2_PREFERRED
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.easyagents.agent.runtime.model;
|
||||
|
||||
/**
|
||||
* OpenAI-compatible 请求中消息 content 的格式策略。
|
||||
*/
|
||||
public enum AgentMessageContentFormat {
|
||||
|
||||
/** 使用 AgentScope 默认格式,纯文本为字符串,多模态内容为数组。 */
|
||||
STANDARD,
|
||||
|
||||
/** 将全部角色的 content 规范为内容块数组。 */
|
||||
TEXT_PARTS
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元数据。
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 请求用户输入
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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 == '_';
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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=="));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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() {
|
||||
|
||||
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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; // 循环总执行次数,取值范围 1~300
|
||||
|
||||
protected boolean retryEnable = false;
|
||||
protected boolean resetRetryCountAfterNormal = false;
|
||||
@@ -158,7 +163,22 @@ public abstract class Node implements Serializable {
|
||||
return maxLoopCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置节点循环的总执行次数。
|
||||
*
|
||||
* @param maxLoopCount 总执行次数,范围为 1~300
|
||||
* @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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 + '\'' +
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
|
||||
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 的分布式锁
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.easyagents.flow.core.chain.runtime;
|
||||
|
||||
/**
|
||||
* 表示触发器内容已经无法继续执行,应进入死信而非无限重放。
|
||||
*/
|
||||
public class NonRetryableTriggerException extends RuntimeException {
|
||||
|
||||
/**
|
||||
* 创建不可重试触发器异常。
|
||||
*
|
||||
* @param message 异常说明
|
||||
*/
|
||||
public NonRetryableTriggerException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user