Merge pull request '发布 v1.1.0' (#2) from develop into main

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-08-20 11:35:40 +08:00
266 changed files with 39284 additions and 1152 deletions

View File

@@ -24,6 +24,7 @@ Easy-Agents 是一个轻量、可扩展的 Java AI 应用开发框架,覆盖
- `easy-agents-search-engine`:检索引擎实现。
- `easy-agents-tool`:工具调用能力。
- `easy-agents-mcp`MCP 集成。
- `easy-agents-skill`:标准 Agent Skills 包模型、安全校验、资源存储与 ZIP 双向编解码。
- `easy-agents-flow`:流程编排核心引擎。
- `easy-agents-support`Flow 与 Easy-Agents 适配模块。
- `easy-agents-spring-boot-starter`Spring Boot 自动配置支持。
@@ -39,11 +40,17 @@ Easy-Agents 是一个轻量、可扩展的 Java AI 应用开发框架,覆盖
在项目根目录执行:
```bash
mvn -DskipTests -Dmaven.javadoc.skip=true -Dgpg.skip=true clean install
mvn -DskipTests clean install
```
构建完成后,相关构件会安装到本地 Maven 仓库,可供 `easyflow` 等项目直接依赖。
发布时启用 `release` profile生成源码包和 Javadoc 包,并调用 Maven Central 发布插件:
```bash
mvn -Prelease -DskipTests deploy
```
## 快速示例
```java

View File

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

View File

@@ -1,6 +1,7 @@
package com.easyagents.agent.runtime;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever;
import com.easyagents.agent.runtime.media.AgentMediaResolver;
import com.easyagents.agent.runtime.persistence.conversation.AgentConversationRecorder;
import com.easyagents.agent.runtime.persistence.conversation.noop.NoopAgentConversationRecorder;
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
@@ -55,6 +56,11 @@ public class AgentInitRequest {
*/
private Map<String, Object> metadata = new LinkedHashMap<>();
/**
* 媒体引用解析器,仅在模型调用前解析稳定引用。
*/
private AgentMediaResolver mediaResolver;
/**
* 获取会话ID。
*
@@ -200,4 +206,22 @@ public class AgentInitRequest {
public void setMetadata(Map<String, Object> metadata) {
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
}
/**
* 获取媒体引用解析器。
*
* @return 媒体引用解析器
*/
public AgentMediaResolver getMediaResolver() {
return mediaResolver;
}
/**
* 设置媒体引用解析器。
*
* @param mediaResolver 媒体引用解析器
*/
public void setMediaResolver(AgentMediaResolver mediaResolver) {
this.mediaResolver = mediaResolver;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,18 +3,22 @@ package com.easyagents.agent.runtime.agentscope;
import com.easyagents.agent.runtime.*;
import com.easyagents.agent.runtime.event.*;
import com.easyagents.agent.runtime.event.interceptor.AutoContextInterceptor;
import com.easyagents.agent.runtime.event.interceptor.MediaReferenceInterceptor;
import com.easyagents.agent.runtime.event.interceptor.ToolHitlInterceptor;
import com.easyagents.agent.runtime.event.observer.AgentRuntimeErrorObserver;
import com.easyagents.agent.runtime.event.observer.ReasoningLifecycleObserver;
import com.easyagents.agent.runtime.event.observer.SkillExecutionObserver;
import com.easyagents.agent.runtime.event.observer.ToolExecutionObserver;
import com.easyagents.agent.runtime.hitl.AgentPendingState;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalCoordinator;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalResolution;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRejectedException;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
import com.easyagents.agent.runtime.knowledge.citation.AgentKnowledgeCitationMatcher;
import com.easyagents.agent.runtime.knowledge.citation.HeuristicKnowledgeCitationMatcher;
import com.easyagents.agent.runtime.message.*;
import com.easyagents.agent.runtime.mcp.McpRegistration;
import com.easyagents.agent.runtime.mcp.McpSkillRegistration;
import com.easyagents.agent.runtime.mcp.McpSpecValidator;
import com.easyagents.agent.runtime.mcp.McpToolkitAdapter;
import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore;
@@ -152,6 +156,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,22 +194,32 @@ public class AgentScopeReActRuntime implements AgentRuntime {
return Flux.error(new AgentRuntimeException("Agent runtime is already streaming."));
}
AgentRuntimeExecutionContext executionContext = createResumeExecutionContext(request);
AgentToolApprovalResolution resolution = null;
try {
if (!request.isTrusted()) {
approvalCoordinator.consume(request);
if (request.isTrusted()) {
approvalCoordinator.authorizeTrustedExecution(request);
} else {
resolution = approvalCoordinator.resolve(request);
}
} catch (RuntimeException error) {
running.set(false);
throw error;
}
// 审批拒绝
if (!request.isApproved()) {
executionContext.setCancelReason(request.getRejectReason());
if (resolution != null
&& resolution.getStatus() == AgentToolApprovalResolution.Status.WAITING) {
return waitingForRemainingApprovals(executionContext, resolution);
}
if (!request.isApproved()
|| resolution != null
&& (resolution.getStatus() == AgentToolApprovalResolution.Status.REJECTED
|| resolution.getStatus() == AgentToolApprovalResolution.Status.EXPIRED)) {
String cancelReason = resolution == null ? request.getRejectReason() : resolution.getReason();
executionContext.setCancelReason(cancelReason);
return Flux.defer(() -> {
saveSession();
return Flux.just(started(executionContext), cancelled(executionContext));
}).doOnNext(event -> executionContext.getConversationRecorder().record(executionContext, event))
.doFinally(signalType -> cleanupTurn());
.doFinally(signalType -> cleanupStreamSegment(false));
}
return runAgentStreamAfterLock(executionContext, List::of);
});
@@ -222,6 +239,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
if (!running.compareAndSet(false, true)) {
return Flux.error(new AgentRuntimeException("Agent runtime is already streaming."));
}
// 新用户消息建立新的 Turn上一 Turn 的 MCP 级批准不能跨轮复用。
approvalCoordinator.clearReusableApprovalScopes();
return runAgentStreamAfterLock(executionContext, inputSupplier);
});
}
@@ -239,18 +258,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 +286,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));
@@ -284,8 +299,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
.doOnNext(event -> executionContext.getConversationRecorder().record(executionContext, event))
// 处理中断请求
.doOnCancel(() -> cancelInternal(executionContext, sideEvents, finalText, finalMessage, cancelled))
// 释放运行锁并清掉 turn context
.doFinally(signalType -> cleanupTurn());
// HITL 挂起时保留当前 Turn 的 MCP 批准,其余终态完整清理
.doFinally(signalType -> cleanupStreamSegment(suspendedEvent.get() != null));
}
/**
@@ -303,6 +318,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 +556,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 +567,46 @@ public class AgentScopeReActRuntime implements AgentRuntime {
return event;
}
/**
* 在同一审批批次仍有未决工具时返回挂起事件,并保持 AgentScope pending tools 不执行。
*
* @param context 本轮恢复上下文
* @param resolution 审批批次决议
* @return 开始与挂起事件流
*/
private Flux<AgentRuntimeEvent> waitingForRemainingApprovals(AgentRuntimeExecutionContext context,
AgentToolApprovalResolution resolution) {
AgentRuntimeEvent suspended = base(context, AgentRuntimeEventType.SUSPENDED);
suspended.getPayload().put("reason", "TOOL_APPROVAL_REQUIRED");
suspended.getPayload().put("pendingApprovals", resolution.getRemainingStates().stream()
.map(this::pendingApprovalPayload)
.toList());
suspended.getMetadata().put("source", "APPROVAL_COORDINATOR");
suspended.getMetadata().put("approvalStatus", AgentToolApprovalResolution.Status.WAITING.name());
return Flux.just(started(context), suspended)
.doOnNext(event -> context.getConversationRecorder().record(context, event))
.doFinally(signalType -> cleanupStreamSegment(true));
}
/**
* 将待审批状态转换为前端可消费的稳定字段。
*
* @param state 待审批状态
* @return 待审批载荷
*/
private Map<String, Object> pendingApprovalPayload(AgentPendingState state) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("resumeToken", state.getResumeToken().getValue());
payload.put("toolCallId", state.getToolCallId());
payload.put("toolName", state.getToolName());
payload.put("toolInput", state.getToolInput());
payload.put("input", state.getToolInput());
payload.put("approvalPrompt", state.getApprovalPrompt());
payload.put("approvalMetadata", state.getMetadata());
payload.put("expiresAt", state.getExpiresAt() == null ? null : state.getExpiresAt().toString());
return payload;
}
/**
* 生成开始事件。
*
@@ -732,9 +794,15 @@ public class AgentScopeReActRuntime implements AgentRuntime {
}
/**
* 清理本轮状态。
* 清理一次 stream/resume 片段状态。
*
* @param preserveReusableApprovalScopes 是否因 HITL 挂起而保留当前 Turn 的 MCP 批准
*/
private void cleanupTurn() {
private void cleanupStreamSegment(boolean preserveReusableApprovalScopes) {
approvalCoordinator.clearExecutionAuthorizations();
if (!preserveReusableApprovalScopes) {
approvalCoordinator.clearReusableApprovalScopes();
}
turnContextHolder.clear();
running.set(false);
}
@@ -815,26 +883,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 +1006,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 +1038,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()));
}
}
@@ -1079,7 +1131,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
AgentScopeMemoryBuildResult memoryResult = memoryAdapter.createMemoryResult(null, definition.getMemoryPolicy(), model);
Memory memory = memoryResult.getMemory();
Knowledge knowledge = knowledgeAdapter.createAggregateKnowledge(context, turnContextHolder);
SkillBox skillBox = skillAdapter.createSkillBox(definition.getSkillBoxSpec(), toolkit, skillTools);
SkillBox skillBox = skillAdapter.createSkillBox(definition.getSkillBoxSpec(), toolkit, skillTools,
toolkitBuildResult.skillMcpRegistrations());
// AutoContextInterceptor 是官方 AutoContextHook 的替代实现。这里仍只注册统一 runtime hook
// 避免官方 hook 与 Easy-Agents interceptor 同时触发压缩和 inputMessages 改写。
AgentRuntimeEventBridge eventBridge = new AgentRuntimeEventBridge(context, turnContextHolder);
@@ -1087,6 +1140,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,
@@ -1157,7 +1211,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
Toolkit toolkit) {
Map<String, List<AgentTool>> skillTools = new LinkedHashMap<>();
if (!context.getAgentDefinition().getExecutionOptions().isToolCallingEnabled()) {
return new AgentScopeToolkitBuildResult(skillTools, List.of(), List.of());
return new AgentScopeToolkitBuildResult(skillTools, List.of(), List.of(), List.of());
}
for (AgentToolSpec toolSpec : context.getAgentDefinition().getToolSpecs()) {
AgentToolInvoker invoker = context.getToolInvokers().get(toolSpec.getName());
@@ -1177,7 +1231,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
context.getAgentDefinition().getOperateToolSpecs(), toolkit);
McpSpecValidator.validateToolConflicts(context.getAgentDefinition().getToolSpecs(),
mcpRegistration.getToolSpecs(), context.getAgentDefinition().getOperateToolSpecs());
return new AgentScopeToolkitBuildResult(skillTools, mcpRegistration.getToolSpecs(), operateToolSpecs);
return new AgentScopeToolkitBuildResult(skillTools, mcpRegistration.getToolSpecs(), operateToolSpecs,
mcpRegistration.getSkillRegistrations());
}
private List<AgentToolSpec> mergeToolSpecs(List<AgentToolSpec> toolSpecs,
@@ -1245,7 +1300,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
}
private record AgentScopeToolkitBuildResult(Map<String, List<AgentTool>> skillTools,
List<AgentToolSpec> mcpToolSpecs,
List<AgentToolSpec> operateToolSpecs) {
List<AgentToolSpec> mcpToolSpecs,
List<AgentToolSpec> operateToolSpecs,
List<McpSkillRegistration> skillMcpRegistrations) {
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,8 +9,6 @@ import com.easyagents.agent.runtime.tool.AgentToolSpec;
import io.agentscope.core.hook.HookEvent;
import io.agentscope.core.hook.PostActingEvent;
import io.agentscope.core.hook.PreActingEvent;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock;
import reactor.core.publisher.Mono;
@@ -103,12 +101,7 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
runtimeEvent.getPayload().put("toolCallId", toolUse.getId());
runtimeEvent.getPayload().put("name", toolUse.getName());
runtimeEvent.getPayload().put("toolName", toolUse.getName());
runtimeEvent.getPayload().put("input", toolUse.getInput());
runtimeEvent.getPayload().put("content", toolUse.getContent());
runtimeEvent.getPayload().put("status", "RUNNING");
runtimeEvent.getPayload().put("source", "HOOK");
runtimeEvent.getPayload().put("phase", "PRE_ACTING");
runtimeEvent.getMetadata().putAll(nullToEmpty(toolUse.getMetadata()));
enrichToolPayload(runtimeEvent, toolUse.getName());
eventBridge.emit(runtimeEvent);
}
@@ -129,15 +122,8 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
runtimeEvent.getPayload().put("toolCallId", toolCallId);
runtimeEvent.getPayload().put("name", toolName);
runtimeEvent.getPayload().put("toolName", toolName);
runtimeEvent.getPayload().put("text", resultText(result));
runtimeEvent.getPayload().put("suspended", result != null && result.isSuspended());
runtimeEvent.getPayload().put("status", success(result) ? "SUCCESS" : "FAILED");
runtimeEvent.getPayload().put("success", success(result));
runtimeEvent.getPayload().put("source", "HOOK");
runtimeEvent.getPayload().put("phase", "POST_ACTING");
if (result != null) {
runtimeEvent.getMetadata().putAll(nullToEmpty(result.getMetadata()));
}
enrichToolPayload(runtimeEvent, toolName);
eventBridge.emit(runtimeEvent);
}
@@ -149,12 +135,7 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
}
Map<String, Object> metadata = toolSpec.getMetadata();
putIfPresent(runtimeEvent.getPayload(), metadata, "toolDisplayName");
putIfPresent(runtimeEvent.getPayload(), metadata, "rawMcpToolName");
putIfPresent(runtimeEvent.getPayload(), metadata, "mcpToolName");
putIfPresent(runtimeEvent.getPayload(), metadata, "mcpName");
putIfPresent(runtimeEvent.getPayload(), metadata, "mcpTitle");
putIfPresent(runtimeEvent.getPayload(), metadata, "source");
runtimeEvent.getMetadata().putAll(metadata);
putIfPresent(runtimeEvent.getPayload(), metadata, "skillId");
}
private void putIfPresent(Map<String, Object> payload, Map<String, Object> metadata, String key) {
@@ -171,25 +152,6 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
return !(success instanceof Boolean) || Boolean.TRUE.equals(success);
}
private String resultText(ToolResultBlock result) {
if (result == null || result.getOutput() == null || result.getOutput().isEmpty()) {
return "";
}
StringBuilder builder = new StringBuilder();
for (ContentBlock block : result.getOutput()) {
if (block instanceof TextBlock textBlock) {
builder.append(textBlock.getText());
} else {
builder.append(block);
}
}
return builder.toString();
}
private Map<String, Object> nullToEmpty(Map<String, Object> map) {
return map == null ? new LinkedHashMap<>() : map;
}
private boolean isSkillTool(String toolName) {
if (skillContext == null) {
return false;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,390 @@
package com.easyagents.agent.runtime.hitl;
import com.easyagents.agent.runtime.AgentResumeRequest;
import com.easyagents.agent.runtime.AgentRuntimeException;
import org.junit.Assert;
import org.junit.Test;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
/**
* 测试工具审批协调器。
*/
public class AgentToolApprovalCoordinatorTest {
/**
* 验证同批次全部调用批准后才签发逐调用执行授权。
*/
@Test
public void shouldAuthorizeBatchOnlyAfterAllCallsApproved() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
AgentPendingState first = register(coordinator, "call-1", "search", Map.of("q", "first"), "batch-1");
AgentPendingState second = register(coordinator, "call-2", "search", Map.of("q", "second"), "batch-1");
AgentToolApprovalResolution firstResolution = coordinator.resolve(resume(first, true));
Assert.assertEquals(AgentToolApprovalResolution.Status.WAITING, firstResolution.getStatus());
Assert.assertEquals(1, firstResolution.getRemainingStates().size());
assertAuthorizationRejected(coordinator, "call-1", "search", Map.of("q", "first"));
AgentToolApprovalResolution secondResolution = coordinator.resolve(resume(second, true));
Assert.assertEquals(AgentToolApprovalResolution.Status.READY, secondResolution.getStatus());
coordinator.consumeExecutionAuthorization("call-1", "search", Map.of("q", "first"));
coordinator.consumeExecutionAuthorization("call-2", "search", Map.of("q", "second"));
assertAuthorizationRejected(coordinator, "call-1", "search", Map.of("q", "first"));
}
/**
* 验证拒绝一个调用会关闭整个审批批次。
*/
@Test
public void shouldRejectWholeBatchWhenAnyCallRejected() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
AgentPendingState first = register(coordinator, "call-1", "search", Map.of("q", "first"), "batch-1");
AgentPendingState second = register(coordinator, "call-2", "search", Map.of("q", "second"), "batch-1");
coordinator.resolve(resume(first, true));
AgentResumeRequest rejection = resume(second, false);
rejection.setRejectReason("not allowed");
AgentToolApprovalResolution resolution = coordinator.resolve(rejection);
Assert.assertEquals(AgentToolApprovalResolution.Status.REJECTED, resolution.getStatus());
Assert.assertEquals("not allowed", resolution.getReason());
assertAuthorizationRejected(coordinator, "call-1", "search", Map.of("q", "first"));
try {
coordinator.resolve(resume(first, true));
Assert.fail("已消费的审批令牌不能重复使用");
} catch (AgentRuntimeException expected) {
Assert.assertTrue(expected.getMessage().contains("invalid"));
}
}
/**
* 验证过期令牌不能签发工具执行授权。
*/
@Test
public void shouldExpireApprovalBeforeResolution() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
AgentPendingState expired = coordinator.register(
"session-1",
"agent-1",
"call-expired",
"search",
"approve",
Map.of("q", "expired"),
Map.of(),
Instant.now().minusSeconds(1),
"batch-expired");
AgentToolApprovalResolution resolution = coordinator.resolve(resume(expired, true));
Assert.assertEquals(AgentToolApprovalResolution.Status.EXPIRED, resolution.getStatus());
assertAuthorizationRejected(coordinator, "call-expired", "search", Map.of("q", "expired"));
}
/**
* 验证并发重复点击同一令牌时最多一个请求可以成功消费。
*
* @throws Exception 并发任务执行失败时抛出
*/
@Test
public void shouldConsumeConcurrentDuplicateApprovalOnlyOnce() throws Exception {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
AgentPendingState pending = register(
coordinator, "call-1", "search", Map.of("q", "easyflow"), "batch-1");
CountDownLatch start = new CountDownLatch(1);
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<Boolean> first = executor.submit(() -> resolveAfter(start, coordinator, pending));
Future<Boolean> second = executor.submit(() -> resolveAfter(start, coordinator, pending));
start.countDown();
int successCount = (first.get() ? 1 : 0) + (second.get() ? 1 : 0);
Assert.assertEquals(1, successCount);
coordinator.consumeExecutionAuthorization(
"call-1", "search", Map.of("q", "easyflow"));
assertAuthorizationRejected(
coordinator, "call-1", "search", Map.of("q", "easyflow"));
} finally {
executor.shutdownNow();
}
}
/**
* 验证工具名称或入参变化时批准凭证立即失效。
*/
@Test
public void shouldRejectExecutionWhenApprovedCallIsModified() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
AgentPendingState pending = register(
coordinator, "call-1", "search", Map.of("q", "easyflow"), "batch-1");
coordinator.resolve(resume(pending, true));
assertAuthorizationRejected(
coordinator, "call-1", "search", Map.of("q", "modified"));
assertAuthorizationRejected(
coordinator, "call-1", "search", Map.of("q", "easyflow"));
}
/**
* 验证跨节点受信任恢复仍需绑定明确的工具调用信息。
*/
@Test
public void shouldAuthorizeTrustedExecutionByToolCallIdentity() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
AgentResumeRequest request = new AgentResumeRequest();
AgentResumeToken token = new AgentResumeToken();
token.setValue("persisted-token");
request.setResumeToken(token);
request.setApproved(true);
request.setTrusted(true);
request.setMetadata(Map.of(
"toolCallId", "call-1",
"toolName", "search",
"toolInput", Map.of("q", "easyflow")));
coordinator.authorizeTrustedExecution(request);
coordinator.consumeExecutionAuthorization(
"call-1", "search", Map.of("q", "easyflow"));
assertAuthorizationRejected(
coordinator, "call-1", "search", Map.of("q", "easyflow"));
}
/**
* 验证受信任恢复可以签发当前 Turn 内可复用的 MCP 审批作用域。
*/
@Test
public void shouldAuthorizeTrustedMcpScope() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
AgentResumeRequest request = new AgentResumeRequest();
AgentResumeToken token = new AgentResumeToken();
token.setValue("persisted-token");
request.setResumeToken(token);
request.setApproved(true);
request.setTrusted(true);
request.setMetadata(Map.of(
"toolCallId", "call-1",
"toolName", "mcp_101_search",
"toolInput", Map.of("q", "easyflow"),
"toolType", "MCP",
"mcpId", "101"));
coordinator.authorizeTrustedExecution(request);
Assert.assertTrue(coordinator.isReusableApprovalGranted(
Map.of("toolType", "MCP", "mcpId", "101")));
}
/**
* 验证 MCP 批准可在当前 Turn 按稳定 mcpId 复用。
*/
@Test
public void shouldReuseApprovedMcpScopeWithinCurrentTurn() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
Map<String, Object> mcpMetadata = Map.of("toolType", "MCP", "mcpId", "101");
AgentPendingState pending = coordinator.register(
"session-1",
"agent-1",
"call-resolve",
"mcp_101_resolve_library_id",
"approve",
Map.of("libraryName", "AG-UI"),
mcpMetadata,
Instant.now().plusSeconds(60),
"batch-mcp");
coordinator.resolve(resume(pending, true));
Assert.assertTrue(coordinator.isReusableApprovalGranted(mcpMetadata));
Assert.assertFalse(coordinator.isReusableApprovalGranted(
Map.of("toolType", "MCP", "mcpId", "102")));
Assert.assertFalse(coordinator.isReusableApprovalGranted(
Map.of("toolType", "MCP", "mcpName", "context7")));
coordinator.clearReusableApprovalScopes();
Assert.assertFalse(coordinator.isReusableApprovalGranted(mcpMetadata));
}
/**
* 验证受控 Shell 脚本只能按受信任内容摘要在当前 Turn 复用审批。
*/
@Test
public void shouldReuseApprovedShellScriptScopeWithinCurrentTurn() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
Map<String, Object> scriptMetadata = Map.of(
"operateTool", true,
"operateToolType", "SHELL",
"approvalScope", "SHELL_SCRIPT:abc123");
AgentPendingState pending = coordinator.register(
"session-1", "agent-1", "call-script", "execute_shell_command", "approve",
Map.of("command", "python3 report.py"), scriptMetadata,
Instant.now().plusSeconds(60), "batch-script");
coordinator.resolve(resume(pending, true));
Assert.assertTrue(coordinator.isReusableApprovalGranted(scriptMetadata));
Assert.assertFalse(coordinator.isReusableApprovalGranted(Map.of(
"operateTool", true,
"operateToolType", "SHELL",
"approvalScope", "SHELL_SCRIPT:changed")));
Assert.assertNull(coordinator.reusableApprovalScope(Map.of(
"approvalScope", "SHELL_SCRIPT:abc123")));
}
/**
* 验证跨节点受信任恢复可恢复 Shell 脚本内容摘要作用域。
*/
@Test
public void shouldAuthorizeTrustedShellScriptScope() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
AgentResumeRequest request = new AgentResumeRequest();
AgentResumeToken token = new AgentResumeToken();
token.setValue("persisted-token");
request.setResumeToken(token);
request.setApproved(true);
request.setTrusted(true);
request.setMetadata(Map.of(
"toolCallId", "call-script",
"toolName", "execute_shell_command",
"toolInput", Map.of("command", "node report.mjs"),
"operateTool", true,
"operateToolType", "SHELL",
"approvalScope", "SHELL_SCRIPT:def456"));
coordinator.authorizeTrustedExecution(request);
Assert.assertTrue(coordinator.isReusableApprovalGranted(Map.of(
"operateTool", true,
"operateToolType", "SHELL",
"approvalScope", "SHELL_SCRIPT:def456")));
}
/**
* 验证拒绝和过期不会产生可复用 MCP 批准。
*/
@Test
public void shouldNotReuseRejectedOrExpiredMcpApproval() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
Map<String, Object> rejectedMetadata = Map.of("toolType", "MCP", "mcpId", "201");
AgentPendingState rejected = coordinator.register(
"session-1", "agent-1", "call-rejected", "mcp_rejected", "approve",
Map.of(), rejectedMetadata, Instant.now().plusSeconds(60), "batch-rejected");
coordinator.resolve(resume(rejected, false));
Map<String, Object> expiredMetadata = Map.of("toolType", "MCP", "mcpId", "202");
AgentPendingState expired = coordinator.register(
"session-1", "agent-1", "call-expired-mcp", "mcp_expired", "approve",
Map.of(), expiredMetadata, Instant.now().minusSeconds(1), "batch-expired-mcp");
coordinator.resolve(resume(expired, true));
Assert.assertFalse(coordinator.isReusableApprovalGranted(rejectedMetadata));
Assert.assertFalse(coordinator.isReusableApprovalGranted(expiredMetadata));
}
/**
* 验证同一 toolCallId 不能被重新绑定到不同工具内容。
*/
@Test
public void shouldRejectDuplicateToolCallIdWithDifferentInput() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
register(coordinator, "call-1", "search", Map.of("q", "easyflow"), "batch-1");
try {
register(coordinator, "call-1", "search", Map.of("q", "modified"), "batch-2");
Assert.fail("重复 toolCallId 不能绑定不同入参");
} catch (AgentRuntimeException expected) {
Assert.assertTrue(expected.getMessage().contains("Duplicate toolCallId"));
}
}
/**
* 注册测试用审批状态。
*
* @param coordinator 审批协调器
* @param toolCallId 工具调用ID
* @param toolName 工具名称
* @param toolInput 工具入参
* @param batchId 审批批次ID
* @return 待审批状态
*/
private AgentPendingState register(AgentToolApprovalCoordinator coordinator,
String toolCallId,
String toolName,
Map<String, Object> toolInput,
String batchId) {
return coordinator.register(
"session-1",
"agent-1",
toolCallId,
toolName,
"approve",
toolInput,
Map.of(),
Instant.now().plusSeconds(60),
batchId);
}
/**
* 创建测试用恢复请求。
*
* @param state 待审批状态
* @param approved 是否批准
* @return 恢复请求
*/
private AgentResumeRequest resume(AgentPendingState state, boolean approved) {
AgentResumeRequest request = new AgentResumeRequest();
request.setResumeToken(state.getResumeToken());
request.setApproved(approved);
return request;
}
/**
* 等待并发起跑信号后消费审批令牌。
*
* @param start 起跑信号
* @param coordinator 审批协调器
* @param pending 待审批状态
* @return 成功消费时为 true
* @throws InterruptedException 等待被中断时抛出
*/
private boolean resolveAfter(CountDownLatch start,
AgentToolApprovalCoordinator coordinator,
AgentPendingState pending) throws InterruptedException {
start.await();
try {
coordinator.resolve(resume(pending, true));
return true;
} catch (AgentRuntimeException expected) {
return false;
}
}
/**
* 断言工具执行授权不可用。
*
* @param coordinator 审批协调器
* @param toolCallId 工具调用ID
* @param toolName 工具名称
* @param toolInput 工具入参
*/
private void assertAuthorizationRejected(AgentToolApprovalCoordinator coordinator,
String toolCallId,
String toolName,
Map<String, Object> toolInput) {
try {
coordinator.consumeExecutionAuthorization(toolCallId, toolName, toolInput);
Assert.fail("未授权或已消费的工具调用必须被拒绝");
} catch (AgentToolApprovalRejectedException expected) {
Assert.assertNotNull(expected.getMessage());
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -256,10 +256,18 @@
<!--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>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-agui</artifactId>
</dependency>
<!--agent runtime end-->
<!--search engines start-->

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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