Compare commits
10 Commits
8356560c26
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bb37d9d708 | |||
| 3bd346ea77 | |||
| 55434466d4 | |||
| 7cac558b6c | |||
| 43f45956ff | |||
| 2bc525c16e | |||
| f324acb83c | |||
| 8b600f9d6c | |||
| 5c7182ac3f | |||
| 2b5e701ade |
@@ -23,6 +23,11 @@
|
||||
<artifactId>agentscope</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.alibaba.fastjson2</groupId>
|
||||
<artifactId>fastjson2</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.anthropic</groupId>
|
||||
<artifactId>anthropic-java</artifactId>
|
||||
|
||||
@@ -2,11 +2,13 @@ package com.easyagents.agent.runtime;
|
||||
|
||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
|
||||
import com.easyagents.agent.runtime.memory.AgentMemoryPolicy;
|
||||
import com.easyagents.agent.runtime.mcp.McpSpec;
|
||||
import com.easyagents.agent.runtime.model.AgentGenerationOptions;
|
||||
import com.easyagents.agent.runtime.model.AgentModelSpec;
|
||||
import com.easyagents.agent.runtime.persistence.AgentPersistencePolicy;
|
||||
import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolSpec;
|
||||
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolSpec;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -27,6 +29,8 @@ public class AgentDefinition {
|
||||
private AgentGenerationOptions generationOptions = new AgentGenerationOptions();
|
||||
private AgentExecutionOptions executionOptions = new AgentExecutionOptions();
|
||||
private List<AgentToolSpec> toolSpecs = new ArrayList<>();
|
||||
private List<McpSpec> mcpSpecs = new ArrayList<>();
|
||||
private List<AgentOperateToolSpec> operateToolSpecs = new ArrayList<>();
|
||||
private List<AgentKnowledgeSpec> knowledgeSpecs = new ArrayList<>();
|
||||
private AgentMemoryPolicy memoryPolicy = AgentMemoryPolicy.autoContext();
|
||||
private AgentPersistencePolicy persistencePolicy = AgentPersistencePolicy.disabled();
|
||||
@@ -177,6 +181,42 @@ public class AgentDefinition {
|
||||
this.toolSpecs = toolSpecs == null ? new ArrayList<>() : new ArrayList<>(toolSpecs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 MCP 声明。
|
||||
*
|
||||
* @return MCP 声明
|
||||
*/
|
||||
public List<McpSpec> getMcpSpecs() {
|
||||
return mcpSpecs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 MCP 声明。
|
||||
*
|
||||
* @param mcpSpecs MCP 声明
|
||||
*/
|
||||
public void setMcpSpecs(List<McpSpec> mcpSpecs) {
|
||||
this.mcpSpecs = mcpSpecs == null ? new ArrayList<>() : new ArrayList<>(mcpSpecs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作类工具定义。
|
||||
*
|
||||
* @return 操作类工具定义
|
||||
*/
|
||||
public List<AgentOperateToolSpec> getOperateToolSpecs() {
|
||||
return operateToolSpecs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置操作类工具定义。
|
||||
*
|
||||
* @param operateToolSpecs 操作类工具定义
|
||||
*/
|
||||
public void setOperateToolSpecs(List<AgentOperateToolSpec> operateToolSpecs) {
|
||||
this.operateToolSpecs = operateToolSpecs == null ? new ArrayList<>() : new ArrayList<>(operateToolSpecs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取知识库定义。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.easyagents.agent.runtime;
|
||||
|
||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever;
|
||||
import com.easyagents.agent.runtime.persistence.conversation.AgentConversationRecorder;
|
||||
import com.easyagents.agent.runtime.persistence.conversation.noop.NoopAgentConversationRecorder;
|
||||
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
|
||||
import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 智能体初始化创建请求参数
|
||||
*/
|
||||
public class AgentInitRequest {
|
||||
|
||||
/**
|
||||
* 会话ID,用于从会话存储中加载和保存当前智能体的上下文状态。
|
||||
*/
|
||||
private String sessionId;
|
||||
|
||||
/**
|
||||
* 智能体定义,包含模型、系统提示词、工具、知识库、记忆策略等静态配置。
|
||||
*/
|
||||
private AgentDefinition agentDefinition;
|
||||
|
||||
/**
|
||||
* 会话状态存储实现,可以实现此接口以管理 session
|
||||
*/
|
||||
private AgentSessionStore sessionStore = NoopAgentSessionStore.INSTANCE;
|
||||
|
||||
/**
|
||||
* 运行时上下文,传递租户、用户、链路等调用环境信息。
|
||||
*/
|
||||
private AgentRuntimeContext runtimeContext = new AgentRuntimeContext();
|
||||
|
||||
/**
|
||||
* 工具集合。
|
||||
*/
|
||||
private Map<String, AgentToolInvoker> toolInvokers = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 知识库集合,实现AgentKnowledgeRetriever接口以进行知识检索动作。
|
||||
*/
|
||||
private Map<String, AgentKnowledgeRetriever> knowledgeRetrievers = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 对话事件记录器,用于记录运行时事件流。
|
||||
*/
|
||||
private AgentConversationRecorder conversationRecorder = NoopAgentConversationRecorder.INSTANCE;
|
||||
|
||||
/**
|
||||
* 初始化元数据,用于传递业务侧扩展信息。
|
||||
*/
|
||||
private Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 获取会话ID。
|
||||
*
|
||||
* @return 会话ID
|
||||
*/
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置会话ID。
|
||||
*
|
||||
* @param sessionId 会话ID
|
||||
*/
|
||||
public void setSessionId(String sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取智能体定义。
|
||||
*
|
||||
* @return 智能体定义
|
||||
*/
|
||||
public AgentDefinition getAgentDefinition() {
|
||||
return agentDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置智能体定义。
|
||||
*
|
||||
* @param agentDefinition 智能体定义
|
||||
*/
|
||||
public void setAgentDefinition(AgentDefinition agentDefinition) {
|
||||
this.agentDefinition = agentDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话存储。
|
||||
*
|
||||
* @return 会话存储
|
||||
*/
|
||||
public AgentSessionStore getSessionStore() {
|
||||
return sessionStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置会话存储。
|
||||
*
|
||||
* @param sessionStore 会话存储
|
||||
*/
|
||||
public void setSessionStore(AgentSessionStore sessionStore) {
|
||||
this.sessionStore = sessionStore == null ? NoopAgentSessionStore.INSTANCE : sessionStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取运行时上下文。
|
||||
*
|
||||
* @return 运行时上下文
|
||||
*/
|
||||
public AgentRuntimeContext getRuntimeContext() {
|
||||
return runtimeContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置运行时上下文。
|
||||
*
|
||||
* @param runtimeContext 运行时上下文
|
||||
*/
|
||||
public void setRuntimeContext(AgentRuntimeContext runtimeContext) {
|
||||
this.runtimeContext = runtimeContext == null ? new AgentRuntimeContext() : runtimeContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具调用器。
|
||||
*
|
||||
* @return 工具调用器
|
||||
*/
|
||||
public Map<String, AgentToolInvoker> getToolInvokers() {
|
||||
return toolInvokers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置工具调用器。
|
||||
*
|
||||
* @param toolInvokers 工具调用器
|
||||
*/
|
||||
public void setToolInvokers(Map<String, AgentToolInvoker> toolInvokers) {
|
||||
this.toolInvokers = toolInvokers == null ? new LinkedHashMap<>() : toolInvokers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取知识库检索器。
|
||||
*
|
||||
* @return 知识库检索器
|
||||
*/
|
||||
public Map<String, AgentKnowledgeRetriever> getKnowledgeRetrievers() {
|
||||
return knowledgeRetrievers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置知识库检索器。
|
||||
*
|
||||
* @param knowledgeRetrievers 知识库检索器
|
||||
*/
|
||||
public void setKnowledgeRetrievers(Map<String, AgentKnowledgeRetriever> knowledgeRetrievers) {
|
||||
this.knowledgeRetrievers = knowledgeRetrievers == null ? new LinkedHashMap<>() : knowledgeRetrievers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话记录器。
|
||||
*
|
||||
* @return 会话记录器
|
||||
*/
|
||||
public AgentConversationRecorder getConversationRecorder() {
|
||||
return conversationRecorder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置会话记录器。
|
||||
*
|
||||
* @param conversationRecorder 会话记录器
|
||||
*/
|
||||
public void setConversationRecorder(AgentConversationRecorder conversationRecorder) {
|
||||
this.conversationRecorder = conversationRecorder == null
|
||||
? NoopAgentConversationRecorder.INSTANCE
|
||||
: conversationRecorder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元数据。
|
||||
*
|
||||
* @return 元数据
|
||||
*/
|
||||
public Map<String, Object> getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置元数据。
|
||||
*
|
||||
* @param metadata 元数据
|
||||
*/
|
||||
public void setMetadata(Map<String, Object> metadata) {
|
||||
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,44 @@
|
||||
package com.easyagents.agent.runtime.hitl;
|
||||
package com.easyagents.agent.runtime;
|
||||
|
||||
import com.easyagents.agent.runtime.hitl.AgentResumeToken;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 工具执行审批响应。
|
||||
* 智能体挂起运行恢复请求。
|
||||
*/
|
||||
public class AgentToolApprovalResponse {
|
||||
public class AgentResumeRequest {
|
||||
|
||||
/**
|
||||
* 挂起运行的恢复令牌。
|
||||
*/
|
||||
private AgentResumeToken resumeToken;
|
||||
|
||||
/**
|
||||
* 是否批准继续执行。
|
||||
*/
|
||||
private boolean approved;
|
||||
|
||||
/**
|
||||
* 拒绝继续执行时的原因。
|
||||
*/
|
||||
private String rejectReason;
|
||||
|
||||
/**
|
||||
* 调用方传入的恢复元数据。
|
||||
*/
|
||||
private Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 恢复请求是否已由调用方的持久化 pending store 完成校验和一次性消费。
|
||||
*
|
||||
* <p>该字段仅供服务端集成层使用。普通调用方不应设置该标记;设置后 runtime 会跳过
|
||||
* 当前进程内 {@code AgentToolApprovalCoordinator} 的 token 存在性校验,用于服务重启或跨节点后
|
||||
* 从 AgentScope session 中继续 pending tool。</p>
|
||||
*/
|
||||
private boolean trusted;
|
||||
|
||||
/**
|
||||
* 获取恢复令牌。
|
||||
*
|
||||
@@ -32,7 +58,7 @@ public class AgentToolApprovalResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回是否批准执行工具。
|
||||
* 返回是否批准继续执行。
|
||||
*
|
||||
* @return 批准时为 true
|
||||
*/
|
||||
@@ -41,7 +67,7 @@ public class AgentToolApprovalResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否批准执行工具。
|
||||
* 设置是否批准继续执行。
|
||||
*
|
||||
* @param approved 批准标记
|
||||
*/
|
||||
@@ -68,20 +94,38 @@ public class AgentToolApprovalResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审批元数据。
|
||||
* 获取恢复元数据。
|
||||
*
|
||||
* @return 审批元数据
|
||||
* @return 恢复元数据
|
||||
*/
|
||||
public Map<String, Object> getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置审批元数据。
|
||||
* 设置恢复元数据。
|
||||
*
|
||||
* @param metadata 审批元数据
|
||||
* @param metadata 恢复元数据
|
||||
*/
|
||||
public void setMetadata(Map<String, Object> metadata) {
|
||||
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回恢复请求是否已由调用方持久化层校验。
|
||||
*
|
||||
* @return 已校验时为 true
|
||||
*/
|
||||
public boolean isTrusted() {
|
||||
return trusted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置恢复请求是否已由调用方持久化层校验。
|
||||
*
|
||||
* @param trusted 已校验标记
|
||||
*/
|
||||
public void setTrusted(boolean trusted) {
|
||||
this.trusted = trusted;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package com.easyagents.agent.runtime;
|
||||
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||
import com.easyagents.agent.runtime.hitl.AgentToolApprovalResponse;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
/**
|
||||
* 可取消的单次智能体运行句柄。
|
||||
*/
|
||||
public interface AgentRunHandle {
|
||||
|
||||
/**
|
||||
* 获取本次运行的事件流。
|
||||
*
|
||||
* @return 事件流
|
||||
*/
|
||||
Flux<AgentRuntimeEvent> stream();
|
||||
|
||||
/**
|
||||
* 取消本次运行。
|
||||
*/
|
||||
void cancel();
|
||||
|
||||
/**
|
||||
* 提交工具审批结果。
|
||||
*
|
||||
* @param response 审批响应
|
||||
*/
|
||||
void submitToolApproval(AgentToolApprovalResponse response);
|
||||
}
|
||||
@@ -1,26 +1,40 @@
|
||||
package com.easyagents.agent.runtime;
|
||||
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||
import com.easyagents.agent.runtime.message.AgentMessage;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
/**
|
||||
* 执行声明式 ReAct 智能体并流式输出运行事件。
|
||||
* 有状态智能体运行器。
|
||||
*/
|
||||
public interface AgentRuntime {
|
||||
|
||||
/**
|
||||
* 启动单次智能体运行并返回可取消句柄。
|
||||
* 初始化智能体运行器。
|
||||
*
|
||||
* @param request 包含定义、输入、记忆和适配器的运行请求
|
||||
* @return 本次运行句柄
|
||||
* @param request 初始化请求
|
||||
*/
|
||||
AgentRunHandle start(AgentRunRequest request);
|
||||
void init(AgentInitRequest request);
|
||||
|
||||
/**
|
||||
* 启动单次智能体运行。
|
||||
* 发送用户消息并流式输出运行事件。
|
||||
*
|
||||
* @param request 包含定义、输入、记忆和适配器的运行请求
|
||||
* @return 本次运行的事件流
|
||||
* @param userMessage 用户消息
|
||||
* @return 运行事件流
|
||||
*/
|
||||
Flux<AgentRuntimeEvent> stream(AgentRunRequest request);
|
||||
Flux<AgentRuntimeEvent> stream(AgentMessage userMessage);
|
||||
|
||||
/**
|
||||
* 恢复一次已挂起的运行。
|
||||
*
|
||||
* @param request 恢复请求
|
||||
* @return 运行事件流
|
||||
*/
|
||||
Flux<AgentRuntimeEvent> resume(AgentResumeRequest request);
|
||||
|
||||
/**
|
||||
* 关闭运行器并释放底层资源。
|
||||
*/
|
||||
default void close() {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,32 +3,83 @@ package com.easyagents.agent.runtime;
|
||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever;
|
||||
import com.easyagents.agent.runtime.memory.AgentMemorySnapshot;
|
||||
import com.easyagents.agent.runtime.message.AgentMessage;
|
||||
import com.easyagents.agent.runtime.persistence.AgentConversationRecorder;
|
||||
import com.easyagents.agent.runtime.persistence.AgentSessionStore;
|
||||
import com.easyagents.agent.runtime.persistence.noop.NoopAgentConversationRecorder;
|
||||
import com.easyagents.agent.runtime.persistence.noop.NoopAgentSessionStore;
|
||||
import com.easyagents.agent.runtime.persistence.conversation.AgentConversationRecorder;
|
||||
import com.easyagents.agent.runtime.persistence.conversation.noop.NoopAgentConversationRecorder;
|
||||
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
|
||||
import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 一次智能体运行请求。
|
||||
* 单轮智能体运行的内部执行上下文。
|
||||
*/
|
||||
public class AgentRunRequest {
|
||||
public class AgentRuntimeExecutionContext {
|
||||
|
||||
/**
|
||||
* 本轮运行请求ID。
|
||||
*/
|
||||
private String requestId;
|
||||
|
||||
/**
|
||||
* 本轮运行链路ID。
|
||||
*/
|
||||
private String traceId;
|
||||
|
||||
/**
|
||||
* 当前会话ID。
|
||||
*/
|
||||
private String sessionId;
|
||||
|
||||
/**
|
||||
* 智能体定义。
|
||||
*/
|
||||
private AgentDefinition agentDefinition;
|
||||
|
||||
/**
|
||||
* 业务运行时上下文。
|
||||
*/
|
||||
private AgentRuntimeContext runtimeContext = new AgentRuntimeContext();
|
||||
|
||||
/**
|
||||
* 本轮用户消息。
|
||||
*/
|
||||
private AgentMessage userMessage;
|
||||
|
||||
/**
|
||||
* 本轮初始记忆快照。
|
||||
*/
|
||||
private AgentMemorySnapshot memorySnapshot = new AgentMemorySnapshot();
|
||||
|
||||
/**
|
||||
* 按工具名称索引的工具调用器。
|
||||
*/
|
||||
private Map<String, AgentToolInvoker> toolInvokers = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 按知识库ID索引的检索器。
|
||||
*/
|
||||
private Map<String, AgentKnowledgeRetriever> knowledgeRetrievers = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 会话状态存储。
|
||||
*/
|
||||
private AgentSessionStore sessionStore = NoopAgentSessionStore.INSTANCE;
|
||||
|
||||
/**
|
||||
* 对话事件记录器。
|
||||
*/
|
||||
private AgentConversationRecorder conversationRecorder = NoopAgentConversationRecorder.INSTANCE;
|
||||
|
||||
/**
|
||||
* 运行元数据。
|
||||
*/
|
||||
private Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 运行取消原因。
|
||||
*/
|
||||
private String cancelReason;
|
||||
|
||||
/**
|
||||
@@ -158,7 +209,7 @@ public class AgentRunRequest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取tool invokers by tool name。
|
||||
* 获取工具调用器。
|
||||
*
|
||||
* @return 工具调用器
|
||||
*/
|
||||
@@ -176,7 +227,7 @@ public class AgentRunRequest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取knowledge retrievers by knowledge id。
|
||||
* 获取知识库检索器。
|
||||
*
|
||||
* @return 知识库检索器
|
||||
*/
|
||||
@@ -212,36 +263,38 @@ public class AgentRunRequest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话记录器。
|
||||
* 获取对话记录器。
|
||||
*
|
||||
* @return 会话记录器
|
||||
* @return 对话记录器
|
||||
*/
|
||||
public AgentConversationRecorder getConversationRecorder() {
|
||||
return conversationRecorder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置会话记录器。
|
||||
* 设置对话记录器。
|
||||
*
|
||||
* @param conversationRecorder 会话记录器
|
||||
* @param conversationRecorder 对话记录器
|
||||
*/
|
||||
public void setConversationRecorder(AgentConversationRecorder conversationRecorder) {
|
||||
this.conversationRecorder = conversationRecorder == null ? NoopAgentConversationRecorder.INSTANCE : conversationRecorder;
|
||||
this.conversationRecorder = conversationRecorder == null
|
||||
? NoopAgentConversationRecorder.INSTANCE
|
||||
: conversationRecorder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元数据。
|
||||
* 获取运行元数据。
|
||||
*
|
||||
* @return 元数据
|
||||
* @return 运行元数据
|
||||
*/
|
||||
public Map<String, Object> getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置元数据。
|
||||
* 设置运行元数据。
|
||||
*
|
||||
* @param metadata 元数据
|
||||
* @param metadata 运行元数据
|
||||
*/
|
||||
public void setMetadata(Map<String, Object> metadata) {
|
||||
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.easyagents.agent.runtime.agentscope;
|
||||
|
||||
import io.agentscope.core.message.*;
|
||||
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 reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 面向 AutoContext 摘要调用的模型包装器。
|
||||
*/
|
||||
class AgentScopeAutoContextCompressionModel implements Model {
|
||||
|
||||
private final Model delegate;
|
||||
|
||||
/**
|
||||
* 创建 AutoContext 摘要模型包装器。
|
||||
*
|
||||
* @param delegate 实际调用的模型
|
||||
*/
|
||||
AgentScopeAutoContextCompressionModel(Model delegate) {
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 AutoContext 压缩摘要输入做协议安全化后转发给实际模型。
|
||||
*
|
||||
* @param messages AgentScope 消息
|
||||
* @param tools 工具声明
|
||||
* @param options 生成参数
|
||||
* @return 模型响应流
|
||||
*/
|
||||
@Override
|
||||
public Flux<ChatResponse> stream(List<Msg> messages, List<ToolSchema> tools, GenerateOptions options) {
|
||||
return delegate.stream(sanitizeCompressionMessages(messages), tools, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型名称。
|
||||
*
|
||||
* @return 模型名称
|
||||
*/
|
||||
@Override
|
||||
public String getModelName() {
|
||||
return delegate.getModelName();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 AutoContext 摘要材料中的工具协议消息转换为普通文本消息。
|
||||
*
|
||||
* @param messages 原始摘要输入
|
||||
* @return 协议安全的摘要输入
|
||||
*/
|
||||
List<Msg> sanitizeCompressionMessages(List<Msg> messages) {
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
return messages;
|
||||
}
|
||||
List<Msg> sanitized = new ArrayList<>(messages.size());
|
||||
for (Msg message : messages) {
|
||||
if (message == null) {
|
||||
continue;
|
||||
}
|
||||
if (requiresPlainText(message)) {
|
||||
sanitized.add(toPlainTextUserMessage(message));
|
||||
} else {
|
||||
sanitized.add(message);
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private boolean requiresPlainText(Msg message) {
|
||||
return message.getRole() == MsgRole.TOOL
|
||||
|| message.hasContentBlocks(ToolUseBlock.class)
|
||||
|| message.hasContentBlocks(ToolResultBlock.class);
|
||||
}
|
||||
|
||||
private Msg toPlainTextUserMessage(Msg message) {
|
||||
return Msg.builder()
|
||||
.id(message.getId())
|
||||
.role(MsgRole.USER)
|
||||
.name("context")
|
||||
.content(TextBlock.builder().text(renderMessage(message)).build())
|
||||
.metadata(message.getMetadata())
|
||||
.timestamp(message.getTimestamp())
|
||||
.build();
|
||||
}
|
||||
|
||||
private String renderMessage(Msg message) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("Context message role=").append(message.getRole());
|
||||
if (message.getName() != null && !message.getName().isBlank()) {
|
||||
builder.append(", name=").append(message.getName());
|
||||
}
|
||||
builder.append('\n');
|
||||
for (ContentBlock block : message.getContent()) {
|
||||
appendBlock(builder, block);
|
||||
}
|
||||
return builder.toString().trim();
|
||||
}
|
||||
|
||||
private void appendBlock(StringBuilder builder, ContentBlock block) {
|
||||
if (block instanceof TextBlock textBlock) {
|
||||
appendSection(builder, "text", textBlock.getText());
|
||||
return;
|
||||
}
|
||||
if (block instanceof ThinkingBlock thinkingBlock) {
|
||||
appendSection(builder, "thinking", thinkingBlock.getThinking());
|
||||
return;
|
||||
}
|
||||
if (block instanceof ToolUseBlock toolUseBlock) {
|
||||
appendSection(builder, "tool_use.id", toolUseBlock.getId());
|
||||
appendSection(builder, "tool_use.name", toolUseBlock.getName());
|
||||
appendSection(builder, "tool_use.input", renderMap(toolUseBlock.getInput()));
|
||||
return;
|
||||
}
|
||||
if (block instanceof ToolResultBlock toolResultBlock) {
|
||||
appendSection(builder, "tool_result.id", toolResultBlock.getId());
|
||||
appendSection(builder, "tool_result.name", toolResultBlock.getName());
|
||||
appendSection(builder, "tool_result.output", renderBlocks(toolResultBlock.getOutput()));
|
||||
return;
|
||||
}
|
||||
appendSection(builder, "content", String.valueOf(block));
|
||||
}
|
||||
|
||||
private String renderBlocks(List<ContentBlock> blocks) {
|
||||
if (blocks == null || blocks.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (ContentBlock block : blocks) {
|
||||
if (block instanceof TextBlock textBlock) {
|
||||
builder.append(textBlock.getText());
|
||||
} else {
|
||||
builder.append(String.valueOf(block));
|
||||
}
|
||||
builder.append('\n');
|
||||
}
|
||||
return builder.toString().trim();
|
||||
}
|
||||
|
||||
private String renderMap(Map<String, Object> map) {
|
||||
return map == null ? "{}" : map.toString();
|
||||
}
|
||||
|
||||
private void appendSection(StringBuilder builder, String label, String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return;
|
||||
}
|
||||
builder.append(label).append(": ").append(value).append('\n');
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.easyagents.agent.runtime.agentscope;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRunRequest;
|
||||
import io.agentscope.core.hook.Hook;
|
||||
import io.agentscope.core.hook.HookEvent;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 从 AgentScope Hook 回调发射不在主事件流中的运行时事件。
|
||||
*/
|
||||
public class AgentScopeEventHook implements Hook {
|
||||
|
||||
private final AgentRunRequest request;
|
||||
|
||||
/**
|
||||
* 创建 Hook。
|
||||
*
|
||||
* @param request 运行请求
|
||||
*/
|
||||
public AgentScopeEventHook(AgentRunRequest request) {
|
||||
this.request = request;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T extends HookEvent> Mono<T> onEvent(T event) {
|
||||
return Mono.just(event);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
package com.easyagents.agent.runtime.agentscope;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRunRequest;
|
||||
import com.easyagents.agent.runtime.AgentRuntimeException;
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
||||
import com.easyagents.agent.runtime.AgentRuntimeExecutionContext;
|
||||
import com.easyagents.agent.runtime.event.*;
|
||||
import com.easyagents.agent.runtime.knowledge.*;
|
||||
import io.agentscope.core.message.TextBlock;
|
||||
import io.agentscope.core.rag.Knowledge;
|
||||
@@ -26,8 +25,8 @@ public class AgentScopeKnowledgeAdapter {
|
||||
* @param request 运行请求
|
||||
* @return 聚合 Knowledge;未配置知识库时返回 null
|
||||
*/
|
||||
public Knowledge createAggregateKnowledge(AgentRunRequest request) {
|
||||
return createAggregateKnowledge(request, null);
|
||||
public Knowledge createAggregateKnowledge(AgentRuntimeExecutionContext request) {
|
||||
return createAggregateKnowledge(request, (Sinks.Many<AgentRuntimeEvent>) null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,11 +36,31 @@ public class AgentScopeKnowledgeAdapter {
|
||||
* @param eventSink 事件 sink
|
||||
* @return 聚合 Knowledge;未配置知识库时返回 null
|
||||
*/
|
||||
public Knowledge createAggregateKnowledge(AgentRunRequest request, Sinks.Many<AgentRuntimeEvent> eventSink) {
|
||||
public Knowledge createAggregateKnowledge(AgentRuntimeExecutionContext request, Sinks.Many<AgentRuntimeEvent> eventSink) {
|
||||
return createAggregateKnowledge(request, fixedHolder(request, eventSink));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建可读取当前运行轮次事件出口的聚合 Knowledge。
|
||||
*
|
||||
* @param request 运行时级上下文
|
||||
* @param turnContextHolder 当前运行轮次上下文持有器
|
||||
* @return 聚合 Knowledge;未配置知识库时返回 null
|
||||
*/
|
||||
public Knowledge createAggregateKnowledge(AgentRuntimeExecutionContext request,
|
||||
AgentRuntimeTurnContextHolder turnContextHolder) {
|
||||
if (request.getAgentDefinition().getKnowledgeSpecs().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return new AggregateKnowledge(request, eventSink);
|
||||
return new AggregateKnowledge(request, turnContextHolder);
|
||||
}
|
||||
|
||||
private AgentRuntimeTurnContextHolder fixedHolder(AgentRuntimeExecutionContext request,
|
||||
Sinks.Many<AgentRuntimeEvent> eventSink) {
|
||||
AgentRuntimeTurnContextHolder holder = new AgentRuntimeTurnContextHolder();
|
||||
AgentRuntimeEventBridge bridge = new AgentRuntimeEventBridge(request, holder);
|
||||
holder.set(new AgentRuntimeTurnContext(null, eventSink, bridge));
|
||||
return holder;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,9 +90,9 @@ public class AgentScopeKnowledgeAdapter {
|
||||
payload.put("documentMetadata", document.getMetadata());
|
||||
payload.putAll(document.getMetadata());
|
||||
DocumentMetadata metadata = DocumentMetadata.builder()
|
||||
.content(TextBlock.builder().text(document.getContent()).build())
|
||||
.docId(document.getDocumentId())
|
||||
.chunkId(document.getChunkId())
|
||||
.content(TextBlock.builder().text(safeContent(document)).build())
|
||||
.docId(safeDocumentId(document))
|
||||
.chunkId(safeChunkId(document))
|
||||
.payload(payload)
|
||||
.build();
|
||||
Document converted = new Document(metadata);
|
||||
@@ -81,17 +100,59 @@ public class AgentScopeKnowledgeAdapter {
|
||||
return converted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 AgentScope 要求的非空文档 ID。
|
||||
*
|
||||
* @param document 知识文档
|
||||
* @return 非空文档 ID
|
||||
*/
|
||||
private String safeDocumentId(AgentKnowledgeDocument document) {
|
||||
if (document.getDocumentId() != null && !document.getDocumentId().isBlank()) {
|
||||
return document.getDocumentId();
|
||||
}
|
||||
if (document.getChunkId() != null && !document.getChunkId().isBlank()) {
|
||||
return document.getChunkId();
|
||||
}
|
||||
return "knowledge-document";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 AgentScope 要求的非空分片 ID。
|
||||
*
|
||||
* @param document 知识文档
|
||||
* @return 非空分片 ID
|
||||
*/
|
||||
private String safeChunkId(AgentKnowledgeDocument document) {
|
||||
if (document.getChunkId() != null && !document.getChunkId().isBlank()) {
|
||||
return document.getChunkId();
|
||||
}
|
||||
if (document.getDocumentId() != null && !document.getDocumentId().isBlank()) {
|
||||
return document.getDocumentId();
|
||||
}
|
||||
return "0";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 AgentScope 要求的非空文档内容。
|
||||
*
|
||||
* @param document 知识文档
|
||||
* @return 文档内容
|
||||
*/
|
||||
private String safeContent(AgentKnowledgeDocument document) {
|
||||
return document.getContent() == null ? "" : document.getContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将检索调用分发到多个知识源的聚合 Knowledge 实现。
|
||||
*/
|
||||
private class AggregateKnowledge implements Knowledge {
|
||||
|
||||
private final AgentRunRequest request;
|
||||
private final Sinks.Many<AgentRuntimeEvent> eventSink;
|
||||
private final AgentRuntimeExecutionContext request;
|
||||
private final AgentRuntimeTurnContextHolder turnContextHolder;
|
||||
|
||||
private AggregateKnowledge(AgentRunRequest request, Sinks.Many<AgentRuntimeEvent> eventSink) {
|
||||
private AggregateKnowledge(AgentRuntimeExecutionContext request, AgentRuntimeTurnContextHolder turnContextHolder) {
|
||||
this.request = request;
|
||||
this.eventSink = eventSink;
|
||||
this.turnContextHolder = turnContextHolder;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,9 +200,10 @@ public class AgentScopeKnowledgeAdapter {
|
||||
retrievalRequest.setLimit(spec.getLimit());
|
||||
retrievalRequest.setScoreThreshold(Math.max(spec.getScoreThreshold(), globalThreshold));
|
||||
retrievalRequest.setKnowledgeSpec(spec);
|
||||
retrievalRequest.setRuntimeContext(request.getRuntimeContext());
|
||||
retrievalRequest.getMetadata().put("traceId", request.getTraceId());
|
||||
retrievalRequest.getMetadata().put("sessionId", request.getSessionId());
|
||||
AgentRuntimeExecutionContext currentRequest = currentRequest();
|
||||
retrievalRequest.setRuntimeContext(currentRequest.getRuntimeContext());
|
||||
retrievalRequest.getMetadata().put("traceId", currentRequest.getTraceId());
|
||||
retrievalRequest.getMetadata().put("sessionId", currentRequest.getSessionId());
|
||||
AgentKnowledgeRetrievalResult result = retriever.retrieve(retrievalRequest);
|
||||
if (result == null || result.getDocuments() == null) {
|
||||
emitKnowledgeRetrievalEvent(query, spec, retrievalRequest, new ArrayList<>());
|
||||
@@ -181,7 +243,11 @@ public class AgentScopeKnowledgeAdapter {
|
||||
}
|
||||
|
||||
/**
|
||||
* 发射知识库检索事件,供聊天界面展示检索过程。
|
||||
* 发射知识库检索旁路事件,供聊天界面展示检索过程。
|
||||
*
|
||||
* <p>知识库检索本身属于 AgentScope RAG 主线路,返回的 Document 会继续进入
|
||||
* AgentScope 的上下文注入流程;这里发出的 {@code KNOWLEDGE_RETRIEVAL}
|
||||
* 只是旁路告知调用方,不会回写 memory,也不会参与模型消息序列。</p>
|
||||
*
|
||||
* @param query 查询
|
||||
* @param spec 知识库声明
|
||||
@@ -192,32 +258,35 @@ public class AgentScopeKnowledgeAdapter {
|
||||
AgentKnowledgeSpec spec,
|
||||
AgentKnowledgeRetrievalRequest retrievalRequest,
|
||||
List<AgentKnowledgeDocument> documents) {
|
||||
if (eventSink == null) {
|
||||
return;
|
||||
}
|
||||
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL);
|
||||
event.setTraceId(request.getTraceId());
|
||||
event.setSessionId(request.getSessionId());
|
||||
event.setAgentId(request.getAgentDefinition().getAgentId());
|
||||
event.getMetadata().put("requestId", request.getRequestId());
|
||||
AgentRuntimeEvent event = currentEventBridge().event(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL);
|
||||
event.getPayload().put("query", query);
|
||||
event.getPayload().put("knowledgeId", spec.getKnowledgeId());
|
||||
event.getPayload().put("knowledgeName", spec.getName());
|
||||
event.getPayload().put("knowledgeType", spec.getMetadata().get("knowledgeType"));
|
||||
event.getPayload().put("faqCollection", spec.getMetadata().get("faqCollection"));
|
||||
event.getPayload().put("limit", retrievalRequest.getLimit());
|
||||
event.getPayload().put("scoreThreshold", retrievalRequest.getScoreThreshold());
|
||||
event.getPayload().put("documentCount", documents == null ? 0 : documents.size());
|
||||
event.getPayload().put("documents", documentSummaries(documents));
|
||||
Sinks.EmitResult result = eventSink.tryEmitNext(event);
|
||||
if (result.isFailure()) {
|
||||
throw new AgentRuntimeException("Failed to emit knowledge retrieval event: " + result);
|
||||
currentEventBridge().emit(event);
|
||||
}
|
||||
|
||||
private AgentRuntimeExecutionContext currentRequest() {
|
||||
return turnContextHolder == null ? request : turnContextHolder.executionContext(request);
|
||||
}
|
||||
|
||||
private AgentRuntimeEventBridge currentEventBridge() {
|
||||
if (turnContextHolder != null && turnContextHolder.eventBridge().isPresent()) {
|
||||
return turnContextHolder.eventBridge().get();
|
||||
}
|
||||
return new AgentRuntimeEventBridge(request, turnContextHolder);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用于事件展示的文档摘要,避免把全文内容放入事件。
|
||||
* 构建用于事件展示的命中片段,保留前端引注需要的原始 chunk 内容。
|
||||
*
|
||||
* @param documents 检索文档
|
||||
* @return 文档摘要列表
|
||||
* @return 命中片段列表
|
||||
*/
|
||||
private List<Map<String, Object>> documentSummaries(List<AgentKnowledgeDocument> documents) {
|
||||
List<Map<String, Object>> summaries = new ArrayList<>();
|
||||
@@ -229,6 +298,7 @@ public class AgentScopeKnowledgeAdapter {
|
||||
summary.put("documentId", document.getDocumentId());
|
||||
summary.put("documentName", document.getDocumentName());
|
||||
summary.put("chunkId", document.getChunkId());
|
||||
summary.put("chunkContent", document.getContent());
|
||||
summary.put("score", document.getScore());
|
||||
summary.put("sourceUri", document.getSourceUri());
|
||||
summary.put("metadata", document.getMetadata());
|
||||
|
||||
@@ -28,16 +28,35 @@ public class AgentScopeMemoryAdapter {
|
||||
* @return AgentScope 记忆
|
||||
*/
|
||||
public Memory createMemory(AgentMemorySnapshot snapshot, AgentMemoryPolicy policy, Model model) {
|
||||
return createMemoryResult(snapshot, policy, model).getMemory();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建记忆并返回 AutoContext 配置快照。
|
||||
*
|
||||
* <p>有状态 runtime 需要把同一份 {@link AutoContextConfig} 交给 AutoContext干预器,
|
||||
* 用于判断是否进入压缩流程并发出旁路事件。非 AutoContext 记忆不会返回配置。</p>
|
||||
*
|
||||
* @param snapshot 记忆快照
|
||||
* @param policy 记忆策略
|
||||
* @param model 用于自动压缩的模型
|
||||
* @return 记忆构建结果
|
||||
*/
|
||||
public AgentScopeMemoryBuildResult createMemoryResult(AgentMemorySnapshot snapshot,
|
||||
AgentMemoryPolicy policy,
|
||||
Model model) {
|
||||
AgentMemoryPolicy safePolicy = policy == null ? AgentMemoryPolicy.autoContext() : policy;
|
||||
Memory memory;
|
||||
AutoContextConfig autoContextConfig = null;
|
||||
if (safePolicy.getType() == com.easyagents.agent.runtime.memory.AgentMemoryType.AUTO_CONTEXT
|
||||
&& safePolicy.getCompressionParameter().isEnabled()) {
|
||||
memory = new AutoContextMemory(toAutoContextConfig(safePolicy.getCompressionParameter()), model);
|
||||
autoContextConfig = toAutoContextConfig(safePolicy.getCompressionParameter());
|
||||
memory = new AutoContextMemory(autoContextConfig, new AgentScopeAutoContextCompressionModel(model));
|
||||
} else {
|
||||
memory = new InMemoryMemory();
|
||||
}
|
||||
attachMessages(memory, snapshot, safePolicy.getMaxAttachedMessageCount());
|
||||
return memory;
|
||||
return new AgentScopeMemoryBuildResult(memory, autoContextConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,13 +67,14 @@ public class AgentScopeMemoryAdapter {
|
||||
*/
|
||||
public AutoContextConfig toAutoContextConfig(AgentMemoryCompressionParameter parameter) {
|
||||
AgentMemoryCompressionParameter safeParameter = parameter == null ? new AgentMemoryCompressionParameter() : parameter;
|
||||
Integer compressionThreshold = safeParameter.getMinCompressionTokenThreshold();
|
||||
return AutoContextConfig.builder()
|
||||
.msgThreshold(safeParameter.getMsgThreshold())
|
||||
.msgThreshold(safeParameter.getMsgThreshold() == null ? Integer.MAX_VALUE : safeParameter.getMsgThreshold())
|
||||
.lastKeep(safeParameter.getLastKeep())
|
||||
.tokenRatio(safeParameter.getTokenRatio())
|
||||
.maxToken(safeParameter.getMaxToken())
|
||||
.tokenRatio(1.0D)
|
||||
.maxToken(compressionThreshold == null ? Long.MAX_VALUE : compressionThreshold)
|
||||
.largePayloadThreshold(safeParameter.getLargePayloadThreshold())
|
||||
.minCompressionTokenThreshold(safeParameter.getMinCompressionTokenThreshold())
|
||||
.minCompressionTokenThreshold(compressionThreshold == null ? Integer.MAX_VALUE : compressionThreshold)
|
||||
.currentRoundCompressionRatio(safeParameter.getCurrentRoundCompressionRatio())
|
||||
.minConsecutiveToolMessages(safeParameter.getMinConsecutiveToolMessages())
|
||||
.build();
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.easyagents.agent.runtime.agentscope;
|
||||
|
||||
import io.agentscope.core.memory.Memory;
|
||||
import io.agentscope.core.memory.autocontext.AutoContextConfig;
|
||||
|
||||
/**
|
||||
* AgentScope 记忆构建结果。
|
||||
*
|
||||
* <p>AutoContext 的压缩入口判断必须使用创建 {@code AutoContextMemory} 时的同一份
|
||||
* {@link AutoContextConfig}。该结果对象将记忆实例和配置快照一起返回,避免运行时
|
||||
* 干预器重新推导配置导致事件触发条件与 AgentScope 主线路不一致。</p>
|
||||
*/
|
||||
public class AgentScopeMemoryBuildResult {
|
||||
|
||||
private final Memory memory;
|
||||
private final AutoContextConfig autoContextConfig;
|
||||
|
||||
/**
|
||||
* 创建记忆构建结果。
|
||||
*
|
||||
* @param memory AgentScope 记忆实例
|
||||
* @param autoContextConfig AutoContext 配置,非 AutoContext 记忆时为 null
|
||||
*/
|
||||
public AgentScopeMemoryBuildResult(Memory memory, AutoContextConfig autoContextConfig) {
|
||||
this.memory = memory;
|
||||
this.autoContextConfig = autoContextConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 AgentScope 记忆实例。
|
||||
*
|
||||
* @return AgentScope 记忆实例
|
||||
*/
|
||||
public Memory getMemory() {
|
||||
return memory;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 AutoContext 配置。
|
||||
*
|
||||
* @return AutoContext 配置,非 AutoContext 记忆时为 null
|
||||
*/
|
||||
public AutoContextConfig getAutoContextConfig() {
|
||||
return autoContextConfig;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
package com.easyagents.agent.runtime.agentscope;
|
||||
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeObservationManager;
|
||||
import io.agentscope.core.hook.Hook;
|
||||
import io.agentscope.core.hook.HookEvent;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* AgentScope Hook 的统一入口。
|
||||
*/
|
||||
public class AgentScopeRuntimeHook implements Hook {
|
||||
|
||||
private final AgentRuntimeObservationManager observationManager;
|
||||
|
||||
/**
|
||||
* 创建统一 Hook 入口。
|
||||
*
|
||||
* @param observationManager 观察和干预调度器
|
||||
*/
|
||||
public AgentScopeRuntimeHook(AgentRuntimeObservationManager observationManager) {
|
||||
this.observationManager = observationManager == null
|
||||
? AgentRuntimeObservationManager.empty()
|
||||
: observationManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 AgentScope Hook 事件。
|
||||
*
|
||||
* @param event Hook 事件
|
||||
* @param <T> Hook 事件类型
|
||||
* @return 处理后的 Hook 事件
|
||||
*/
|
||||
@Override
|
||||
public <T extends HookEvent> Mono<T> onEvent(T event) {
|
||||
return observationManager.handle(event);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
package com.easyagents.agent.runtime.agentscope;
|
||||
|
||||
import com.easyagents.agent.runtime.persistence.AgentPersistencePolicy;
|
||||
import com.easyagents.agent.runtime.persistence.AgentRuntimeState;
|
||||
import com.easyagents.agent.runtime.persistence.AgentSessionStore;
|
||||
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
|
||||
import io.agentscope.core.session.Session;
|
||||
import io.agentscope.core.state.SessionKey;
|
||||
import io.agentscope.core.state.State;
|
||||
import io.agentscope.core.state.StatePersistence;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
@@ -32,7 +30,7 @@ public class AgentScopeSessionAdapter implements Session {
|
||||
|
||||
@Override
|
||||
public void save(SessionKey sessionKey, String name, State state) {
|
||||
sessionStore.save(toKey(sessionKey), name, AgentRuntimeState.of(name, state));
|
||||
sessionStore.save(toKey(sessionKey), name, state);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,13 +42,7 @@ public class AgentScopeSessionAdapter implements Session {
|
||||
*/
|
||||
@Override
|
||||
public void save(SessionKey sessionKey, String name, List<? extends State> states) {
|
||||
List<AgentRuntimeState> converted = new ArrayList<>();
|
||||
if (states != null) {
|
||||
for (State state : states) {
|
||||
converted.add(AgentRuntimeState.of(name, state));
|
||||
}
|
||||
}
|
||||
sessionStore.saveList(toKey(sessionKey), name, converted);
|
||||
sessionStore.saveList(toKey(sessionKey), name, states);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,10 +56,7 @@ public class AgentScopeSessionAdapter implements Session {
|
||||
*/
|
||||
@Override
|
||||
public <T extends State> Optional<T> get(SessionKey sessionKey, String name, Class<T> clazz) {
|
||||
return sessionStore.get(toKey(sessionKey), name)
|
||||
.map(AgentRuntimeState::getValue)
|
||||
.filter(clazz::isInstance)
|
||||
.map(clazz::cast);
|
||||
return sessionStore.get(toKey(sessionKey), name, clazz);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,11 +70,7 @@ public class AgentScopeSessionAdapter implements Session {
|
||||
*/
|
||||
@Override
|
||||
public <T extends State> List<T> getList(SessionKey sessionKey, String name, Class<T> clazz) {
|
||||
return sessionStore.getList(toKey(sessionKey), name).stream()
|
||||
.map(AgentRuntimeState::getValue)
|
||||
.filter(clazz::isInstance)
|
||||
.map(clazz::cast)
|
||||
.collect(Collectors.toList());
|
||||
return sessionStore.getList(toKey(sessionKey), name, clazz);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,7 +114,7 @@ public class AgentScopeSessionAdapter implements Session {
|
||||
*/
|
||||
public static StatePersistence toStatePersistence(AgentPersistencePolicy policy) {
|
||||
if (policy == null || !policy.isEnabled()) {
|
||||
return StatePersistence.none();
|
||||
return StatePersistence.memoryOnly();
|
||||
}
|
||||
return StatePersistence.builder()
|
||||
.memoryManaged(policy.isMemoryManaged())
|
||||
@@ -154,24 +139,17 @@ public class AgentScopeSessionAdapter implements Session {
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时支撑的 AgentScope 会话键。
|
||||
*/
|
||||
private static class RuntimeSessionKey implements SessionKey {
|
||||
|
||||
private final String value;
|
||||
|
||||
private RuntimeSessionKey(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
* 运行时支撑的 AgentScope 会话键。
|
||||
*/
|
||||
private record RuntimeSessionKey(String value) implements SessionKey {
|
||||
/**
|
||||
* 将键转换为稳定标识符。
|
||||
* 将键转换为稳定标识符。
|
||||
*
|
||||
* @return 标识符
|
||||
*/
|
||||
@Override
|
||||
public String toIdentifier() {
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.easyagents.agent.runtime.agentscope;
|
||||
|
||||
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;
|
||||
@@ -8,7 +9,6 @@ import io.agentscope.core.skill.SkillBox;
|
||||
import io.agentscope.core.tool.AgentTool;
|
||||
import io.agentscope.core.tool.Toolkit;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -19,18 +19,14 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler<AgentSkill> {
|
||||
|
||||
@Override
|
||||
public AgentSkill compile(AgentSkillSpec skillSpec) {
|
||||
Map<String, Object> metadata = new LinkedHashMap<>(skillSpec.getMetadata());
|
||||
metadata.put("skillId", skillSpec.getSkillId());
|
||||
metadata.put("name", skillSpec.getName());
|
||||
metadata.put("description", skillSpec.getDescription());
|
||||
return AgentSkill.builder()
|
||||
.name(skillSpec.getName())
|
||||
.description(skillSpec.getDescription())
|
||||
.skillContent(skillSpec.getSkillContent())
|
||||
.metadata(metadata)
|
||||
.resources(skillSpec.getResources())
|
||||
.source(skillSpec.getSource())
|
||||
.build();
|
||||
validateSkillSpec(skillSpec);
|
||||
return new EasyAgentsAgentSkill(
|
||||
skillSpec.getSkillId(),
|
||||
skillSpec.getName(),
|
||||
skillSpec.getDescription(),
|
||||
skillSpec.getSkillContent(),
|
||||
skillSpec.getResources(),
|
||||
skillSpec.getSource());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,4 +101,71 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler<AgentSkill> {
|
||||
skillBox.syncToolGroupStates();
|
||||
return skillBox;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 Skill 声明是否具备 AgentScope 注册和模型提示所需的必要信息。
|
||||
*
|
||||
* @param skillSpec Skill 声明
|
||||
* @throws AgentRuntimeException Skill 声明为空或核心字段缺失时抛出
|
||||
*/
|
||||
private void validateSkillSpec(AgentSkillSpec skillSpec) {
|
||||
if (skillSpec == null) {
|
||||
throw new AgentRuntimeException("Agent skill spec is required.");
|
||||
}
|
||||
if (skillSpec.getSkillId() == null || skillSpec.getSkillId().isBlank()) {
|
||||
throw new AgentRuntimeException("Agent skill id is required.");
|
||||
}
|
||||
if (skillSpec.getName() == null || skillSpec.getName().isBlank()) {
|
||||
throw new AgentRuntimeException("Agent skill name is required: " + skillSpec.getSkillId());
|
||||
}
|
||||
if (skillSpec.getDescription() == null || skillSpec.getDescription().isBlank()) {
|
||||
throw new AgentRuntimeException("Agent skill description is required: " + skillSpec.getSkillId());
|
||||
}
|
||||
if (skillSpec.getSkillContent() == null || skillSpec.getSkillContent().isBlank()) {
|
||||
throw new AgentRuntimeException("Agent skill content is required: " + skillSpec.getSkillId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保持 Easy-Agents Skill ID 与 AgentScope Skill ID 完全一致。
|
||||
*
|
||||
* <p>AgentScope 默认用 {@code name + "_" + source} 生成 Skill ID,而 Easy-Agents
|
||||
* 的工具绑定、旁路事件和调用层配置都以 {@link AgentSkillSpec#getSkillId()} 为准。
|
||||
* 如果不覆盖这里,模型在 prompt 中看到的 skill-id 会和 Easy 侧绑定 key 不一致,
|
||||
* 后续 Skill 状态监听也无法做到精准归属。</p>
|
||||
*/
|
||||
private static class EasyAgentsAgentSkill extends AgentSkill {
|
||||
|
||||
private final String skillId;
|
||||
|
||||
/**
|
||||
* 创建 ID 对齐的 AgentScope Skill。
|
||||
*
|
||||
* @param skillId Easy-Agents Skill ID
|
||||
* @param name Skill 展示名称
|
||||
* @param description Skill 描述
|
||||
* @param skillContent Skill 内容
|
||||
* @param resources Skill 资源
|
||||
* @param source Skill 来源
|
||||
*/
|
||||
EasyAgentsAgentSkill(String skillId,
|
||||
String name,
|
||||
String description,
|
||||
String skillContent,
|
||||
Map<String, String> resources,
|
||||
String source) {
|
||||
super(name, description, skillContent, resources, source);
|
||||
this.skillId = skillId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Easy-Agents 声明的 Skill ID。
|
||||
*
|
||||
* @return Easy-Agents Skill ID
|
||||
*/
|
||||
@Override
|
||||
public String getSkillId() {
|
||||
return skillId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package com.easyagents.agent.runtime.agentscope;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRunRequest;
|
||||
import com.easyagents.agent.runtime.AgentRuntimeException;
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
||||
import com.easyagents.agent.runtime.AgentRuntimeExecutionContext;
|
||||
import com.easyagents.agent.runtime.event.*;
|
||||
import com.easyagents.agent.runtime.hitl.AgentPendingState;
|
||||
import com.easyagents.agent.runtime.hitl.AgentToolApprovalCoordinator;
|
||||
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRejectedException;
|
||||
@@ -39,8 +38,9 @@ public class AgentScopeToolAdapter {
|
||||
* @param request 运行请求
|
||||
* @return AgentScope 工具
|
||||
*/
|
||||
public AgentTool adapt(AgentToolSpec toolSpec, AgentToolInvoker invoker, AgentRunRequest request) {
|
||||
return adapt(toolSpec, invoker, request, AgentToolApprovalCoordinator.disabled(), null);
|
||||
public AgentTool adapt(AgentToolSpec toolSpec, AgentToolInvoker invoker, AgentRuntimeExecutionContext request) {
|
||||
return adapt(toolSpec, invoker, request, AgentToolApprovalCoordinator.disabled(),
|
||||
(Sinks.Many<AgentRuntimeEvent>) null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,9 +54,10 @@ public class AgentScopeToolAdapter {
|
||||
*/
|
||||
public AgentTool adapt(AgentToolSpec toolSpec,
|
||||
AgentToolInvoker invoker,
|
||||
AgentRunRequest request,
|
||||
AgentRuntimeExecutionContext request,
|
||||
Sinks.Many<AgentRuntimeEvent> eventSink) {
|
||||
return adapt(toolSpec, invoker, request, AgentToolApprovalCoordinator.disabled(), eventSink);
|
||||
return adapt(toolSpec, invoker, request, AgentToolApprovalCoordinator.disabled(), fixedHolder(request, eventSink),
|
||||
null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,10 +72,10 @@ public class AgentScopeToolAdapter {
|
||||
*/
|
||||
public AgentTool adapt(AgentToolSpec toolSpec,
|
||||
AgentToolInvoker invoker,
|
||||
AgentRunRequest request,
|
||||
AgentRuntimeExecutionContext request,
|
||||
AgentToolApprovalCoordinator approvalCoordinator,
|
||||
Sinks.Many<AgentRuntimeEvent> eventSink) {
|
||||
return adapt(toolSpec, invoker, request, approvalCoordinator, eventSink, null);
|
||||
return adapt(toolSpec, invoker, request, approvalCoordinator, fixedHolder(request, eventSink), null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,11 +91,11 @@ public class AgentScopeToolAdapter {
|
||||
*/
|
||||
public AgentTool adapt(AgentToolSpec toolSpec,
|
||||
AgentToolInvoker invoker,
|
||||
AgentRunRequest request,
|
||||
AgentRuntimeExecutionContext request,
|
||||
AgentToolApprovalCoordinator approvalCoordinator,
|
||||
Sinks.Many<AgentRuntimeEvent> eventSink,
|
||||
AgentSkillBinding skillBinding) {
|
||||
return adapt(toolSpec, invoker, request, approvalCoordinator, eventSink, null, skillBinding);
|
||||
return adapt(toolSpec, invoker, request, approvalCoordinator, fixedHolder(request, eventSink), null, skillBinding);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,27 +112,153 @@ public class AgentScopeToolAdapter {
|
||||
*/
|
||||
public AgentTool adapt(AgentToolSpec toolSpec,
|
||||
AgentToolInvoker invoker,
|
||||
AgentRunRequest request,
|
||||
AgentRuntimeExecutionContext request,
|
||||
AgentToolApprovalCoordinator approvalCoordinator,
|
||||
Sinks.Many<AgentRuntimeEvent> eventSink,
|
||||
AgentSkillRuntimeContext skillContext,
|
||||
AgentSkillBinding skillBinding) {
|
||||
return adapt(toolSpec, invoker, request, approvalCoordinator, fixedHolder(request, eventSink), skillContext, skillBinding);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将运行时工具声明和调用器转换为可读取当前运行轮次的 AgentScope AgentTool。
|
||||
*
|
||||
* @param toolSpec 工具声明
|
||||
* @param invoker 工具调用器
|
||||
* @param request 运行时级上下文
|
||||
* @param approvalCoordinator 审批协调器
|
||||
* @param turnContextHolder 当前运行轮次上下文持有器
|
||||
* @param skillContext Skill 运行时上下文
|
||||
* @param skillBinding Skill 静态绑定关系
|
||||
* @return AgentScope 工具
|
||||
*/
|
||||
public AgentTool adapt(AgentToolSpec toolSpec,
|
||||
AgentToolInvoker invoker,
|
||||
AgentRuntimeExecutionContext request,
|
||||
AgentToolApprovalCoordinator approvalCoordinator,
|
||||
AgentRuntimeTurnContextHolder turnContextHolder,
|
||||
AgentSkillRuntimeContext skillContext,
|
||||
AgentSkillBinding skillBinding) {
|
||||
return adapt(toolSpec, invoker, request, approvalCoordinator, turnContextHolder, skillContext, skillBinding, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将运行时工具声明和调用器转换为可读取当前运行轮次的 AgentScope AgentTool。
|
||||
*
|
||||
* @param toolSpec 工具声明
|
||||
* @param invoker 工具调用器
|
||||
* @param request 运行时级上下文
|
||||
* @param approvalCoordinator 审批协调器
|
||||
* @param turnContextHolder 当前运行轮次上下文持有器
|
||||
* @param skillContext Skill 运行时上下文
|
||||
* @param skillBinding Skill 静态绑定关系
|
||||
* @param emitNormalToolResult 是否由 adapter 发出普通工具结果旁路事件
|
||||
* @return AgentScope 工具
|
||||
*/
|
||||
public AgentTool adapt(AgentToolSpec toolSpec,
|
||||
AgentToolInvoker invoker,
|
||||
AgentRuntimeExecutionContext request,
|
||||
AgentToolApprovalCoordinator approvalCoordinator,
|
||||
AgentRuntimeTurnContextHolder turnContextHolder,
|
||||
AgentSkillRuntimeContext skillContext,
|
||||
AgentSkillBinding skillBinding,
|
||||
boolean emitNormalToolResult) {
|
||||
if (toolSpec == null || toolSpec.getName() == null) {
|
||||
throw new AgentRuntimeException("Agent tool spec and name are required.");
|
||||
}
|
||||
if (invoker == null) {
|
||||
throw new AgentRuntimeException("Agent tool invoker is required: " + toolSpec.getName());
|
||||
}
|
||||
return new RuntimeAgentTool(toolSpec, invoker, request, approvalCoordinator, eventSink, skillContext, skillBinding);
|
||||
return new RuntimeAgentTool(toolSpec, invoker, request, approvalCoordinator, turnContextHolder,
|
||||
skillContext, skillBinding, emitNormalToolResult, true, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将运行时工具声明和调用器转换为可读取当前运行轮次的 AgentScope AgentTool。
|
||||
*
|
||||
* @param toolSpec 工具声明
|
||||
* @param invoker 工具调用器
|
||||
* @param request 运行时级上下文
|
||||
* @param approvalCoordinator 审批协调器
|
||||
* @param turnContextHolder 当前运行轮次上下文持有器
|
||||
* @param skillContext Skill 运行时上下文
|
||||
* @param skillBinding Skill 静态绑定关系
|
||||
* @param emitNormalToolResult 是否由 adapter 发出普通工具结果旁路事件
|
||||
* @param emitSkillStep 是否由 adapter 发出 Skill 步骤旁路事件
|
||||
* @return AgentScope 工具
|
||||
*/
|
||||
public AgentTool adapt(AgentToolSpec toolSpec,
|
||||
AgentToolInvoker invoker,
|
||||
AgentRuntimeExecutionContext request,
|
||||
AgentToolApprovalCoordinator approvalCoordinator,
|
||||
AgentRuntimeTurnContextHolder turnContextHolder,
|
||||
AgentSkillRuntimeContext skillContext,
|
||||
AgentSkillBinding skillBinding,
|
||||
boolean emitNormalToolResult,
|
||||
boolean emitSkillStep) {
|
||||
if (toolSpec == null || toolSpec.getName() == null) {
|
||||
throw new AgentRuntimeException("Agent tool spec and name are required.");
|
||||
}
|
||||
if (invoker == null) {
|
||||
throw new AgentRuntimeException("Agent tool invoker is required: " + toolSpec.getName());
|
||||
}
|
||||
return new RuntimeAgentTool(toolSpec, invoker, request, approvalCoordinator, turnContextHolder,
|
||||
skillContext, skillBinding, emitNormalToolResult, emitSkillStep, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将运行时工具声明和调用器转换为可读取当前运行轮次的 AgentScope AgentTool。
|
||||
*
|
||||
* @param toolSpec 工具声明
|
||||
* @param invoker 工具调用器
|
||||
* @param request 运行时级上下文
|
||||
* @param approvalCoordinator 审批协调器
|
||||
* @param turnContextHolder 当前运行轮次上下文持有器
|
||||
* @param skillContext Skill 运行时上下文
|
||||
* @param skillBinding Skill 静态绑定关系
|
||||
* @param emitNormalToolResult 是否由 adapter 发出普通工具结果旁路事件
|
||||
* @param emitSkillStep 是否由 adapter 发出 Skill 步骤旁路事件
|
||||
* @param handleApprovalInTool 是否在工具执行阶段处理审批
|
||||
* @return AgentScope 工具
|
||||
*/
|
||||
public AgentTool adapt(AgentToolSpec toolSpec,
|
||||
AgentToolInvoker invoker,
|
||||
AgentRuntimeExecutionContext request,
|
||||
AgentToolApprovalCoordinator approvalCoordinator,
|
||||
AgentRuntimeTurnContextHolder turnContextHolder,
|
||||
AgentSkillRuntimeContext skillContext,
|
||||
AgentSkillBinding skillBinding,
|
||||
boolean emitNormalToolResult,
|
||||
boolean emitSkillStep,
|
||||
boolean handleApprovalInTool) {
|
||||
if (toolSpec == null || toolSpec.getName() == null) {
|
||||
throw new AgentRuntimeException("Agent tool spec and name are required.");
|
||||
}
|
||||
if (invoker == null) {
|
||||
throw new AgentRuntimeException("Agent tool invoker is required: " + toolSpec.getName());
|
||||
}
|
||||
return new RuntimeAgentTool(toolSpec, invoker, request, approvalCoordinator, turnContextHolder,
|
||||
skillContext, skillBinding, emitNormalToolResult, emitSkillStep, handleApprovalInTool);
|
||||
}
|
||||
|
||||
private AgentRuntimeTurnContextHolder fixedHolder(AgentRuntimeExecutionContext request,
|
||||
Sinks.Many<AgentRuntimeEvent> eventSink) {
|
||||
AgentRuntimeTurnContextHolder holder = new AgentRuntimeTurnContextHolder();
|
||||
AgentRuntimeEventBridge bridge = new AgentRuntimeEventBridge(request, holder);
|
||||
holder.set(new AgentRuntimeTurnContext(null, eventSink, bridge));
|
||||
return holder;
|
||||
}
|
||||
|
||||
private record RuntimeAgentTool(AgentToolSpec toolSpec,
|
||||
AgentToolInvoker invoker,
|
||||
AgentRunRequest request,
|
||||
AgentRuntimeExecutionContext request,
|
||||
AgentToolApprovalCoordinator approvalCoordinator,
|
||||
Sinks.Many<AgentRuntimeEvent> eventSink,
|
||||
AgentRuntimeTurnContextHolder turnContextHolder,
|
||||
AgentSkillRuntimeContext skillContext,
|
||||
AgentSkillBinding skillBinding) implements AgentTool {
|
||||
AgentSkillBinding skillBinding,
|
||||
boolean emitNormalToolResult,
|
||||
boolean emitSkillStep,
|
||||
boolean handleApprovalInTool) implements AgentTool {
|
||||
|
||||
/**
|
||||
* 获取工具名称。
|
||||
@@ -181,11 +308,14 @@ public class AgentScopeToolAdapter {
|
||||
*/
|
||||
@Override
|
||||
public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
|
||||
emit(toolCallEvent(param == null ? null : param.getToolUseBlock()));
|
||||
AgentRuntimeEvent startEvent = toolExecutionStartEvent(param == null ? null : param.getToolUseBlock());
|
||||
if (startEvent != null) {
|
||||
emit(startEvent);
|
||||
}
|
||||
Map<String, Object> input = param == null || param.getInput() == null
|
||||
? new LinkedHashMap<>()
|
||||
: new LinkedHashMap<>(param.getInput());
|
||||
if (toolSpec.isApprovalRequired()) {
|
||||
if (handleApprovalInTool && toolSpec.isApprovalRequired()) {
|
||||
if (approvalCoordinator == null) {
|
||||
throw new AgentRuntimeException("Agent tool approval coordinator is required: " + toolSpec.getName());
|
||||
}
|
||||
@@ -224,15 +354,17 @@ public class AgentScopeToolAdapter {
|
||||
* @return 工具上下文
|
||||
*/
|
||||
private AgentToolContext buildContext(ToolCallParam param) {
|
||||
AgentRuntimeExecutionContext currentRequest = currentRequest();
|
||||
AgentToolContext context = new AgentToolContext();
|
||||
context.setRequestId(request.getRequestId());
|
||||
context.setTraceId(request.getTraceId());
|
||||
context.setSessionId(request.getSessionId());
|
||||
context.setAgentId(request.getAgentDefinition().getAgentId());
|
||||
context.setRuntimeContext(request.getRuntimeContext());
|
||||
context.setRequestId(currentRequest.getRequestId());
|
||||
context.setTraceId(currentRequest.getTraceId());
|
||||
context.setSessionId(currentRequest.getSessionId());
|
||||
context.setAgentId(currentRequest.getAgentDefinition().getAgentId());
|
||||
context.setRuntimeContext(currentRequest.getRuntimeContext());
|
||||
if (param != null && param.getToolUseBlock() != null) {
|
||||
context.setToolCallId(param.getToolUseBlock().getId());
|
||||
}
|
||||
context.setEventEmitter(this::emit);
|
||||
context.getMetadata().put("toolName", toolSpec.getName());
|
||||
context.getMetadata().put("category", toolSpec.getCategory());
|
||||
appendSkillPayload(context.getMetadata(), activeSkillBinding());
|
||||
@@ -241,7 +373,7 @@ public class AgentScopeToolAdapter {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将运行时结果转换为 AgentScope 结果块。
|
||||
* 将 AgentToolResult 转换为 AgentScope 结果块。
|
||||
*
|
||||
* @param param 工具调用参数
|
||||
* @param result 运行时结果
|
||||
@@ -273,35 +405,39 @@ public class AgentScopeToolAdapter {
|
||||
AgentToolContext context = buildContext(param);
|
||||
AgentToolResult result = invoker.invoke(input, context);
|
||||
ToolResultBlock block = toToolResultBlock(param, result);
|
||||
emit(toolResultEvent(block));
|
||||
// 有状态 runtime 中,普通工具结果由 AgentScope 原生 PostActingEvent
|
||||
// 旁路观察器统一发出;旧 sink 辅助路径没有统一 hook,因此仍允许 adapter 兼容发射。
|
||||
if (emitNormalToolResult || (emitSkillStep && activeSkillBinding() != null)) {
|
||||
emit(toolResultEvent(block));
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
/**
|
||||
* 存在 sink 时发射一条事件。
|
||||
* 通过旁路事件桥发射事件。
|
||||
*
|
||||
* <p>这里发射的是 Easy-Agents 对调用方的监察/交互事件,不是 AgentScope
|
||||
* 主线路消息。主线路的 tool_call/tool_result 顺序仍由 AgentScope 自己维护。</p>
|
||||
*
|
||||
* @param event 事件
|
||||
*/
|
||||
private void emit(AgentRuntimeEvent event) {
|
||||
if (eventSink != null && event != null) {
|
||||
Sinks.EmitResult result = eventSink.tryEmitNext(event);
|
||||
if (result.isFailure()) {
|
||||
throw new AgentRuntimeException("Failed to emit agent runtime event: " + result);
|
||||
}
|
||||
}
|
||||
currentEventBridge().emit(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建工具调用事件。
|
||||
* 构建工具执行开始事件。
|
||||
* 普通工具调用由 AgentScope stream 的 ToolUseBlock 表达,这里仅为已激活 Skill 发射步骤事件。
|
||||
*
|
||||
* @param block 工具使用块
|
||||
* @return 运行时事件
|
||||
* @return Skill 步骤事件,普通工具返回 null
|
||||
*/
|
||||
private AgentRuntimeEvent toolCallEvent(ToolUseBlock block) {
|
||||
private AgentRuntimeEvent toolExecutionStartEvent(ToolUseBlock block) {
|
||||
AgentSkillBinding activeBinding = activeSkillBinding();
|
||||
AgentRuntimeEvent event = baseEvent(activeBinding == null
|
||||
? AgentRuntimeEventType.TOOL_CALL
|
||||
: AgentRuntimeEventType.SKILL_STEP);
|
||||
if (!emitSkillStep || activeBinding == null) {
|
||||
return null;
|
||||
}
|
||||
AgentRuntimeEvent event = baseEvent(AgentRuntimeEventType.SKILL_STEP);
|
||||
if (block != null) {
|
||||
event.setToolCallId(block.getId());
|
||||
event.getPayload().put("name", block.getName());
|
||||
@@ -359,6 +495,7 @@ public class AgentScopeToolAdapter {
|
||||
* @return 工具审批事件
|
||||
*/
|
||||
private AgentRuntimeEvent toolApprovalRequiredEvent(ToolUseBlock toolUseBlock, AgentPendingState pendingState) {
|
||||
AgentRuntimeExecutionContext currentRequest = currentRequest();
|
||||
AgentRuntimeEvent event = baseEvent(AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED);
|
||||
if (pendingState != null && pendingState.getToolCallId() != null) {
|
||||
event.setToolCallId(pendingState.getToolCallId());
|
||||
@@ -367,8 +504,8 @@ public class AgentScopeToolAdapter {
|
||||
payload.put("resumeToken", pendingState == null || pendingState.getResumeToken() == null
|
||||
? UUID.randomUUID().toString()
|
||||
: pendingState.getResumeToken().getValue());
|
||||
payload.put("sessionId", request.getSessionId());
|
||||
payload.put("agentId", request.getAgentDefinition().getAgentId());
|
||||
payload.put("sessionId", currentRequest.getSessionId());
|
||||
payload.put("agentId", currentRequest.getAgentDefinition().getAgentId());
|
||||
payload.put("approvalPrompt", approvalPrompt(pendingState == null ? null : pendingState.getApprovalPrompt()));
|
||||
payload.put("approvalMetadata", pendingState == null ? new LinkedHashMap<>() : pendingState.getMetadata());
|
||||
payload.put("toolInput", pendingState == null ? new LinkedHashMap<>() : pendingState.getToolInput());
|
||||
@@ -404,11 +541,8 @@ public class AgentScopeToolAdapter {
|
||||
* @return 运行时事件
|
||||
*/
|
||||
private AgentRuntimeEvent baseEvent(AgentRuntimeEventType type) {
|
||||
AgentRuntimeEvent event = AgentRuntimeEvent.of(type);
|
||||
event.setTraceId(request.getTraceId());
|
||||
event.setSessionId(request.getSessionId());
|
||||
event.setAgentId(request.getAgentDefinition().getAgentId());
|
||||
event.getMetadata().put("requestId", request.getRequestId());
|
||||
AgentRuntimeExecutionContext currentRequest = currentRequest();
|
||||
AgentRuntimeEvent event = currentEventBridge().event(type);
|
||||
event.getMetadata().put("toolCategory", toolSpec.getCategory().name());
|
||||
event.getMetadata().put("visibility", toolSpec.getVisibility().name());
|
||||
appendSkillPayload(event.getMetadata(), activeSkillBinding());
|
||||
@@ -455,5 +589,16 @@ public class AgentScopeToolAdapter {
|
||||
Object success = event.getMetadata().get("success");
|
||||
return !(success instanceof Boolean) || Boolean.TRUE.equals(success);
|
||||
}
|
||||
|
||||
private AgentRuntimeExecutionContext currentRequest() {
|
||||
return turnContextHolder == null ? request : turnContextHolder.executionContext(request);
|
||||
}
|
||||
|
||||
private AgentRuntimeEventBridge currentEventBridge() {
|
||||
if (turnContextHolder != null && turnContextHolder.eventBridge().isPresent()) {
|
||||
return turnContextHolder.eventBridge().get();
|
||||
}
|
||||
return new AgentRuntimeEventBridge(request, turnContextHolder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.easyagents.agent.runtime.event;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRuntimeException;
|
||||
import com.easyagents.agent.runtime.AgentRuntimeExecutionContext;
|
||||
import reactor.core.publisher.Sinks;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* AgentScope 运行时的旁路事件桥。
|
||||
*
|
||||
* <p>该类只负责 Easy-Agents 自己的旁路监察事件,不负责 AgentScope 主线路事件。
|
||||
* 主线路事件来自 {@code agent.stream(...)},会进入模型会话、工具结果和最终输出的正常顺序;
|
||||
* 旁路事件只给调用方观察运行过程,例如知识库检索、自动上下文压缩、工具审批和 Skill 步骤。</p>
|
||||
*
|
||||
* <p>旁路事件不会写入 AgentScope memory/session,也不会修改 AgentScope {@code Msg}。仅作观察与展示使用</p>
|
||||
*/
|
||||
public class AgentRuntimeEventBridge {
|
||||
|
||||
private final AgentRuntimeExecutionContext fallbackContext;
|
||||
private final AgentRuntimeTurnContextHolder turnContextHolder;
|
||||
|
||||
/**
|
||||
* 创建旁路事件桥。
|
||||
*
|
||||
* @param fallbackContext 运行时级上下文,当前轮次未设置时作为身份信息来源
|
||||
* @param turnContextHolder 当前运行轮次上下文持有器
|
||||
*/
|
||||
public AgentRuntimeEventBridge(AgentRuntimeExecutionContext fallbackContext,
|
||||
AgentRuntimeTurnContextHolder turnContextHolder) {
|
||||
this.fallbackContext = fallbackContext;
|
||||
this.turnContextHolder = turnContextHolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建固定 sink 的旁路事件桥,主要用于旧测试和旧辅助构造路径。
|
||||
*
|
||||
* @param fallbackContext 运行时级上下文
|
||||
* @param eventSink 旁路事件 sink
|
||||
* @return 旁路事件桥
|
||||
*/
|
||||
public static AgentRuntimeEventBridge fixed(AgentRuntimeExecutionContext fallbackContext,
|
||||
Sinks.Many<AgentRuntimeEvent> eventSink) {
|
||||
AgentRuntimeTurnContextHolder holder = new AgentRuntimeTurnContextHolder();
|
||||
AgentRuntimeEventBridge bridge = new AgentRuntimeEventBridge(fallbackContext, holder);
|
||||
holder.set(new AgentRuntimeTurnContext(null, eventSink, bridge));
|
||||
return bridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建指定类型的旁路事件,并自动补齐公共运行身份信息。
|
||||
*
|
||||
* @param type 旁路事件类型
|
||||
* @return 已补齐公共字段的运行时事件
|
||||
*/
|
||||
public AgentRuntimeEvent event(AgentRuntimeEventType type) {
|
||||
AgentRuntimeEvent event = AgentRuntimeEvent.of(type);
|
||||
enrich(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发射一条旁路事件。
|
||||
*
|
||||
* <p>当前没有运行轮次或没有旁路 sink 时直接忽略。这允许 adapter 在非流式测试、
|
||||
* 初始化阶段或主线路未建立旁路订阅时保持无副作用。</p>
|
||||
*
|
||||
* @param event 旁路事件
|
||||
*/
|
||||
public void emit(AgentRuntimeEvent event) {
|
||||
if (event == null) {
|
||||
return;
|
||||
}
|
||||
Optional<Sinks.Many<AgentRuntimeEvent>> sink = eventSink();
|
||||
if (sink.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
enrich(event);
|
||||
Sinks.EmitResult result = sink.get().tryEmitNext(event);
|
||||
if (result.isFailure()) {
|
||||
throw new AgentRuntimeException("Failed to emit agent runtime side event: " + result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前轮次合并后的执行上下文。
|
||||
*
|
||||
* @return 当前执行上下文
|
||||
*/
|
||||
public AgentRuntimeExecutionContext executionContext() {
|
||||
return turnContextHolder == null ? fallbackContext : turnContextHolder.executionContext(fallbackContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 补齐旁路事件的公共身份信息。
|
||||
*
|
||||
* @param event 旁路事件
|
||||
*/
|
||||
public void enrich(AgentRuntimeEvent event) {
|
||||
if (event == null) {
|
||||
return;
|
||||
}
|
||||
AgentRuntimeExecutionContext context = executionContext();
|
||||
if (context == null) {
|
||||
return;
|
||||
}
|
||||
if (event.getTraceId() == null || event.getTraceId().isBlank()) {
|
||||
event.setTraceId(context.getTraceId());
|
||||
}
|
||||
if (event.getSessionId() == null || event.getSessionId().isBlank()) {
|
||||
event.setSessionId(context.getSessionId());
|
||||
}
|
||||
if ((event.getAgentId() == null || event.getAgentId().isBlank())
|
||||
&& context.getAgentDefinition() != null) {
|
||||
event.setAgentId(context.getAgentDefinition().getAgentId());
|
||||
}
|
||||
if (context.getRequestId() != null && !context.getRequestId().isBlank()) {
|
||||
event.getMetadata().putIfAbsent("requestId", context.getRequestId());
|
||||
}
|
||||
Map<String, Object> metadata = context.getMetadata();
|
||||
if (metadata != null && !metadata.isEmpty()) {
|
||||
metadata.forEach(event.getMetadata()::putIfAbsent);
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<Sinks.Many<AgentRuntimeEvent>> eventSink() {
|
||||
return turnContextHolder == null ? Optional.empty() : turnContextHolder.eventSink();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,16 @@ public enum AgentRuntimeEventType {
|
||||
*/
|
||||
REASONING_DELTA,
|
||||
|
||||
/**
|
||||
* 智能体开始一次模型推理。
|
||||
*/
|
||||
REASONING_STARTED,
|
||||
|
||||
/**
|
||||
* 智能体完成一次模型推理。
|
||||
*/
|
||||
REASONING_COMPLETED,
|
||||
|
||||
/**
|
||||
* 流式输出内容,用于流式展示聊天内容。
|
||||
*/
|
||||
@@ -29,11 +39,51 @@ public enum AgentRuntimeEventType {
|
||||
*/
|
||||
TOOL_RESULT,
|
||||
|
||||
/**
|
||||
* 异步工具已提交任务。
|
||||
*/
|
||||
ASYNC_TOOL_SUBMITTED,
|
||||
|
||||
/**
|
||||
* 异步工具已观察任务状态。
|
||||
*/
|
||||
ASYNC_TOOL_OBSERVED,
|
||||
|
||||
/**
|
||||
* 异步工具已读取任务结果。
|
||||
*/
|
||||
ASYNC_TOOL_RESULT,
|
||||
|
||||
/**
|
||||
* 异步工具已请求取消任务。
|
||||
*/
|
||||
ASYNC_TOOL_CANCELLED,
|
||||
|
||||
/**
|
||||
* 异步工具已查询任务列表。
|
||||
*/
|
||||
ASYNC_TOOL_LISTED,
|
||||
|
||||
/**
|
||||
* 异步工具执行失败。
|
||||
*/
|
||||
ASYNC_TOOL_FAILED,
|
||||
|
||||
/**
|
||||
* 知识库检索完成并返回文档摘要。
|
||||
*/
|
||||
KNOWLEDGE_RETRIEVAL,
|
||||
|
||||
/**
|
||||
* 自动上下文压缩已开始。
|
||||
*/
|
||||
MEMORY_COMPRESSION_STARTED,
|
||||
|
||||
/**
|
||||
* 自动上下文压缩已完成。
|
||||
*/
|
||||
MEMORY_COMPRESSION_COMPLETED,
|
||||
|
||||
/**
|
||||
* 工具执行前需要人工审批。
|
||||
*/
|
||||
@@ -64,6 +114,11 @@ public enum AgentRuntimeEventType {
|
||||
*/
|
||||
COMPLETED,
|
||||
|
||||
/**
|
||||
* 智能体运行已暂停,等待外部输入后继续。
|
||||
*/
|
||||
SUSPENDED,
|
||||
|
||||
/**
|
||||
* 智能体运行失败。
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.easyagents.agent.runtime.event;
|
||||
|
||||
import io.agentscope.core.hook.HookEvent;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* AgentScope Hook 事件的主线路干预器。对接 AgentScope 的原生生命周期hook
|
||||
*
|
||||
* <p>干预器允许修改 AgentScope HookEvent,因此会影响主线路执行。
|
||||
* 典型场景包括 AutoContext 在推理前改写输入消息,或后续 HITL 在推理后调用
|
||||
* {@code stopAgent()} 暂停工具执行。普通运行状态通知应使用 {@link AgentRuntimeObserver}。</p>
|
||||
* <p>警告:本干预器会影响到主线路agent 交互,谨慎使用
|
||||
* </p>
|
||||
*/
|
||||
public interface AgentRuntimeInterceptor {
|
||||
|
||||
/**
|
||||
* 处理并返回可能被修改的 AgentScope Hook 事件。
|
||||
*
|
||||
* @param event AgentScope Hook 事件
|
||||
* @param <T> Hook 事件类型
|
||||
* @return 处理后的 Hook 事件
|
||||
*/
|
||||
<T extends HookEvent> Mono<T> intercept(T event);
|
||||
|
||||
/**
|
||||
* 获取干预器执行优先级。
|
||||
*
|
||||
* @return 优先级,数值越小越先执行
|
||||
*/
|
||||
default int priority() {
|
||||
return 100;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.easyagents.agent.runtime.event;
|
||||
|
||||
import io.agentscope.core.hook.HookEvent;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AgentScope Hook 事件的统一观察和干预调度器。
|
||||
*
|
||||
* <p>处理顺序固定为:先执行干预器,再执行观察器。干预器属于主线路能力,
|
||||
* 可以修改 AgentScope HookEvent;观察器属于旁线路能力,只能查看事件并通过
|
||||
* {@link AgentRuntimeEventBridge} 发射对外监察事件。</p>
|
||||
*/
|
||||
public class AgentRuntimeObservationManager {
|
||||
|
||||
private final List<AgentRuntimeInterceptor> interceptors;
|
||||
private final List<AgentRuntimeObserver> observers;
|
||||
|
||||
/**
|
||||
* 创建观察调度器。
|
||||
*
|
||||
* @param interceptors 主线路干预器
|
||||
* @param observers 旁路观察器
|
||||
*/
|
||||
public AgentRuntimeObservationManager(List<AgentRuntimeInterceptor> interceptors,
|
||||
List<AgentRuntimeObserver> observers) {
|
||||
this.interceptors = sortInterceptors(interceptors);
|
||||
this.observers = sortObservers(observers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建空观察调度器。
|
||||
*
|
||||
* @return 空调度器
|
||||
*/
|
||||
public static AgentRuntimeObservationManager empty() {
|
||||
return new AgentRuntimeObservationManager(List.of(), List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 AgentScope Hook 事件。
|
||||
*
|
||||
* @param event Hook 事件
|
||||
* @param <T> Hook 事件类型
|
||||
* @return 处理后的 Hook 事件
|
||||
*/
|
||||
public <T extends HookEvent> Mono<T> handle(T event) {
|
||||
Mono<T> chain = Mono.just(event);
|
||||
for (AgentRuntimeInterceptor interceptor : interceptors) {
|
||||
chain = chain.flatMap(interceptor::intercept);
|
||||
}
|
||||
for (AgentRuntimeObserver observer : observers) {
|
||||
chain = chain.flatMap(current -> observer.observe(current).thenReturn(current));
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
private List<AgentRuntimeInterceptor> sortInterceptors(List<AgentRuntimeInterceptor> source) {
|
||||
List<AgentRuntimeInterceptor> sorted = new ArrayList<>(source == null ? List.of() : source);
|
||||
sorted.sort(Comparator.comparingInt(AgentRuntimeInterceptor::priority));
|
||||
return List.copyOf(sorted);
|
||||
}
|
||||
|
||||
private List<AgentRuntimeObserver> sortObservers(List<AgentRuntimeObserver> source) {
|
||||
List<AgentRuntimeObserver> sorted = new ArrayList<>(source == null ? List.of() : source);
|
||||
sorted.sort(Comparator.comparingInt(AgentRuntimeObserver::priority));
|
||||
return List.copyOf(sorted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.easyagents.agent.runtime.event;
|
||||
|
||||
import io.agentscope.core.hook.HookEvent;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* AgentScope Hook 事件的旁路观察器。对接 AgentScope 的原生生命周期hook
|
||||
*
|
||||
* <p>观察器只做监察和旁路事件发射,不允许修改 AgentScope HookEvent。
|
||||
* 如果能力需要影响主线路,例如修改输入消息、暂停 agent 或替换工具结果,应实现
|
||||
* {@link AgentRuntimeInterceptor}。</p>
|
||||
*/
|
||||
public interface AgentRuntimeObserver {
|
||||
|
||||
/**
|
||||
* 观察 AgentScope Hook 事件。
|
||||
*
|
||||
* @param event AgentScope Hook 事件
|
||||
* @return 完成信号
|
||||
*/
|
||||
Mono<Void> observe(HookEvent event);
|
||||
|
||||
/**
|
||||
* 获取观察器执行优先级。
|
||||
*
|
||||
* @return 优先级,数值越小越先执行
|
||||
*/
|
||||
default int priority() {
|
||||
return 100;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.easyagents.agent.runtime.event;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRuntimeExecutionContext;
|
||||
import reactor.core.publisher.Sinks;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AgentScope 运行时的单轮执行上下文。
|
||||
*/
|
||||
public class AgentRuntimeTurnContext {
|
||||
|
||||
/**
|
||||
* 本轮运行上下文。
|
||||
*/
|
||||
private final AgentRuntimeExecutionContext executionContext;
|
||||
|
||||
/**
|
||||
* 本轮旁路事件 sink。
|
||||
*
|
||||
* <p>该字段只承载旁线路监察事件,不承载 AgentScope 主线路 stream 事件。</p>
|
||||
*/
|
||||
private final Sinks.Many<AgentRuntimeEvent> eventSink;
|
||||
|
||||
/**
|
||||
* 本轮旁路事件桥。
|
||||
*
|
||||
* <p>adapter 和 observer 应优先通过 bridge 发射旁路事件,避免各处重复拼接
|
||||
* trace/session/request 等公共字段。</p>
|
||||
*/
|
||||
private final AgentRuntimeEventBridge eventBridge;
|
||||
|
||||
/**
|
||||
* 创建单轮执行上下文。
|
||||
*
|
||||
* @param executionContext 本轮运行上下文
|
||||
* @param eventSink 本轮旁路事件 sink
|
||||
*/
|
||||
public AgentRuntimeTurnContext(AgentRuntimeExecutionContext executionContext,
|
||||
Sinks.Many<AgentRuntimeEvent> eventSink) {
|
||||
this(executionContext, eventSink, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单轮执行上下文。
|
||||
*
|
||||
* @param executionContext 本轮运行上下文
|
||||
* @param eventSink 本轮旁路事件 sink
|
||||
* @param eventBridge 本轮旁路事件桥
|
||||
*/
|
||||
public AgentRuntimeTurnContext(AgentRuntimeExecutionContext executionContext,
|
||||
Sinks.Many<AgentRuntimeEvent> eventSink,
|
||||
AgentRuntimeEventBridge eventBridge) {
|
||||
this.executionContext = executionContext;
|
||||
this.eventSink = eventSink;
|
||||
this.eventBridge = eventBridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本轮运行上下文。
|
||||
*
|
||||
* @return 本轮运行上下文
|
||||
*/
|
||||
public AgentRuntimeExecutionContext getExecutionContext() {
|
||||
return executionContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本轮旁路事件 sink。
|
||||
*
|
||||
* @return 本轮旁路事件 sink
|
||||
*/
|
||||
public Sinks.Many<AgentRuntimeEvent> getEventSink() {
|
||||
return eventSink;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本轮旁路事件桥。
|
||||
*
|
||||
* @return 本轮旁路事件桥
|
||||
*/
|
||||
public AgentRuntimeEventBridge getEventBridge() {
|
||||
return eventBridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建以当前轮信息覆盖运行时信息后的上下文。
|
||||
*
|
||||
* @param fallback 运行时级上下文
|
||||
* @return 当前轮上下文,未设置时返回运行时级上下文
|
||||
*/
|
||||
public AgentRuntimeExecutionContext mergeWith(AgentRuntimeExecutionContext fallback) {
|
||||
if (executionContext == null) {
|
||||
return fallback;
|
||||
}
|
||||
AgentRuntimeExecutionContext merged = new AgentRuntimeExecutionContext();
|
||||
merged.setRequestId(firstNonBlank(executionContext.getRequestId(), fallback == null ? null : fallback.getRequestId()));
|
||||
merged.setTraceId(firstNonBlank(executionContext.getTraceId(), fallback == null ? null : fallback.getTraceId()));
|
||||
merged.setSessionId(firstNonBlank(executionContext.getSessionId(), fallback == null ? null : fallback.getSessionId()));
|
||||
merged.setAgentDefinition(executionContext.getAgentDefinition() == null && fallback != null
|
||||
? fallback.getAgentDefinition()
|
||||
: executionContext.getAgentDefinition());
|
||||
merged.setRuntimeContext(executionContext.getRuntimeContext() == null && fallback != null
|
||||
? fallback.getRuntimeContext()
|
||||
: executionContext.getRuntimeContext());
|
||||
merged.setUserMessage(executionContext.getUserMessage());
|
||||
merged.setMemorySnapshot(executionContext.getMemorySnapshot());
|
||||
merged.setToolInvokers(fallback == null ? executionContext.getToolInvokers() : fallback.getToolInvokers());
|
||||
merged.setKnowledgeRetrievers(fallback == null
|
||||
? executionContext.getKnowledgeRetrievers()
|
||||
: fallback.getKnowledgeRetrievers());
|
||||
merged.setSessionStore(fallback == null ? executionContext.getSessionStore() : fallback.getSessionStore());
|
||||
merged.setConversationRecorder(fallback == null
|
||||
? executionContext.getConversationRecorder()
|
||||
: fallback.getConversationRecorder());
|
||||
merged.setMetadata(mergedMetadata(fallback, executionContext));
|
||||
merged.setCancelReason(executionContext.getCancelReason());
|
||||
return merged;
|
||||
}
|
||||
|
||||
private Map<String, Object> mergedMetadata(AgentRuntimeExecutionContext fallback,
|
||||
AgentRuntimeExecutionContext current) {
|
||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
if (fallback != null && fallback.getMetadata() != null) {
|
||||
metadata.putAll(fallback.getMetadata());
|
||||
}
|
||||
if (current != null && current.getMetadata() != null) {
|
||||
metadata.putAll(current.getMetadata());
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private String firstNonBlank(String first, String second) {
|
||||
return first == null || first.isBlank() ? second : first;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.easyagents.agent.runtime.event;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRuntimeExecutionContext;
|
||||
import reactor.core.publisher.Sinks;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* 保存当前运行轮次上下文。
|
||||
*/
|
||||
public class AgentRuntimeTurnContextHolder {
|
||||
|
||||
private final AtomicReference<AgentRuntimeTurnContext> current = new AtomicReference<>();
|
||||
|
||||
/**
|
||||
* 设置当前运行轮次上下文。
|
||||
*
|
||||
* @param context 当前轮次上下文
|
||||
*/
|
||||
public void set(AgentRuntimeTurnContext context) {
|
||||
current.set(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理当前运行轮次上下文。
|
||||
*/
|
||||
public void clear() {
|
||||
current.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前运行轮次上下文。
|
||||
*
|
||||
* @return 当前轮次上下文
|
||||
*/
|
||||
public Optional<AgentRuntimeTurnContext> current() {
|
||||
return Optional.ofNullable(current.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前轮次事件 sink。
|
||||
*
|
||||
* @return 当前轮次旁路事件 sink
|
||||
*/
|
||||
public Optional<Sinks.Many<AgentRuntimeEvent>> eventSink() {
|
||||
return current()
|
||||
.map(AgentRuntimeTurnContext::getEventSink)
|
||||
.filter(sink -> sink != null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前轮次旁路事件桥。
|
||||
*
|
||||
* @return 当前轮次旁路事件桥
|
||||
*/
|
||||
public Optional<AgentRuntimeEventBridge> eventBridge() {
|
||||
return current()
|
||||
.map(AgentRuntimeTurnContext::getEventBridge)
|
||||
.filter(bridge -> bridge != null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并当前轮次上下文和运行时级上下文。
|
||||
*
|
||||
* @param fallback 运行时级上下文
|
||||
* @return 合并后的上下文
|
||||
*/
|
||||
public AgentRuntimeExecutionContext executionContext(AgentRuntimeExecutionContext fallback) {
|
||||
return current()
|
||||
.map(context -> context.mergeWith(fallback))
|
||||
.orElse(fallback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package com.easyagents.agent.runtime.event.interceptor;
|
||||
|
||||
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 io.agentscope.core.ReActAgent;
|
||||
import io.agentscope.core.agent.Agent;
|
||||
import io.agentscope.core.hook.HookEvent;
|
||||
import io.agentscope.core.hook.PreCallEvent;
|
||||
import io.agentscope.core.hook.PreReasoningEvent;
|
||||
import io.agentscope.core.memory.Memory;
|
||||
import io.agentscope.core.memory.autocontext.*;
|
||||
import io.agentscope.core.message.Msg;
|
||||
import io.agentscope.core.message.MsgRole;
|
||||
import io.agentscope.core.message.TextBlock;
|
||||
import io.agentscope.core.plan.PlanNotebook;
|
||||
import io.agentscope.core.tool.Toolkit;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* AutoContext 主线路干预器。
|
||||
*
|
||||
* <p><strong>特殊约束:</strong>本干预器是对 AgentScope 官方
|
||||
* {@code AutoContextHook} 的替代实现,不能与官方 {@code AutoContextHook} 同时注册。
|
||||
* 两者同时存在会导致 {@link AutoContextMemory#compressIfNeeded()}、上下文重写以及
|
||||
* {@link ContextOffloadTool} 注册被重复执行。</p>
|
||||
*
|
||||
* <p>本类承担两类职责。第一类是主线路干预:在 {@link PreCallEvent} 中注册
|
||||
* AutoContext 工具能力,在 {@link PreReasoningEvent} 中触发记忆压缩并改写
|
||||
* LLM 输入消息。第二类是旁路通知:通过 {@link AgentRuntimeEventBridge} 发出
|
||||
* {@link AgentRuntimeEventType#MEMORY_COMPRESSION_STARTED} 和
|
||||
* {@link AgentRuntimeEventType#MEMORY_COMPRESSION_COMPLETED},这些事件只用于调用方展示,
|
||||
* 不写入 AgentScope memory/session,也不参与 LLM 会话协议。</p>
|
||||
*/
|
||||
public class AutoContextInterceptor implements AgentRuntimeInterceptor {
|
||||
|
||||
private static final String STATUS_KEY = "memory-compression";
|
||||
private static final String AUTO_CONTEXT_SYSTEM_INSTRUCTION =
|
||||
"You may see compressed messages containing <!-- CONTEXT_OFFLOAD uuid=... -->.\n"
|
||||
+ "- Use the UUID to call context_reload if you need full details.\n"
|
||||
+ "- NEVER mention, quote, or refer to UUIDs, offload tags, or internal metadata in your response.";
|
||||
|
||||
private final AgentRuntimeEventBridge eventBridge;
|
||||
private final AutoContextConfig autoContextConfig;
|
||||
private final AtomicBoolean registered = new AtomicBoolean(false);
|
||||
|
||||
/**
|
||||
* 创建 AutoContext 主线路干预器。
|
||||
*
|
||||
* <p>传入的 {@link AutoContextConfig} 必须是创建目标 {@link AutoContextMemory}
|
||||
* 时使用的同一份配置。这样压缩开始事件的触发条件才能与 AgentScope
|
||||
* {@code compressIfNeeded()} 的入口判断保持一致。</p>
|
||||
*
|
||||
* @param eventBridge 旁路事件桥
|
||||
* @param autoContextConfig AutoContext 配置
|
||||
*/
|
||||
public AutoContextInterceptor(AgentRuntimeEventBridge eventBridge,
|
||||
AutoContextConfig autoContextConfig) {
|
||||
this.eventBridge = eventBridge;
|
||||
this.autoContextConfig = autoContextConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 AutoContext 相关 AgentScope Hook 事件。
|
||||
*
|
||||
* @param event AgentScope Hook 事件
|
||||
* @param <T> Hook 事件类型
|
||||
* @return 处理后的 Hook 事件
|
||||
*/
|
||||
@Override
|
||||
public <T extends HookEvent> Mono<T> intercept(T event) {
|
||||
if (event instanceof PreCallEvent preCallEvent) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Mono<T> result = (Mono<T>) handlePreCall(preCallEvent);
|
||||
return result;
|
||||
}
|
||||
if (event instanceof PreReasoningEvent preReasoningEvent) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Mono<T> result = (Mono<T>) handlePreReasoning(preReasoningEvent);
|
||||
return result;
|
||||
}
|
||||
return Mono.just(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取干预器优先级。
|
||||
*
|
||||
* @return 优先级,保持与官方 AutoContextHook 一致
|
||||
*/
|
||||
@Override
|
||||
public int priority() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 AutoContext 工具集成是否已经注册。
|
||||
*
|
||||
* @return 已注册时返回 true
|
||||
*/
|
||||
public boolean isRegistered() {
|
||||
return registered.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理调用前事件,注册 AutoContext 的上下文重载工具和计划本。
|
||||
*
|
||||
* @param event 调用前事件
|
||||
* @return 原事件
|
||||
*/
|
||||
private Mono<PreCallEvent> handlePreCall(PreCallEvent event) {
|
||||
if (registered.get()) {
|
||||
return Mono.just(event);
|
||||
}
|
||||
Agent agent = event.getAgent();
|
||||
if (!(agent instanceof ReActAgent reActAgent)) {
|
||||
return Mono.just(event);
|
||||
}
|
||||
Memory memory = reActAgent.getMemory();
|
||||
if (!(memory instanceof AutoContextMemory autoContextMemory)) {
|
||||
return Mono.just(event);
|
||||
}
|
||||
if (!registered.compareAndSet(false, true)) {
|
||||
return Mono.just(event);
|
||||
}
|
||||
try {
|
||||
Toolkit toolkit = reActAgent.getToolkit();
|
||||
if (toolkit != null) {
|
||||
toolkit.registerTool(new ContextOffloadTool(autoContextMemory));
|
||||
}
|
||||
PlanNotebook planNotebook = reActAgent.getPlanNotebook();
|
||||
if (planNotebook != null) {
|
||||
autoContextMemory.attachPlanNote(planNotebook);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
registered.set(false);
|
||||
throw new AgentRuntimeException("Failed to register AutoContext integration.", e);
|
||||
}
|
||||
return Mono.just(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理推理前事件,触发 AutoContext 压缩并改写 LLM 输入消息。
|
||||
*
|
||||
* <p>这是主线路干预逻辑:{@code compressIfNeeded()} 和 {@code setInputMessages(...)}
|
||||
* 会影响 AgentScope 本次 reasoning 输入。压缩开始/完成事件则是旁路通知,只发给调用方。</p>
|
||||
*
|
||||
* @param event 推理前事件
|
||||
* @return 改写输入消息后的事件
|
||||
*/
|
||||
private Mono<PreReasoningEvent> handlePreReasoning(PreReasoningEvent event) {
|
||||
Agent agent = event.getAgent();
|
||||
if (!(agent instanceof ReActAgent reActAgent)) {
|
||||
return Mono.just(event);
|
||||
}
|
||||
Memory memory = reActAgent.getMemory();
|
||||
if (!(memory instanceof AutoContextMemory autoContextMemory)) {
|
||||
return Mono.just(event);
|
||||
}
|
||||
// 判断是否达到压缩条件
|
||||
CompressionCheck compressionCheck = compressionCheck(autoContextMemory.getMessages());
|
||||
int beforeEventCount = compressionEventCount(autoContextMemory);
|
||||
if (compressionCheck.thresholdReached()) {
|
||||
emitCompressionStarted(compressionCheck);
|
||||
boolean compressed = autoContextMemory.compressIfNeeded();
|
||||
List<CompressionEvent> newEvents = newCompressionEvents(autoContextMemory, beforeEventCount);
|
||||
emitCompressionCompleted(compressionCheck, newEvents, compressed && !newEvents.isEmpty());
|
||||
}
|
||||
// 压缩完毕后自动进行当前的会话
|
||||
event.setInputMessages(buildInputMessages(event, autoContextMemory));
|
||||
return Mono.just(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算当前记忆是否达到 AutoContext 官方压缩入口条件。
|
||||
*
|
||||
* @param messages 当前工作记忆消息
|
||||
* @return 压缩入口检查结果
|
||||
*/
|
||||
private CompressionCheck compressionCheck(List<Msg> messages) {
|
||||
List<Msg> safeMessages = messages == null ? List.of() : messages;
|
||||
int messageCount = safeMessages.size();
|
||||
int tokenCount = TokenCounterUtil.calculateToken(safeMessages);
|
||||
int thresholdMessageCount = autoContextConfig == null ? Integer.MAX_VALUE : autoContextConfig.getMsgThreshold();
|
||||
long thresholdTokenCount = autoContextConfig == null
|
||||
? Long.MAX_VALUE
|
||||
: (long) (autoContextConfig.getMaxToken() * autoContextConfig.getTokenRatio());
|
||||
boolean messageThresholdReached = messageCount >= thresholdMessageCount;
|
||||
boolean tokenThresholdReached = tokenCount >= thresholdTokenCount;
|
||||
return new CompressionCheck(messageCount, tokenCount, thresholdMessageCount, thresholdTokenCount,
|
||||
messageThresholdReached, tokenThresholdReached);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前压缩事件数量。
|
||||
*
|
||||
* @param autoContextMemory AutoContext 记忆
|
||||
* @return 压缩事件数量
|
||||
*/
|
||||
private int compressionEventCount(AutoContextMemory autoContextMemory) {
|
||||
List<CompressionEvent> events = autoContextMemory.getCompressionEvents();
|
||||
return events == null ? 0 : events.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本次压缩新增的 AgentScope 压缩事件。
|
||||
*
|
||||
* @param autoContextMemory AutoContext 记忆
|
||||
* @param beforeEventCount 压缩前事件数量
|
||||
* @return 新增压缩事件
|
||||
*/
|
||||
private List<CompressionEvent> newCompressionEvents(AutoContextMemory autoContextMemory, int beforeEventCount) {
|
||||
List<CompressionEvent> events = autoContextMemory.getCompressionEvents();
|
||||
if (events == null || events.size() <= beforeEventCount) {
|
||||
return List.of();
|
||||
}
|
||||
return new ArrayList<>(events.subList(beforeEventCount, events.size()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 AutoContext 压缩后传给 LLM 的输入消息。
|
||||
*
|
||||
* @param event 推理前事件
|
||||
* @param autoContextMemory AutoContext 记忆
|
||||
* @return 更新后的输入消息
|
||||
*/
|
||||
private List<Msg> buildInputMessages(PreReasoningEvent event, AutoContextMemory autoContextMemory) {
|
||||
List<Msg> originalInputMessages = event.getInputMessages();
|
||||
List<Msg> newInputMessages = new ArrayList<>();
|
||||
if (!originalInputMessages.isEmpty() && originalInputMessages.get(0).getRole() == MsgRole.SYSTEM) {
|
||||
Msg originalSystemMsg = originalInputMessages.get(0);
|
||||
String originalSystemText = originalSystemMsg.getTextContent();
|
||||
String newSystemText = originalSystemText != null
|
||||
? originalSystemText + "\n\n" + AUTO_CONTEXT_SYSTEM_INSTRUCTION
|
||||
: AUTO_CONTEXT_SYSTEM_INSTRUCTION;
|
||||
newInputMessages.add(Msg.builder()
|
||||
.role(MsgRole.SYSTEM)
|
||||
.name(originalSystemMsg.getName())
|
||||
.content(TextBlock.builder().text(newSystemText).build())
|
||||
.metadata(originalSystemMsg.getMetadata())
|
||||
.build());
|
||||
} else {
|
||||
newInputMessages.add(Msg.builder()
|
||||
.role(MsgRole.SYSTEM)
|
||||
.name("system")
|
||||
.content(TextBlock.builder().text(AUTO_CONTEXT_SYSTEM_INSTRUCTION).build())
|
||||
.build());
|
||||
}
|
||||
newInputMessages.addAll(autoContextMemory.getMessages());
|
||||
return newInputMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发射上下文压缩开始旁路事件。
|
||||
*
|
||||
* @param compressionCheck 压缩入口检查结果
|
||||
*/
|
||||
private void emitCompressionStarted(CompressionCheck compressionCheck) {
|
||||
AgentRuntimeEvent event = eventBridge.event(AgentRuntimeEventType.MEMORY_COMPRESSION_STARTED);
|
||||
event.getPayload().put("statusKey", STATUS_KEY);
|
||||
event.getPayload().put("phase", "started");
|
||||
event.getPayload().put("status", "running");
|
||||
event.getPayload().put("label", "正在整理上下文");
|
||||
putCompressionCheckPayload(event, compressionCheck);
|
||||
eventBridge.emit(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发射上下文压缩完成旁路事件。
|
||||
*
|
||||
* @param compressionCheck 压缩入口检查结果
|
||||
* @param events 新增压缩事件
|
||||
*/
|
||||
private void emitCompressionCompleted(CompressionCheck compressionCheck,
|
||||
List<CompressionEvent> events,
|
||||
boolean compressed) {
|
||||
AgentRuntimeEvent event = eventBridge.event(AgentRuntimeEventType.MEMORY_COMPRESSION_COMPLETED);
|
||||
event.getPayload().put("statusKey", STATUS_KEY);
|
||||
event.getPayload().put("phase", "completed");
|
||||
event.getPayload().put("status", "done");
|
||||
event.getPayload().put("label", compressed ? "已整理上下文" : "无需压缩上下文");
|
||||
event.getPayload().put("compressed", compressed);
|
||||
event.getPayload().put("eventCount", events == null ? 0 : events.size());
|
||||
event.getPayload().put("events", toPayloadEvents(events));
|
||||
putCompressionCheckPayload(event, compressionCheck);
|
||||
eventBridge.emit(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充压缩入口判断相关载荷。
|
||||
*
|
||||
* @param event 运行时事件
|
||||
* @param compressionCheck 压缩入口检查结果
|
||||
*/
|
||||
private void putCompressionCheckPayload(AgentRuntimeEvent event, CompressionCheck compressionCheck) {
|
||||
event.getPayload().put("messageCount", compressionCheck.messageCount());
|
||||
event.getPayload().put("tokenCount", compressionCheck.tokenCount());
|
||||
event.getPayload().put("thresholdMessageCount", compressionCheck.thresholdMessageCount());
|
||||
event.getPayload().put("thresholdTokenCount", compressionCheck.thresholdTokenCount());
|
||||
event.getPayload().put("messageThresholdReached", compressionCheck.messageThresholdReached());
|
||||
event.getPayload().put("tokenThresholdReached", compressionCheck.tokenThresholdReached());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 AgentScope 压缩事件转换为旁路事件载荷。
|
||||
*
|
||||
* @param events AgentScope 压缩事件
|
||||
* @return 压缩事件载荷
|
||||
*/
|
||||
private List<Map<String, Object>> toPayloadEvents(List<CompressionEvent> events) {
|
||||
if (events == null || events.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Map<String, Object>> payloadEvents = new ArrayList<>();
|
||||
for (CompressionEvent event : events) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("eventType", event.getEventType());
|
||||
payload.put("timestamp", event.getTimestamp());
|
||||
payload.put("compressedMessageCount", event.getCompressedMessageCount());
|
||||
payload.put("previousMessageId", event.getPreviousMessageId());
|
||||
payload.put("nextMessageId", event.getNextMessageId());
|
||||
payload.put("compressedMessageId", event.getCompressedMessageId());
|
||||
payload.put("tokenBefore", event.getTokenBefore());
|
||||
payload.put("tokenAfter", event.getTokenAfter());
|
||||
payload.put("tokenReduction", event.getTokenReduction());
|
||||
payload.put("inputToken", event.getCompressInputToken());
|
||||
payload.put("outputToken", event.getCompressOutputToken());
|
||||
payloadEvents.add(payload);
|
||||
}
|
||||
return payloadEvents;
|
||||
}
|
||||
|
||||
private record CompressionCheck(int messageCount,
|
||||
int tokenCount,
|
||||
int thresholdMessageCount,
|
||||
long thresholdTokenCount,
|
||||
boolean messageThresholdReached,
|
||||
boolean tokenThresholdReached) {
|
||||
|
||||
/**
|
||||
* 判断是否达到 AutoContext 官方压缩入口条件。
|
||||
*
|
||||
* @return 达到任一阈值时返回 true
|
||||
*/
|
||||
private boolean thresholdReached() {
|
||||
return messageThresholdReached || tokenThresholdReached;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.easyagents.agent.runtime.event.interceptor;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRuntimeExecutionContext;
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEventBridge;
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeInterceptor;
|
||||
import com.easyagents.agent.runtime.hitl.AgentPendingState;
|
||||
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.message.Msg;
|
||||
import io.agentscope.core.message.ToolUseBlock;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 工具 HITL 主线路干预器。
|
||||
*
|
||||
* <p>本 interceptor 专门处理“工具执行前人工审批”。监听 AgentScope 原生
|
||||
* {@link PostReasoningEvent}</p>
|
||||
*
|
||||
* <p>这里包含两类动作:
|
||||
* <ul>
|
||||
* <li>主线路干预:发现待审批工具后调用 {@link PostReasoningEvent#stopAgent()},
|
||||
* 让 AgentScope 返回当前带 ToolUseBlock 的消息并暂停工具执行。</li>
|
||||
* <li>旁路交互事件:通过 {@link AgentRuntimeEventBridge} 发出
|
||||
* {@link AgentRuntimeEventType#TOOL_APPROVAL_REQUIRED},通知调用方展示审批交互。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>注意:本 interceptor 不执行工具、不写入 AgentScope memory/session,也不实现恢复。
|
||||
* 后续 resume 流程应基于 AgentScope pending tool 状态继续调用 agent stream/call。</p>
|
||||
*/
|
||||
public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
|
||||
|
||||
private final AgentRuntimeEventBridge eventBridge;
|
||||
private final AgentToolApprovalCoordinator approvalCoordinator;
|
||||
private final Map<String, AgentToolSpec> toolSpecs;
|
||||
|
||||
/**
|
||||
* 创建工具 HITL 干预器。
|
||||
*
|
||||
* @param eventBridge 旁路事件桥
|
||||
* @param approvalCoordinator 工具审批协调器
|
||||
* @param toolSpecs 工具声明列表
|
||||
*/
|
||||
public ToolHitlInterceptor(AgentRuntimeEventBridge eventBridge,
|
||||
AgentToolApprovalCoordinator approvalCoordinator,
|
||||
List<AgentToolSpec> toolSpecs) {
|
||||
this.eventBridge = eventBridge;
|
||||
this.approvalCoordinator = approvalCoordinator;
|
||||
this.toolSpecs = (toolSpecs == null ? List.<AgentToolSpec>of() : toolSpecs).stream()
|
||||
.filter(spec -> spec != null && spec.getName() != null && !spec.getName().isBlank())
|
||||
.collect(Collectors.toMap(AgentToolSpec::getName, Function.identity(), (left, right) -> left,
|
||||
LinkedHashMap::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 AgentScope Hook 事件。
|
||||
*
|
||||
* @param event AgentScope Hook 事件
|
||||
* @param <T> Hook 事件类型
|
||||
* @return 处理后的 Hook 事件
|
||||
*/
|
||||
@Override
|
||||
public <T extends HookEvent> Mono<T> intercept(T event) {
|
||||
if (event instanceof PostReasoningEvent postReasoningEvent) {
|
||||
interceptPostReasoning(postReasoningEvent);
|
||||
}
|
||||
return Mono.just(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回执行优先级。
|
||||
*
|
||||
* <p>工具审批需要在普通观察器发出 reasoning completed 后保持事件已被标记暂停,
|
||||
* 但不应早于 AutoContext 的 PreReasoning 干预。当前值用于主线路 reasoning 后检查。</p>
|
||||
*
|
||||
* @return 优先级
|
||||
*/
|
||||
@Override
|
||||
public int priority() {
|
||||
return 50;
|
||||
}
|
||||
|
||||
private void interceptPostReasoning(PostReasoningEvent event) {
|
||||
Msg reasoningMessage = event.getReasoningMessage();
|
||||
if (reasoningMessage == null) {
|
||||
return;
|
||||
}
|
||||
List<ToolUseBlock> approvalRequiredTools = approvalRequiredTools(reasoningMessage);
|
||||
if (approvalRequiredTools.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Map<String, Object>> pendingApprovals = new ArrayList<>();
|
||||
for (ToolUseBlock toolUse : approvalRequiredTools) {
|
||||
AgentToolSpec toolSpec = toolSpecs.get(toolUse.getName());
|
||||
AgentPendingState pendingState = registerPendingState(toolSpec, toolUse);
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
private AgentPendingState registerPendingState(AgentToolSpec toolSpec, ToolUseBlock toolUse) {
|
||||
AgentRuntimeExecutionContext context = eventBridge.executionContext();
|
||||
AgentToolApprovalRequest approvalRequest = toolSpec.getApprovalRequest();
|
||||
Duration timeout = approvalRequest == null || approvalRequest.getTimeout() == null
|
||||
? Duration.ofMinutes(30)
|
||||
: approvalRequest.getTimeout();
|
||||
Map<String, Object> metadata = approvalRequest == null
|
||||
? new LinkedHashMap<>()
|
||||
: new LinkedHashMap<>(approvalRequest.getMetadata());
|
||||
if (toolSpec.getMetadata() != null && !toolSpec.getMetadata().isEmpty()) {
|
||||
metadata.putAll(toolSpec.getMetadata());
|
||||
}
|
||||
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(),
|
||||
toolUse.getId(),
|
||||
toolUse.getName(),
|
||||
approvalPrompt(approvalRequest),
|
||||
toolUse.getInput(),
|
||||
metadata,
|
||||
Instant.now().plus(timeout));
|
||||
}
|
||||
|
||||
private AgentRuntimeEvent toolApprovalRequiredEvent(AgentToolSpec toolSpec,
|
||||
ToolUseBlock toolUse,
|
||||
AgentPendingState pendingState) {
|
||||
AgentRuntimeExecutionContext context = eventBridge.executionContext();
|
||||
AgentRuntimeEvent event = eventBridge.event(AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED);
|
||||
event.setToolCallId(toolUse.getId());
|
||||
event.getPayload().putAll(pendingApprovalPayload(pendingState, toolUse));
|
||||
event.getPayload().put("sessionId", context == null ? null : context.getSessionId());
|
||||
event.getPayload().put("agentId", context == null || context.getAgentDefinition() == null
|
||||
? null
|
||||
: context.getAgentDefinition().getAgentId());
|
||||
event.getPayload().put("approvalPrompt", approvalPrompt(toolSpec.getApprovalRequest()));
|
||||
event.getPayload().put("approvalMetadata", pendingState.getMetadata());
|
||||
event.getPayload().put("toolDescription", toolSpec.getDescription());
|
||||
enrichToolPayload(event.getPayload(), toolSpec);
|
||||
event.getMetadata().put("source", "TOOL_HITL_INTERCEPTOR");
|
||||
event.getMetadata().put("phase", "POST_REASONING");
|
||||
return event;
|
||||
}
|
||||
|
||||
private Map<String, Object> pendingApprovalPayload(AgentPendingState pendingState, ToolUseBlock toolUse) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("resumeToken", pendingState.getResumeToken().getValue());
|
||||
payload.put("toolCallId", pendingState.getToolCallId());
|
||||
payload.put("toolName", pendingState.getToolName());
|
||||
payload.put("toolInput", pendingState.getToolInput());
|
||||
payload.put("input", toolUse.getInput());
|
||||
payload.put("content", toolUse.getContent());
|
||||
payload.put("expiresAt", pendingState.getExpiresAt() == null ? null : pendingState.getExpiresAt().toString());
|
||||
return payload;
|
||||
}
|
||||
|
||||
private void enrichToolPayload(Map<String, Object> payload, AgentToolSpec toolSpec) {
|
||||
if (toolSpec == null || toolSpec.getMetadata() == null || toolSpec.getMetadata().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> metadata = toolSpec.getMetadata();
|
||||
putIfPresent(payload, metadata, "toolDisplayName");
|
||||
putIfPresent(payload, metadata, "rawMcpToolName");
|
||||
putIfPresent(payload, metadata, "mcpToolName");
|
||||
putIfPresent(payload, metadata, "mcpName");
|
||||
putIfPresent(payload, metadata, "mcpTitle");
|
||||
}
|
||||
|
||||
private void putIfPresent(Map<String, Object> payload, Map<String, Object> metadata, String key) {
|
||||
if (metadata.containsKey(key)) {
|
||||
payload.put(key, metadata.get(key));
|
||||
}
|
||||
}
|
||||
|
||||
private String approvalPrompt(AgentToolApprovalRequest approvalRequest) {
|
||||
if (approvalRequest != null
|
||||
&& approvalRequest.getApprovalPrompt() != null
|
||||
&& !approvalRequest.getApprovalPrompt().isBlank()) {
|
||||
return approvalRequest.getApprovalPrompt();
|
||||
}
|
||||
return "是否批准执行该工具?";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.easyagents.agent.runtime.event.observer;
|
||||
|
||||
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.AgentRuntimeObserver;
|
||||
import io.agentscope.core.agent.Agent;
|
||||
import io.agentscope.core.hook.ErrorEvent;
|
||||
import io.agentscope.core.hook.HookEvent;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 监听 AgentScope 原生错误事件,并发射运行失败旁路事件。
|
||||
*
|
||||
* <p>该观察器复用 {@link AgentRuntimeEventType#FAILED},但通过 payload 中的
|
||||
* {@code source=HOOK} 标识它来自 AgentScope 生命周期观察,不等同于 Easy-Agents
|
||||
* runtime 外层流已经完成失败收口。</p>
|
||||
*/
|
||||
public class AgentRuntimeErrorObserver implements AgentRuntimeObserver {
|
||||
|
||||
private final AgentRuntimeEventBridge eventBridge;
|
||||
|
||||
/**
|
||||
* 创建运行错误观察器。
|
||||
*
|
||||
* @param eventBridge 旁路事件桥
|
||||
*/
|
||||
public AgentRuntimeErrorObserver(AgentRuntimeEventBridge eventBridge) {
|
||||
this.eventBridge = eventBridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* 观察 AgentScope 错误事件。
|
||||
*
|
||||
* @param event AgentScope Hook 事件
|
||||
* @return 完成信号
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> observe(HookEvent event) {
|
||||
if (!(event instanceof ErrorEvent errorEvent)) {
|
||||
return Mono.empty();
|
||||
}
|
||||
Throwable error = errorEvent.getError();
|
||||
AgentRuntimeEvent runtimeEvent = eventBridge.event(AgentRuntimeEventType.FAILED);
|
||||
runtimeEvent.getPayload().put("source", "HOOK");
|
||||
runtimeEvent.getPayload().put("phase", "ERROR");
|
||||
runtimeEvent.getPayload().put("stage", "AGENTSCOPE_HOOK");
|
||||
runtimeEvent.getPayload().put("errorType", error == null ? null : error.getClass().getName());
|
||||
runtimeEvent.getPayload().put("message", error == null ? "AgentScope hook error." : error.getMessage());
|
||||
appendAgent(runtimeEvent, errorEvent.getAgent());
|
||||
eventBridge.emit(runtimeEvent);
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
private void appendAgent(AgentRuntimeEvent event, Agent agent) {
|
||||
if (agent == null) {
|
||||
return;
|
||||
}
|
||||
event.getPayload().put("agentName", agent.getName());
|
||||
event.getPayload().put("agentScopeAgentId", agent.getAgentId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.easyagents.agent.runtime.event.observer;
|
||||
|
||||
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.AgentRuntimeObserver;
|
||||
import io.agentscope.core.hook.HookEvent;
|
||||
import io.agentscope.core.hook.PostReasoningEvent;
|
||||
import io.agentscope.core.hook.PreReasoningEvent;
|
||||
import io.agentscope.core.message.Msg;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 监听 AgentScope 推理生命周期,并发射思考状态旁路事件。
|
||||
*
|
||||
* <p>{@link AgentRuntimeEventType#REASONING_DELTA} 表示主线路中的推理内容片段;
|
||||
* 本观察器发射的 started/completed 事件只用于前端状态展示,不携带模型上下文,
|
||||
* 也不会修改 AgentScope 的推理输入或输出。</p>
|
||||
*/
|
||||
public class ReasoningLifecycleObserver implements AgentRuntimeObserver {
|
||||
|
||||
private final AgentRuntimeEventBridge eventBridge;
|
||||
|
||||
/**
|
||||
* 创建推理生命周期观察器。
|
||||
*
|
||||
* @param eventBridge 旁路事件桥
|
||||
*/
|
||||
public ReasoningLifecycleObserver(AgentRuntimeEventBridge eventBridge) {
|
||||
this.eventBridge = eventBridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* 观察推理开始和完成事件。
|
||||
*
|
||||
* @param event AgentScope Hook 事件
|
||||
* @return 完成信号
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> observe(HookEvent event) {
|
||||
if (event instanceof PreReasoningEvent preReasoningEvent) {
|
||||
emitStarted(preReasoningEvent);
|
||||
return Mono.empty();
|
||||
}
|
||||
if (event instanceof PostReasoningEvent postReasoningEvent) {
|
||||
emitCompleted(postReasoningEvent);
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
private void emitStarted(PreReasoningEvent event) {
|
||||
AgentRuntimeEvent runtimeEvent = eventBridge.event(AgentRuntimeEventType.REASONING_STARTED);
|
||||
runtimeEvent.getPayload().put("modelName", event.getModelName());
|
||||
runtimeEvent.getPayload().put("messageCount", event.getInputMessages() == null ? 0 : event.getInputMessages().size());
|
||||
runtimeEvent.getPayload().put("source", "HOOK");
|
||||
runtimeEvent.getPayload().put("phase", "PRE_REASONING");
|
||||
eventBridge.emit(runtimeEvent);
|
||||
}
|
||||
|
||||
private void emitCompleted(PostReasoningEvent event) {
|
||||
AgentRuntimeEvent runtimeEvent = eventBridge.event(AgentRuntimeEventType.REASONING_COMPLETED);
|
||||
runtimeEvent.getPayload().put("modelName", event.getModelName());
|
||||
runtimeEvent.getPayload().put("stopRequested", event.isStopRequested());
|
||||
runtimeEvent.getPayload().put("gotoReasoningRequested", event.isGotoReasoningRequested());
|
||||
runtimeEvent.getPayload().put("text", reasoningText(event.getReasoningMessage()));
|
||||
runtimeEvent.getPayload().put("source", "HOOK");
|
||||
runtimeEvent.getPayload().put("phase", "POST_REASONING");
|
||||
eventBridge.emit(runtimeEvent);
|
||||
}
|
||||
|
||||
private String reasoningText(Msg message) {
|
||||
if (message == null || message.getTextContent() == null) {
|
||||
return "";
|
||||
}
|
||||
return message.getTextContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package com.easyagents.agent.runtime.event.observer;
|
||||
|
||||
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.AgentRuntimeObserver;
|
||||
import com.easyagents.agent.runtime.skill.AgentSkillBinding;
|
||||
import com.easyagents.agent.runtime.skill.AgentSkillLoadCall;
|
||||
import com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext;
|
||||
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 io.agentscope.core.skill.SkillBox;
|
||||
import io.agentscope.core.tool.Toolkit;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.StringJoiner;
|
||||
|
||||
/**
|
||||
* 监听 AgentScope 工具执行生命周期,并发射 Skill 旁路事件。
|
||||
*
|
||||
* <p>该观察器只做 Easy-Agents 的旁路监察,不修改 AgentScope {@link HookEvent}。
|
||||
* Skill 加载工具 {@link AgentSkillRuntimeContext#LOAD_SKILL_TOOL_NAME} 本身仍是
|
||||
* AgentScope 主线路中的工具调用;本观察器只把它翻译成调用方可展示的
|
||||
* {@link AgentRuntimeEventType#SKILL_CALL}、{@link AgentRuntimeEventType#SKILL_RESULT}
|
||||
* 和 {@link AgentRuntimeEventType#SKILL_FAILED}。已激活 Skill 内部的普通工具调用会
|
||||
* 被翻译成 {@link AgentRuntimeEventType#SKILL_STEP}。</p>
|
||||
*
|
||||
* <p>Skill 是否激活以 AgentScope {@link SkillBox} 和 {@link Toolkit#getActiveGroups()}
|
||||
* 为准,本地 {@link AgentSkillRuntimeContext} 只缓存旁路展示所需的归属状态。</p>
|
||||
*/
|
||||
public class SkillExecutionObserver implements AgentRuntimeObserver {
|
||||
|
||||
private final AgentRuntimeEventBridge eventBridge;
|
||||
private final AgentSkillRuntimeContext skillContext;
|
||||
private final SkillBox skillBox;
|
||||
|
||||
/**
|
||||
* 创建 Skill 执行观察器。
|
||||
*
|
||||
* @param eventBridge 旁路事件桥
|
||||
* @param skillContext Skill 运行时上下文
|
||||
*/
|
||||
public SkillExecutionObserver(AgentRuntimeEventBridge eventBridge,
|
||||
AgentSkillRuntimeContext skillContext) {
|
||||
this(eventBridge, skillContext, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Skill 执行观察器。
|
||||
*
|
||||
* @param eventBridge 旁路事件桥
|
||||
* @param skillContext Skill 运行时上下文
|
||||
* @param skillBox AgentScope SkillBox,作为 Skill 激活状态的权威来源
|
||||
*/
|
||||
public SkillExecutionObserver(AgentRuntimeEventBridge eventBridge,
|
||||
AgentSkillRuntimeContext skillContext,
|
||||
SkillBox skillBox) {
|
||||
this.eventBridge = eventBridge;
|
||||
this.skillContext = skillContext;
|
||||
this.skillBox = skillBox;
|
||||
}
|
||||
|
||||
/**
|
||||
* 观察 Skill 加载和已激活 Skill 内部工具执行。
|
||||
*
|
||||
* @param event AgentScope Hook 事件
|
||||
* @return 完成信号
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> observe(HookEvent event) {
|
||||
if (skillContext == null) {
|
||||
return Mono.empty();
|
||||
}
|
||||
if (event instanceof PreActingEvent preActingEvent) {
|
||||
observePreActing(preActingEvent);
|
||||
return Mono.empty();
|
||||
}
|
||||
if (event instanceof PostActingEvent postActingEvent) {
|
||||
observePostActing(postActingEvent);
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
private void observePreActing(PreActingEvent event) {
|
||||
ToolUseBlock toolUse = event.getToolUse();
|
||||
if (toolUse == null) {
|
||||
return;
|
||||
}
|
||||
syncSkillState(toolUse.getName(), event.getToolkit());
|
||||
if (skillContext.isSkillLoadTool(toolUse.getName())) {
|
||||
emitSkillCall(event, toolUse);
|
||||
return;
|
||||
}
|
||||
AgentSkillBinding activeBinding = skillContext.getActiveToolBinding(toolUse.getName());
|
||||
if (activeBinding != null) {
|
||||
emitSkillStepCall(toolUse, activeBinding);
|
||||
}
|
||||
}
|
||||
|
||||
private void observePostActing(PostActingEvent event) {
|
||||
ToolResultBlock result = event.getToolResult();
|
||||
ToolUseBlock toolUse = event.getToolUse();
|
||||
String toolName = result == null ? toolName(toolUse) : result.getName();
|
||||
if (skillContext.isSkillLoadTool(toolName)) {
|
||||
emitSkillResult(result, toolUse, event.getToolkit());
|
||||
return;
|
||||
}
|
||||
syncSkillState(toolName, event.getToolkit());
|
||||
AgentSkillBinding activeBinding = skillContext.getActiveToolBinding(toolName);
|
||||
if (activeBinding != null) {
|
||||
emitSkillStepResult(result, toolUse, activeBinding);
|
||||
}
|
||||
}
|
||||
|
||||
private void emitSkillCall(PreActingEvent event, ToolUseBlock toolUse) {
|
||||
AgentSkillLoadCall call = skillContext.rememberLoadCall(toolUse.getId(), toolUse.getInput());
|
||||
if (!skillContext.markLoadCallEmitted(toolUse.getId())) {
|
||||
return;
|
||||
}
|
||||
AgentRuntimeEvent runtimeEvent = eventBridge.event(AgentRuntimeEventType.SKILL_CALL);
|
||||
runtimeEvent.setToolCallId(toolUse.getId());
|
||||
runtimeEvent.getPayload().put("toolCallId", toolUse.getId());
|
||||
runtimeEvent.getPayload().put("toolName", toolUse.getName());
|
||||
runtimeEvent.getPayload().put("input", toolUse.getInput());
|
||||
runtimeEvent.getPayload().put("status", "RUNNING");
|
||||
runtimeEvent.getPayload().put("source", "HOOK");
|
||||
runtimeEvent.getPayload().put("phase", "PRE_ACTING");
|
||||
appendSkillLoadPayload(runtimeEvent, call);
|
||||
runtimeEvent.getMetadata().putAll(nullToEmpty(toolUse.getMetadata()));
|
||||
eventBridge.emit(runtimeEvent);
|
||||
}
|
||||
|
||||
private void emitSkillResult(ToolResultBlock result, ToolUseBlock toolUse, Toolkit toolkit) {
|
||||
String toolCallId = result == null ? toolCallId(toolUse) : result.getId();
|
||||
AgentSkillLoadCall call = skillContext.removeLoadCall(toolCallId);
|
||||
boolean active = syncSkillState(call, toolkit);
|
||||
AgentRuntimeEvent runtimeEvent = eventBridge.event(skillResultType(result, active));
|
||||
runtimeEvent.setToolCallId(toolCallId);
|
||||
runtimeEvent.getPayload().put("toolCallId", toolCallId);
|
||||
runtimeEvent.getPayload().put("toolName", result == null ? toolName(toolUse) : result.getName());
|
||||
runtimeEvent.getPayload().put("text", resultText(result));
|
||||
runtimeEvent.getPayload().put("status", runtimeEvent.getEventType() == AgentRuntimeEventType.SKILL_RESULT
|
||||
? "SUCCESS"
|
||||
: "FAILED");
|
||||
runtimeEvent.getPayload().put("success", runtimeEvent.getEventType() == AgentRuntimeEventType.SKILL_RESULT);
|
||||
runtimeEvent.getPayload().put("suspended", result != null && result.isSuspended());
|
||||
runtimeEvent.getPayload().put("active", active);
|
||||
runtimeEvent.getPayload().put("source", "HOOK");
|
||||
runtimeEvent.getPayload().put("phase", "POST_ACTING");
|
||||
if (result != null) {
|
||||
runtimeEvent.getMetadata().putAll(nullToEmpty(result.getMetadata()));
|
||||
}
|
||||
appendSkillLoadPayload(runtimeEvent, call);
|
||||
eventBridge.emit(runtimeEvent);
|
||||
}
|
||||
|
||||
private void emitSkillStepCall(ToolUseBlock toolUse, AgentSkillBinding binding) {
|
||||
AgentRuntimeEvent runtimeEvent = eventBridge.event(AgentRuntimeEventType.SKILL_STEP);
|
||||
runtimeEvent.setToolCallId(toolUse.getId());
|
||||
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("stepType", "TOOL_CALL");
|
||||
runtimeEvent.getPayload().put("stepName", toolUse.getName());
|
||||
runtimeEvent.getPayload().put("status", "RUNNING");
|
||||
runtimeEvent.getPayload().put("source", "HOOK");
|
||||
runtimeEvent.getPayload().put("phase", "PRE_ACTING");
|
||||
appendSkillPayload(runtimeEvent.getPayload(), binding);
|
||||
appendSkillPayload(runtimeEvent.getMetadata(), binding);
|
||||
runtimeEvent.getMetadata().putAll(nullToEmpty(toolUse.getMetadata()));
|
||||
eventBridge.emit(runtimeEvent);
|
||||
}
|
||||
|
||||
private void emitSkillStepResult(ToolResultBlock result,
|
||||
ToolUseBlock toolUse,
|
||||
AgentSkillBinding binding) {
|
||||
AgentRuntimeEvent runtimeEvent = eventBridge.event(AgentRuntimeEventType.SKILL_STEP);
|
||||
String toolCallId = result == null ? toolCallId(toolUse) : result.getId();
|
||||
String toolName = result == null ? toolName(toolUse) : result.getName();
|
||||
runtimeEvent.setToolCallId(toolCallId);
|
||||
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("stepType", "TOOL_RESULT");
|
||||
runtimeEvent.getPayload().put("stepName", toolName);
|
||||
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");
|
||||
appendSkillPayload(runtimeEvent.getPayload(), binding);
|
||||
appendSkillPayload(runtimeEvent.getMetadata(), binding);
|
||||
if (result != null) {
|
||||
runtimeEvent.getMetadata().putAll(nullToEmpty(result.getMetadata()));
|
||||
}
|
||||
eventBridge.emit(runtimeEvent);
|
||||
}
|
||||
|
||||
private AgentRuntimeEventType skillResultType(ToolResultBlock result, boolean active) {
|
||||
return success(result) && active ? AgentRuntimeEventType.SKILL_RESULT : AgentRuntimeEventType.SKILL_FAILED;
|
||||
}
|
||||
|
||||
private boolean success(ToolResultBlock result) {
|
||||
if (result == null) {
|
||||
return false;
|
||||
}
|
||||
Object success = result.getMetadata() == null ? null : result.getMetadata().get("success");
|
||||
return !(success instanceof Boolean) || Boolean.TRUE.equals(success);
|
||||
}
|
||||
|
||||
private String resultText(ToolResultBlock result) {
|
||||
if (result == null || result.getOutput() == null || result.getOutput().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
StringJoiner joiner = new StringJoiner("\n");
|
||||
for (ContentBlock output : result.getOutput()) {
|
||||
if (output instanceof TextBlock textBlock && textBlock.getText() != null) {
|
||||
joiner.add(textBlock.getText());
|
||||
} else if (output != null) {
|
||||
joiner.add(output.toString());
|
||||
}
|
||||
}
|
||||
return joiner.toString();
|
||||
}
|
||||
|
||||
private boolean syncSkillState(AgentSkillLoadCall call, Toolkit toolkit) {
|
||||
if (call == null) {
|
||||
return false;
|
||||
}
|
||||
boolean active = isAgentScopeSkillActive(call.getSkillId(), toolkit);
|
||||
skillContext.syncSkillActive(call.getSkillId(), active);
|
||||
return active;
|
||||
}
|
||||
|
||||
private void syncSkillState(String toolName, Toolkit toolkit) {
|
||||
AgentSkillBinding binding = skillContext.getToolBinding(toolName);
|
||||
if (binding == null) {
|
||||
return;
|
||||
}
|
||||
skillContext.syncSkillActive(binding.getSkillId(), isAgentScopeSkillActive(binding.getSkillId(), toolkit));
|
||||
}
|
||||
|
||||
private boolean isAgentScopeSkillActive(String skillId, Toolkit toolkit) {
|
||||
if (skillId == null || skillId.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
if (skillBox != null && skillBox.isSkillActive(skillId)) {
|
||||
return true;
|
||||
}
|
||||
return toolkit != null && toolkit.getActiveGroups().contains(skillToolGroupName(skillId));
|
||||
}
|
||||
|
||||
private String skillToolGroupName(String skillId) {
|
||||
return skillId + "_skill_tools";
|
||||
}
|
||||
|
||||
private void appendSkillLoadPayload(AgentRuntimeEvent event, AgentSkillLoadCall call) {
|
||||
if (call == null) {
|
||||
return;
|
||||
}
|
||||
event.getPayload().put("skillId", call.getSkillId());
|
||||
event.getPayload().put("skillName", call.getSkillName());
|
||||
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("skillBoxId", call.getSkillBoxId());
|
||||
}
|
||||
|
||||
private void appendSkillPayload(Map<String, Object> target, AgentSkillBinding binding) {
|
||||
if (binding == null || target == null) {
|
||||
return;
|
||||
}
|
||||
target.put("skillId", binding.getSkillId());
|
||||
target.put("skillName", binding.getSkillName());
|
||||
target.put("skillBoxId", binding.getSkillBoxId());
|
||||
}
|
||||
|
||||
private String toolCallId(ToolUseBlock toolUse) {
|
||||
return toolUse == null ? null : toolUse.getId();
|
||||
}
|
||||
|
||||
private String toolName(ToolUseBlock toolUse) {
|
||||
return toolUse == null ? null : toolUse.getName();
|
||||
}
|
||||
|
||||
private Map<String, Object> nullToEmpty(Map<String, Object> map) {
|
||||
return map == null ? new LinkedHashMap<>() : map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.easyagents.agent.runtime.event.observer;
|
||||
|
||||
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.AgentRuntimeObserver;
|
||||
import com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolSpec;
|
||||
import io.agentscope.core.hook.HookEvent;
|
||||
import io.agentscope.core.hook.PostActingEvent;
|
||||
import io.agentscope.core.hook.PreActingEvent;
|
||||
import io.agentscope.core.message.ContentBlock;
|
||||
import io.agentscope.core.message.TextBlock;
|
||||
import io.agentscope.core.message.ToolResultBlock;
|
||||
import io.agentscope.core.message.ToolUseBlock;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 监听 AgentScope 原生工具执行生命周期,并发射工具状态旁路事件。
|
||||
*
|
||||
* <p>该观察器复用 {@link AgentRuntimeEventType#TOOL_CALL} 和
|
||||
* {@link AgentRuntimeEventType#TOOL_RESULT},用于 EasyFlow 展示工具开始与完成状态。
|
||||
* 它不修改 AgentScope HookEvent,也不写入模型上下文。</p>
|
||||
*/
|
||||
public class ToolExecutionObserver implements AgentRuntimeObserver {
|
||||
|
||||
private final AgentRuntimeEventBridge eventBridge;
|
||||
private final AgentSkillRuntimeContext skillContext;
|
||||
private final Map<String, AgentToolSpec> toolSpecs;
|
||||
|
||||
/**
|
||||
* 创建工具执行观察器。
|
||||
*
|
||||
* @param eventBridge 旁路事件桥
|
||||
*/
|
||||
public ToolExecutionObserver(AgentRuntimeEventBridge eventBridge) {
|
||||
this(eventBridge, null, List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建工具执行观察器。
|
||||
*
|
||||
* @param eventBridge 旁路事件桥
|
||||
* @param skillContext Skill 上下文,用于跳过由 SkillExecutionObserver 处理的工具
|
||||
*/
|
||||
public ToolExecutionObserver(AgentRuntimeEventBridge eventBridge,
|
||||
AgentSkillRuntimeContext skillContext) {
|
||||
this(eventBridge, skillContext, List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建工具执行观察器。
|
||||
*
|
||||
* @param eventBridge 旁路事件桥
|
||||
* @param skillContext Skill 上下文,用于跳过由 SkillExecutionObserver 处理的工具
|
||||
* @param toolSpecs 工具声明列表,用于补齐展示名称和治理元数据
|
||||
*/
|
||||
public ToolExecutionObserver(AgentRuntimeEventBridge eventBridge,
|
||||
AgentSkillRuntimeContext skillContext,
|
||||
List<AgentToolSpec> toolSpecs) {
|
||||
this.eventBridge = eventBridge;
|
||||
this.skillContext = skillContext;
|
||||
this.toolSpecs = (toolSpecs == null ? List.<AgentToolSpec>of() : toolSpecs).stream()
|
||||
.filter(spec -> spec != null && spec.getName() != null && !spec.getName().isBlank())
|
||||
.collect(Collectors.toMap(AgentToolSpec::getName, Function.identity(), (left, right) -> left,
|
||||
LinkedHashMap::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* 观察工具执行前后事件。
|
||||
*
|
||||
* @param event AgentScope Hook 事件
|
||||
* @return 完成信号
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> observe(HookEvent event) {
|
||||
if (event instanceof PreActingEvent preActingEvent) {
|
||||
emitToolCall(preActingEvent);
|
||||
return Mono.empty();
|
||||
}
|
||||
if (event instanceof PostActingEvent postActingEvent) {
|
||||
emitToolResult(postActingEvent);
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
private void emitToolCall(PreActingEvent event) {
|
||||
ToolUseBlock toolUse = event.getToolUse();
|
||||
if (toolUse == null) {
|
||||
return;
|
||||
}
|
||||
if (isSkillTool(toolUse.getName())) {
|
||||
return;
|
||||
}
|
||||
AgentRuntimeEvent runtimeEvent = eventBridge.event(AgentRuntimeEventType.TOOL_CALL);
|
||||
runtimeEvent.setToolCallId(toolUse.getId());
|
||||
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);
|
||||
}
|
||||
|
||||
private void emitToolResult(PostActingEvent event) {
|
||||
ToolResultBlock result = event.getToolResult();
|
||||
ToolUseBlock toolUse = event.getToolUse();
|
||||
if (result == null && toolUse == null) {
|
||||
return;
|
||||
}
|
||||
String toolCallId = result == null ? toolUse.getId() : result.getId();
|
||||
String toolName = result == null ? toolUse.getName() : result.getName();
|
||||
if (isSkillTool(toolName)) {
|
||||
return;
|
||||
}
|
||||
AgentRuntimeEvent runtimeEvent = eventBridge.event(AgentRuntimeEventType.TOOL_RESULT);
|
||||
runtimeEvent.setToolCallId(toolCallId);
|
||||
runtimeEvent.getPayload().put("toolCallId", toolCallId);
|
||||
runtimeEvent.getPayload().put("name", toolName);
|
||||
runtimeEvent.getPayload().put("toolName", toolName);
|
||||
runtimeEvent.getPayload().put("text", resultText(result));
|
||||
runtimeEvent.getPayload().put("suspended", result != null && result.isSuspended());
|
||||
runtimeEvent.getPayload().put("status", success(result) ? "SUCCESS" : "FAILED");
|
||||
runtimeEvent.getPayload().put("success", success(result));
|
||||
runtimeEvent.getPayload().put("source", "HOOK");
|
||||
runtimeEvent.getPayload().put("phase", "POST_ACTING");
|
||||
if (result != null) {
|
||||
runtimeEvent.getMetadata().putAll(nullToEmpty(result.getMetadata()));
|
||||
}
|
||||
enrichToolPayload(runtimeEvent, toolName);
|
||||
eventBridge.emit(runtimeEvent);
|
||||
}
|
||||
|
||||
private void enrichToolPayload(AgentRuntimeEvent runtimeEvent, String toolName) {
|
||||
AgentToolSpec toolSpec = toolSpecs.get(toolName);
|
||||
if (toolSpec == null || toolSpec.getMetadata() == null || toolSpec.getMetadata().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
private void putIfPresent(Map<String, Object> payload, Map<String, Object> metadata, String key) {
|
||||
if (metadata.containsKey(key)) {
|
||||
payload.put(key, metadata.get(key));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean success(ToolResultBlock result) {
|
||||
if (result == null) {
|
||||
return false;
|
||||
}
|
||||
Object success = result.getMetadata() == null ? null : result.getMetadata().get("success");
|
||||
return !(success instanceof Boolean) || Boolean.TRUE.equals(success);
|
||||
}
|
||||
|
||||
private String resultText(ToolResultBlock result) {
|
||||
if (result == null || result.getOutput() == null || result.getOutput().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
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;
|
||||
}
|
||||
return skillContext.isSkillLoadTool(toolName) || skillContext.getActiveToolBinding(toolName) != null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.easyagents.agent.runtime.hitl;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentResumeRequest;
|
||||
import com.easyagents.agent.runtime.AgentRuntimeException;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Instant;
|
||||
@@ -86,14 +88,14 @@ public class AgentToolApprovalCoordinator {
|
||||
* 等待指定审批令牌的响应。
|
||||
*
|
||||
* @param resumeToken 恢复令牌
|
||||
* @return 审批响应
|
||||
* @return 恢复请求
|
||||
*/
|
||||
public Mono<AgentToolApprovalResponse> await(AgentResumeToken resumeToken) {
|
||||
public Mono<AgentResumeRequest> await(AgentResumeToken resumeToken) {
|
||||
if (!enabled) {
|
||||
AgentToolApprovalResponse response = new AgentToolApprovalResponse();
|
||||
response.setResumeToken(resumeToken);
|
||||
response.setApproved(true);
|
||||
return Mono.just(response);
|
||||
AgentResumeRequest request = new AgentResumeRequest();
|
||||
request.setResumeToken(resumeToken);
|
||||
request.setApproved(true);
|
||||
return Mono.just(request);
|
||||
}
|
||||
if (resumeToken == null || resumeToken.getValue() == null || resumeToken.getValue().isBlank()) {
|
||||
return Mono.error(new AgentToolApprovalRejectedException("缺少审批令牌。"));
|
||||
@@ -106,19 +108,31 @@ public class AgentToolApprovalCoordinator {
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交审批响应。
|
||||
* 消费恢复请求对应的待审批状态。
|
||||
*
|
||||
* @param response 审批响应
|
||||
* <p>该方法用于有状态 runtime 的 HITL resume。第一版 pending state 仅保存在
|
||||
* 当前进程内存中,因此消费成功后会立即移除 token,避免重复恢复。</p>
|
||||
*
|
||||
* @param request 恢复请求
|
||||
* @return 待审批状态
|
||||
*/
|
||||
public void submit(AgentToolApprovalResponse response) {
|
||||
if (!enabled || response == null || response.getResumeToken() == null
|
||||
|| response.getResumeToken().getValue() == null) {
|
||||
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.");
|
||||
}
|
||||
PendingApproval pendingApproval = approvals.get(response.getResumeToken().getValue());
|
||||
if (pendingApproval != null) {
|
||||
pendingApproval.future.complete(response);
|
||||
if (!enabled) {
|
||||
AgentPendingState state = new AgentPendingState();
|
||||
state.setResumeToken(request.getResumeToken());
|
||||
return state;
|
||||
}
|
||||
PendingApproval pendingApproval = approvals.remove(request.getResumeToken().getValue());
|
||||
if (pendingApproval == null) {
|
||||
throw new AgentRuntimeException("Agent resume token is invalid or expired.");
|
||||
}
|
||||
pendingApproval.future.complete(request);
|
||||
return pendingApproval.state;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,11 +145,11 @@ public class AgentToolApprovalCoordinator {
|
||||
return;
|
||||
}
|
||||
for (PendingApproval pendingApproval : approvals.values()) {
|
||||
AgentToolApprovalResponse response = new AgentToolApprovalResponse();
|
||||
response.setResumeToken(pendingApproval.state.getResumeToken());
|
||||
response.setApproved(false);
|
||||
response.setRejectReason(reason);
|
||||
pendingApproval.future.complete(response);
|
||||
AgentResumeRequest request = new AgentResumeRequest();
|
||||
request.setResumeToken(pendingApproval.state.getResumeToken());
|
||||
request.setApproved(false);
|
||||
request.setRejectReason(reason);
|
||||
pendingApproval.future.complete(request);
|
||||
}
|
||||
approvals.clear();
|
||||
}
|
||||
@@ -151,9 +165,9 @@ public class AgentToolApprovalCoordinator {
|
||||
|
||||
private static class PendingApproval {
|
||||
private final AgentPendingState state;
|
||||
private final CompletableFuture<AgentToolApprovalResponse> future;
|
||||
private final CompletableFuture<AgentResumeRequest> future;
|
||||
|
||||
private PendingApproval(AgentPendingState state, CompletableFuture<AgentToolApprovalResponse> future) {
|
||||
private PendingApproval(AgentPendingState state, CompletableFuture<AgentResumeRequest> future) {
|
||||
this.state = Objects.requireNonNull(state, "state");
|
||||
this.future = Objects.requireNonNull(future, "future");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.easyagents.agent.runtime.knowledge.citation;
|
||||
|
||||
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 知识库引用匹配器。
|
||||
*
|
||||
* <p>该接口只负责从最终答案和本轮检索候选中选择可展示的引用。实现不应修改答案文本,
|
||||
* 也不应把引用写入 AgentScope memory/session。</p>
|
||||
*/
|
||||
public interface AgentKnowledgeCitationMatcher {
|
||||
|
||||
/**
|
||||
* 匹配最终答案中可由候选知识片段支撑的引用。
|
||||
*
|
||||
* @param answerText 最终答案文本
|
||||
* @param candidates 本轮检索候选引用
|
||||
* @return 匹配到的知识库引用
|
||||
*/
|
||||
List<AgentKnowledgeReference> match(String answerText, Collection<AgentKnowledgeReference> candidates);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package com.easyagents.agent.runtime.knowledge.citation;
|
||||
|
||||
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 基于文本证据的启发式知识库引用匹配器。
|
||||
*
|
||||
* <p>该实现用于模型没有显式输出引用 ID 的场景。它只能说明答案文本与某些检索片段
|
||||
* 存在较强文本支撑关系,不能证明 LLM 真实使用了该片段。因此匹配策略保持保守:
|
||||
* 宁可少返回引用,也不为了“看起来有引用”而强行猜测。</p>
|
||||
*/
|
||||
public class HeuristicKnowledgeCitationMatcher implements AgentKnowledgeCitationMatcher {
|
||||
|
||||
private static final int MIN_NORMALIZED_ANSWER_LENGTH = 4;
|
||||
private static final int MIN_CONTENT_LENGTH = 6;
|
||||
private static final int MAX_REFERENCES = 5;
|
||||
private static final double MIN_SUPPORT_SCORE = 0.42D;
|
||||
|
||||
/**
|
||||
* 根据最终答案和候选知识片段匹配引用。
|
||||
*
|
||||
* @param answerText 最终答案文本
|
||||
* @param candidates 本轮检索候选引用
|
||||
* @return 高置信引用列表
|
||||
*/
|
||||
@Override
|
||||
public List<AgentKnowledgeReference> match(String answerText, Collection<AgentKnowledgeReference> candidates) {
|
||||
if (candidates == null || candidates.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
String normalizedAnswer = normalize(answerText);
|
||||
if (normalizedAnswer.length() < MIN_NORMALIZED_ANSWER_LENGTH) {
|
||||
return List.of();
|
||||
}
|
||||
List<ScoredKnowledgeReference> scoredReferences = new ArrayList<>();
|
||||
for (AgentKnowledgeReference candidate : candidates) {
|
||||
double supportScore = supportScore(normalizedAnswer, normalize(candidate == null ? null : candidate.getChunkContent()));
|
||||
if (supportScore >= MIN_SUPPORT_SCORE) {
|
||||
scoredReferences.add(new ScoredKnowledgeReference(candidate, supportScore));
|
||||
}
|
||||
}
|
||||
scoredReferences.sort(Comparator.comparingDouble(ScoredKnowledgeReference::score).reversed());
|
||||
return scoredReferences.stream()
|
||||
.limit(MAX_REFERENCES)
|
||||
.map(ScoredKnowledgeReference::reference)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算答案与候选片段之间的文本支撑分。
|
||||
*
|
||||
* @param normalizedAnswer 归一化后的答案
|
||||
* @param normalizedContent 归一化后的候选片段
|
||||
* @return 支撑分
|
||||
*/
|
||||
private double supportScore(String normalizedAnswer, String normalizedContent) {
|
||||
if (normalizedAnswer.isBlank()
|
||||
|| normalizedContent.isBlank()
|
||||
|| normalizedContent.length() < MIN_CONTENT_LENGTH) {
|
||||
return 0D;
|
||||
}
|
||||
if (normalizedContent.contains(normalizedAnswer)) {
|
||||
return 1D;
|
||||
}
|
||||
int longestCommon = longestCommonSubstringLength(normalizedAnswer, normalizedContent);
|
||||
double gramOverlap = gramOverlapRatio(charGrams(normalizedAnswer, 2), charGrams(normalizedContent, 2));
|
||||
double numericCoverage = numericCoverage(normalizedAnswer, normalizedContent);
|
||||
|
||||
// 长连续文本片段比零散关键词更能说明答案来自该 chunk。
|
||||
if (longestCommon >= 8 && gramOverlap >= 0.12D) {
|
||||
double numericBoost = numericCoverage >= 0.8D ? 0.12D : numericCoverage * 0.08D;
|
||||
return Math.max(0.55D, gramOverlap + Math.min(0.35D, longestCommon / 60D) + numericBoost);
|
||||
}
|
||||
if (gramOverlap >= 0.28D && longestCommon >= 5) {
|
||||
return gramOverlap + Math.min(0.25D, longestCommon / 80D);
|
||||
}
|
||||
return gramOverlap * 0.7D + Math.min(0.2D, longestCommon / 80D) + numericCoverage * 0.08D;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化用于引用匹配的文本。
|
||||
*
|
||||
* @param text 原始文本
|
||||
* @return 低噪声文本
|
||||
*/
|
||||
private String normalize(String text) {
|
||||
if (text == null) {
|
||||
return "";
|
||||
}
|
||||
return text.toLowerCase()
|
||||
.replace('至', '到')
|
||||
.replaceAll("[^\\p{IsHan}a-z0-9]", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成字符 ngram。
|
||||
*
|
||||
* @param text 文本
|
||||
* @param size ngram 长度
|
||||
* @return ngram 集合
|
||||
*/
|
||||
private Set<String> charGrams(String text, int size) {
|
||||
Set<String> grams = new HashSet<>();
|
||||
if (text == null || text.length() < size) {
|
||||
return grams;
|
||||
}
|
||||
for (int i = 0; i <= text.length() - size; i++) {
|
||||
grams.add(text.substring(i, i + size));
|
||||
}
|
||||
return grams;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算答案 ngram 被候选片段覆盖的比例。
|
||||
*
|
||||
* @param answerGrams 答案 ngram
|
||||
* @param contentGrams 候选片段 ngram
|
||||
* @return 覆盖比例
|
||||
*/
|
||||
private double gramOverlapRatio(Set<String> answerGrams, Set<String> contentGrams) {
|
||||
if (answerGrams == null || answerGrams.isEmpty() || contentGrams == null || contentGrams.isEmpty()) {
|
||||
return 0D;
|
||||
}
|
||||
int matched = 0;
|
||||
for (String gram : answerGrams) {
|
||||
if (contentGrams.contains(gram)) {
|
||||
matched++;
|
||||
}
|
||||
}
|
||||
return (double) matched / (double) answerGrams.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算答案中的数字片段被候选片段覆盖的比例。
|
||||
*
|
||||
* <p>数字只能作为弱加权因素,不能单独造成高置信引用,避免日期、章节号等噪声误判。</p>
|
||||
*
|
||||
* @param normalizedAnswer 归一化后的答案
|
||||
* @param normalizedContent 归一化后的候选片段
|
||||
* @return 数字覆盖比例
|
||||
*/
|
||||
private double numericCoverage(String normalizedAnswer, String normalizedContent) {
|
||||
List<String> numbers = extractNumericTokens(normalizedAnswer);
|
||||
if (numbers.isEmpty()) {
|
||||
return 0D;
|
||||
}
|
||||
int matched = 0;
|
||||
for (String number : numbers) {
|
||||
if (normalizedContent.contains(number)) {
|
||||
matched++;
|
||||
}
|
||||
}
|
||||
return (double) matched / (double) numbers.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取数字片段。
|
||||
*
|
||||
* @param text 文本
|
||||
* @return 数字片段
|
||||
*/
|
||||
private List<String> extractNumericTokens(String text) {
|
||||
List<String> numbers = new ArrayList<>();
|
||||
if (text == null || text.isBlank()) {
|
||||
return numbers;
|
||||
}
|
||||
java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("\\d+").matcher(text);
|
||||
while (matcher.find()) {
|
||||
numbers.add(matcher.group());
|
||||
}
|
||||
return numbers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算最长公共子串长度。
|
||||
*
|
||||
* @param first 第一段文本
|
||||
* @param second 第二段文本
|
||||
* @return 最长公共子串长度
|
||||
*/
|
||||
private int longestCommonSubstringLength(String first, String second) {
|
||||
if (first == null || second == null || first.isEmpty() || second.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int[] previous = new int[second.length() + 1];
|
||||
int max = 0;
|
||||
for (int i = 1; i <= first.length(); i++) {
|
||||
int[] current = new int[second.length() + 1];
|
||||
for (int j = 1; j <= second.length(); j++) {
|
||||
if (first.charAt(i - 1) == second.charAt(j - 1)) {
|
||||
current[j] = previous[j - 1] + 1;
|
||||
max = Math.max(max, current[j]);
|
||||
}
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
private record ScoredKnowledgeReference(AgentKnowledgeReference reference, double score) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.easyagents.agent.runtime.mcp;
|
||||
|
||||
import io.agentscope.core.tool.mcp.McpClientWrapper;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 为 MCP client 增加运行时工具别名。
|
||||
*/
|
||||
class AliasedMcpClientWrapper extends McpClientWrapper {
|
||||
|
||||
static final String RAW_TOOL_NAME_META_KEY = "easyagentsRawMcpToolName";
|
||||
|
||||
private final McpClientWrapper delegate;
|
||||
private final Map<String, String> rawToAlias;
|
||||
private final Map<String, String> aliasToRaw;
|
||||
private final String toolNamePrefix;
|
||||
|
||||
/**
|
||||
* 创建 MCP client 别名包装器。
|
||||
*
|
||||
* @param delegate 原始 MCP client
|
||||
* @param rawToAlias 原始工具名到运行时工具名的映射
|
||||
*/
|
||||
AliasedMcpClientWrapper(McpClientWrapper delegate, Map<String, String> rawToAlias) {
|
||||
this(delegate, rawToAlias, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 MCP client 别名包装器。
|
||||
*
|
||||
* @param delegate 原始 MCP client
|
||||
* @param rawToAlias 原始工具名到运行时工具名的映射
|
||||
* @param toolNamePrefix 动态工具名前缀
|
||||
*/
|
||||
AliasedMcpClientWrapper(McpClientWrapper delegate, Map<String, String> rawToAlias, String toolNamePrefix) {
|
||||
super(delegate == null ? "mcp" : delegate.getName());
|
||||
this.delegate = delegate;
|
||||
this.rawToAlias = rawToAlias == null ? Map.of() : new LinkedHashMap<>(rawToAlias);
|
||||
this.aliasToRaw = new LinkedHashMap<>();
|
||||
this.toolNamePrefix = toolNamePrefix == null || toolNamePrefix.isBlank() ? null : toolNamePrefix.trim();
|
||||
this.rawToAlias.forEach((rawName, aliasName) -> {
|
||||
if (rawName != null && aliasName != null && !aliasName.isBlank()) {
|
||||
this.aliasToRaw.put(aliasName, rawName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化底层 MCP client。
|
||||
*
|
||||
* @return 初始化完成信号
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> initialize() {
|
||||
return delegate.initialize().doOnSuccess(ignored -> initialized = delegate.isInitialized());
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回已替换为运行时别名的工具列表。
|
||||
*
|
||||
* @return 工具列表
|
||||
*/
|
||||
@Override
|
||||
public Mono<List<McpSchema.Tool>> listTools() {
|
||||
return delegate.listTools().map(this::aliasTools);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 MCP 工具,运行时别名会映射回原始工具名。
|
||||
*
|
||||
* @param toolName 运行时工具名
|
||||
* @param arguments 工具参数
|
||||
* @return 工具调用结果
|
||||
*/
|
||||
@Override
|
||||
public Mono<McpSchema.CallToolResult> callTool(String toolName, Map<String, Object> arguments) {
|
||||
return delegate.callTool(rawToolName(toolName), arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭底层 MCP client。
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
delegate.close();
|
||||
initialized = false;
|
||||
}
|
||||
|
||||
private List<McpSchema.Tool> aliasTools(List<McpSchema.Tool> tools) {
|
||||
if (tools == null || tools.isEmpty()) {
|
||||
cachedTools.clear();
|
||||
return List.of();
|
||||
}
|
||||
List<McpSchema.Tool> aliased = new ArrayList<>();
|
||||
cachedTools.clear();
|
||||
Map<String, String> usedAliases = new LinkedHashMap<>();
|
||||
for (McpSchema.Tool tool : tools) {
|
||||
if (tool == null) {
|
||||
continue;
|
||||
}
|
||||
McpSchema.Tool aliasTool = aliasTool(tool, usedAliases);
|
||||
cachedTools.put(aliasTool.name(), aliasTool);
|
||||
aliased.add(aliasTool);
|
||||
}
|
||||
return aliased;
|
||||
}
|
||||
|
||||
private McpSchema.Tool aliasTool(McpSchema.Tool tool, Map<String, String> usedAliases) {
|
||||
String aliasName = uniqueAliasName(aliasName(tool.name()), tool.name(), usedAliases);
|
||||
if (aliasName == null || aliasName.isBlank() || aliasName.equals(tool.name())) {
|
||||
return tool;
|
||||
}
|
||||
aliasToRaw.put(aliasName, tool.name());
|
||||
Map<String, Object> meta = new LinkedHashMap<>();
|
||||
if (tool.meta() != null) {
|
||||
meta.putAll(tool.meta());
|
||||
}
|
||||
meta.put(RAW_TOOL_NAME_META_KEY, tool.name());
|
||||
return new McpSchema.Tool(aliasName, tool.title(), tool.description(), tool.inputSchema(),
|
||||
tool.outputSchema(), tool.annotations(), meta);
|
||||
}
|
||||
|
||||
private String uniqueAliasName(String aliasName, String rawName, Map<String, String> usedAliases) {
|
||||
if (aliasName == null || aliasName.isBlank()) {
|
||||
return aliasName;
|
||||
}
|
||||
String existingRawName = usedAliases.get(aliasName);
|
||||
if (existingRawName == null || existingRawName.equals(rawName)) {
|
||||
usedAliases.put(aliasName, rawName);
|
||||
return aliasName;
|
||||
}
|
||||
int suffix = 2;
|
||||
String candidate = aliasName + "_" + suffix;
|
||||
while (usedAliases.containsKey(candidate)) {
|
||||
suffix++;
|
||||
candidate = aliasName + "_" + suffix;
|
||||
}
|
||||
usedAliases.put(candidate, rawName);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private String aliasName(String rawName) {
|
||||
String explicitAlias = rawToAlias.get(rawName);
|
||||
if (explicitAlias != null && !explicitAlias.isBlank()) {
|
||||
return explicitAlias;
|
||||
}
|
||||
if (toolNamePrefix == null) {
|
||||
return rawName;
|
||||
}
|
||||
return toolNamePrefix + safeToolNameSegment(rawName);
|
||||
}
|
||||
|
||||
private String safeToolNameSegment(String value) {
|
||||
String normalized = String.valueOf(value == null ? "" : value).trim()
|
||||
.replaceAll("[^A-Za-z0-9_-]", "_")
|
||||
.replaceAll("_+", "_");
|
||||
if (normalized.isBlank()) {
|
||||
return "tool";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String rawToolName(String toolName) {
|
||||
return aliasToRaw.getOrDefault(toolName, toolName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.easyagents.agent.runtime.mcp;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRuntimeException;
|
||||
import io.agentscope.core.tool.mcp.McpClientBuilder;
|
||||
import io.agentscope.core.tool.mcp.McpClientWrapper;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 创建 AgentScope MCP client。
|
||||
*/
|
||||
public class McpClientFactory {
|
||||
|
||||
/**
|
||||
* 根据 MCP 运行时声明创建 AgentScope MCP client。
|
||||
*
|
||||
* @param spec MCP 运行时声明
|
||||
* @return AgentScope MCP client
|
||||
*/
|
||||
public McpClientWrapper create(McpSpec spec) {
|
||||
McpSpecValidator.validateConnection(spec);
|
||||
McpClientBuilder builder = McpClientBuilder.create(spec.getName())
|
||||
.timeout(timeout(spec.getTimeout(), Duration.ofSeconds(120)))
|
||||
.initializationTimeout(timeout(spec.getInitializationTimeout(), Duration.ofSeconds(30)));
|
||||
switch (spec.getTransportType()) {
|
||||
case STDIO -> builder.stdioTransport(spec.getCommand(), spec.getArgs(), spec.getEnv());
|
||||
case SSE -> builder.sseTransport(spec.getUrl())
|
||||
.headers(spec.getHeaders())
|
||||
.queryParams(spec.getQueryParams());
|
||||
case HTTP -> builder.streamableHttpTransport(spec.getUrl())
|
||||
.headers(spec.getHeaders())
|
||||
.queryParams(spec.getQueryParams());
|
||||
default -> throw new AgentRuntimeException("Unsupported MCP transport type: " + spec.getTransportType());
|
||||
}
|
||||
return builder.buildAsync().block();
|
||||
}
|
||||
|
||||
private Duration timeout(Duration value, Duration defaultValue) {
|
||||
return value == null || value.isZero() || value.isNegative() ? defaultValue : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.easyagents.agent.runtime.mcp;
|
||||
|
||||
import com.easyagents.agent.runtime.tool.AgentToolSpec;
|
||||
import io.agentscope.core.tool.mcp.McpClientWrapper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MCP 注册结果。
|
||||
*/
|
||||
public class McpRegistration {
|
||||
|
||||
private final List<McpClientWrapper> clients;
|
||||
private final List<AgentToolSpec> toolSpecs;
|
||||
|
||||
/**
|
||||
* 创建 MCP 注册结果。
|
||||
*
|
||||
* @param clients 已创建 MCP client
|
||||
* @param toolSpecs 已注册工具声明
|
||||
*/
|
||||
public McpRegistration(List<McpClientWrapper> clients, List<AgentToolSpec> toolSpecs) {
|
||||
this.clients = clients == null ? List.of() : new ArrayList<>(clients);
|
||||
this.toolSpecs = toolSpecs == null ? List.of() : new ArrayList<>(toolSpecs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建空注册结果。
|
||||
*
|
||||
* @return 空注册结果
|
||||
*/
|
||||
public static McpRegistration empty() {
|
||||
return new McpRegistration(List.of(), List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已创建 MCP client。
|
||||
*
|
||||
* @return 已创建 MCP client
|
||||
*/
|
||||
public List<McpClientWrapper> getClients() {
|
||||
return clients;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已注册工具声明。
|
||||
*
|
||||
* @return 已注册工具声明
|
||||
*/
|
||||
public List<AgentToolSpec> getToolSpecs() {
|
||||
return toolSpecs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package com.easyagents.agent.runtime.mcp;
|
||||
|
||||
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* MCP 运行时声明。
|
||||
*/
|
||||
public class McpSpec {
|
||||
|
||||
private String name;
|
||||
private String description;
|
||||
private McpTransportType transportType = McpTransportType.STDIO;
|
||||
private String command;
|
||||
private List<String> args = new ArrayList<>();
|
||||
private Map<String, String> env = new LinkedHashMap<>();
|
||||
private String url;
|
||||
private Map<String, String> headers = new LinkedHashMap<>();
|
||||
private Map<String, String> queryParams = new LinkedHashMap<>();
|
||||
private Duration timeout = Duration.ofSeconds(120);
|
||||
private Duration initializationTimeout = Duration.ofSeconds(30);
|
||||
private List<String> enableTools = new ArrayList<>();
|
||||
private List<String> disableTools = new ArrayList<>();
|
||||
private String groupName;
|
||||
private Map<String, Map<String, Object>> presetParameters = new LinkedHashMap<>();
|
||||
private Map<String, String> toolAliases = new LinkedHashMap<>();
|
||||
private String toolNamePrefix;
|
||||
private boolean approvalRequired;
|
||||
private AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest();
|
||||
private Map<String, AgentToolApprovalRequest> toolApprovalRequests = new LinkedHashMap<>();
|
||||
private Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 获取 MCP client 名称。
|
||||
*
|
||||
* @return MCP client 名称
|
||||
*/
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 MCP client 名称。
|
||||
*
|
||||
* @param name MCP client 名称
|
||||
*/
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 MCP 描述。
|
||||
*
|
||||
* @return MCP 描述
|
||||
*/
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 MCP 描述。
|
||||
*
|
||||
* @param description MCP 描述
|
||||
*/
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接方式。
|
||||
*
|
||||
* @return 连接方式
|
||||
*/
|
||||
public McpTransportType getTransportType() {
|
||||
return transportType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置连接方式。
|
||||
*
|
||||
* @param transportType 连接方式
|
||||
*/
|
||||
public void setTransportType(McpTransportType transportType) {
|
||||
this.transportType = transportType == null ? McpTransportType.STDIO : transportType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过字符串设置连接方式。
|
||||
*
|
||||
* @param transportType 连接方式文本
|
||||
*/
|
||||
public void setTransportType(String transportType) {
|
||||
this.transportType = McpTransportType.from(transportType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 stdio 命令。
|
||||
*
|
||||
* @return stdio 命令
|
||||
*/
|
||||
public String getCommand() {
|
||||
return command;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 stdio 命令。
|
||||
*
|
||||
* @param command stdio 命令
|
||||
*/
|
||||
public void setCommand(String command) {
|
||||
this.command = command;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 stdio 参数。
|
||||
*
|
||||
* @return stdio 参数
|
||||
*/
|
||||
public List<String> getArgs() {
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 stdio 参数。
|
||||
*
|
||||
* @param args stdio 参数
|
||||
*/
|
||||
public void setArgs(List<String> args) {
|
||||
this.args = args == null ? new ArrayList<>() : new ArrayList<>(args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 stdio 环境变量。
|
||||
*
|
||||
* @return stdio 环境变量
|
||||
*/
|
||||
public Map<String, String> getEnv() {
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 stdio 环境变量。
|
||||
*
|
||||
* @param env stdio 环境变量
|
||||
*/
|
||||
public void setEnv(Map<String, String> env) {
|
||||
this.env = env == null ? new LinkedHashMap<>() : new LinkedHashMap<>(env);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 HTTP 地址。
|
||||
*
|
||||
* @return HTTP 地址
|
||||
*/
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 HTTP 地址。
|
||||
*
|
||||
* @param url HTTP 地址
|
||||
*/
|
||||
public void setUrl(String url) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 HTTP 请求头。
|
||||
*
|
||||
* @return HTTP 请求头
|
||||
*/
|
||||
public Map<String, String> getHeaders() {
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 HTTP 请求头。
|
||||
*
|
||||
* @param headers HTTP 请求头
|
||||
*/
|
||||
public void setHeaders(Map<String, String> headers) {
|
||||
this.headers = headers == null ? new LinkedHashMap<>() : new LinkedHashMap<>(headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 HTTP 查询参数。
|
||||
*
|
||||
* @return HTTP 查询参数
|
||||
*/
|
||||
public Map<String, String> getQueryParams() {
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 HTTP 查询参数。
|
||||
*
|
||||
* @param queryParams HTTP 查询参数
|
||||
*/
|
||||
public void setQueryParams(Map<String, String> queryParams) {
|
||||
this.queryParams = queryParams == null ? new LinkedHashMap<>() : new LinkedHashMap<>(queryParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取请求超时时间。
|
||||
*
|
||||
* @return 请求超时时间
|
||||
*/
|
||||
public Duration getTimeout() {
|
||||
return timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置请求超时时间。
|
||||
*
|
||||
* @param timeout 请求超时时间
|
||||
*/
|
||||
public void setTimeout(Duration timeout) {
|
||||
this.timeout = timeout == null ? Duration.ofSeconds(120) : timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取初始化超时时间。
|
||||
*
|
||||
* @return 初始化超时时间
|
||||
*/
|
||||
public Duration getInitializationTimeout() {
|
||||
return initializationTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置初始化超时时间。
|
||||
*
|
||||
* @param initializationTimeout 初始化超时时间
|
||||
*/
|
||||
public void setInitializationTimeout(Duration initializationTimeout) {
|
||||
this.initializationTimeout = initializationTimeout == null ? Duration.ofSeconds(30) : initializationTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用工具白名单。
|
||||
*
|
||||
* @return 启用工具白名单
|
||||
*/
|
||||
public List<String> getEnableTools() {
|
||||
return enableTools;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置启用工具白名单。
|
||||
*
|
||||
* @param enableTools 启用工具白名单
|
||||
*/
|
||||
public void setEnableTools(List<String> enableTools) {
|
||||
this.enableTools = enableTools == null ? new ArrayList<>() : new ArrayList<>(enableTools);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取禁用工具黑名单。
|
||||
*
|
||||
* @return 禁用工具黑名单
|
||||
*/
|
||||
public List<String> getDisableTools() {
|
||||
return disableTools;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置禁用工具黑名单。
|
||||
*
|
||||
* @param disableTools 禁用工具黑名单
|
||||
*/
|
||||
public void setDisableTools(List<String> disableTools) {
|
||||
this.disableTools = disableTools == null ? new ArrayList<>() : new ArrayList<>(disableTools);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具分组名。
|
||||
*
|
||||
* @return 工具分组名
|
||||
*/
|
||||
public String getGroupName() {
|
||||
return groupName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置工具分组名。
|
||||
*
|
||||
* @param groupName 工具分组名
|
||||
*/
|
||||
public void setGroupName(String groupName) {
|
||||
this.groupName = groupName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预置参数。
|
||||
*
|
||||
* @return 预置参数
|
||||
*/
|
||||
public Map<String, Map<String, Object>> getPresetParameters() {
|
||||
return presetParameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置预置参数。
|
||||
*
|
||||
* @param presetParameters 预置参数
|
||||
*/
|
||||
public void setPresetParameters(Map<String, Map<String, Object>> presetParameters) {
|
||||
this.presetParameters = presetParameters == null ? new LinkedHashMap<>() : new LinkedHashMap<>(presetParameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 MCP 原始工具名到运行时工具名的别名映射。
|
||||
*
|
||||
* @return 工具别名映射
|
||||
*/
|
||||
public Map<String, String> getToolAliases() {
|
||||
return toolAliases;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 MCP 原始工具名到运行时工具名的别名映射。
|
||||
*
|
||||
* @param toolAliases 工具别名映射
|
||||
*/
|
||||
public void setToolAliases(Map<String, String> toolAliases) {
|
||||
this.toolAliases = toolAliases == null ? new LinkedHashMap<>() : new LinkedHashMap<>(toolAliases);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取动态工具名前缀。
|
||||
*
|
||||
* @return 动态工具名前缀
|
||||
*/
|
||||
public String getToolNamePrefix() {
|
||||
return toolNamePrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置动态工具名前缀。
|
||||
*
|
||||
* @param toolNamePrefix 动态工具名前缀
|
||||
*/
|
||||
public void setToolNamePrefix(String toolNamePrefix) {
|
||||
this.toolNamePrefix = toolNamePrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 MCP 工具是否默认需要人工审批。
|
||||
*
|
||||
* @return 需要审批时为 true
|
||||
*/
|
||||
public boolean isApprovalRequired() {
|
||||
return approvalRequired;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 MCP 工具是否默认需要人工审批。
|
||||
*
|
||||
* @param approvalRequired 审批标记
|
||||
*/
|
||||
public void setApprovalRequired(boolean approvalRequired) {
|
||||
this.approvalRequired = approvalRequired;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审批请求。
|
||||
*
|
||||
* @return 审批请求
|
||||
*/
|
||||
public AgentToolApprovalRequest getApprovalRequest() {
|
||||
return approvalRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置审批请求。
|
||||
*
|
||||
* @param approvalRequest 审批请求
|
||||
*/
|
||||
public void setApprovalRequest(AgentToolApprovalRequest approvalRequest) {
|
||||
this.approvalRequest = approvalRequest == null ? new AgentToolApprovalRequest() : approvalRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取运行时工具名到审批请求的映射。
|
||||
*
|
||||
* @return 工具审批请求映射
|
||||
*/
|
||||
public Map<String, AgentToolApprovalRequest> getToolApprovalRequests() {
|
||||
return toolApprovalRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置运行时工具名到审批请求的映射。
|
||||
*
|
||||
* @param toolApprovalRequests 工具审批请求映射
|
||||
*/
|
||||
public void setToolApprovalRequests(Map<String, AgentToolApprovalRequest> toolApprovalRequests) {
|
||||
this.toolApprovalRequests = toolApprovalRequests == null
|
||||
? new LinkedHashMap<>()
|
||||
: new LinkedHashMap<>(toolApprovalRequests);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元数据。
|
||||
*
|
||||
* @return 元数据
|
||||
*/
|
||||
public Map<String, Object> getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置元数据。
|
||||
*
|
||||
* @param metadata 元数据
|
||||
*/
|
||||
public void setMetadata(Map<String, Object> metadata) {
|
||||
this.metadata = metadata == null ? new LinkedHashMap<>() : new LinkedHashMap<>(metadata);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.easyagents.agent.runtime.mcp;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRuntimeException;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolSpec;
|
||||
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolAdapter;
|
||||
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolSpec;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* MCP 声明校验器。
|
||||
*/
|
||||
public final class McpSpecValidator {
|
||||
|
||||
private McpSpecValidator() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 MCP 连接配置。
|
||||
*
|
||||
* @param spec MCP 运行时声明
|
||||
*/
|
||||
public static void validateConnection(McpSpec spec) {
|
||||
if (spec == null) {
|
||||
throw new AgentRuntimeException("MCP spec is required.");
|
||||
}
|
||||
if (spec.getName() == null || spec.getName().isBlank()) {
|
||||
throw new AgentRuntimeException("MCP name is required.");
|
||||
}
|
||||
if (spec.getTransportType() == null) {
|
||||
throw new AgentRuntimeException("MCP transport type is required: " + spec.getName());
|
||||
}
|
||||
validateToolAliases(spec);
|
||||
switch (spec.getTransportType()) {
|
||||
case STDIO -> {
|
||||
if (spec.getCommand() == null || spec.getCommand().isBlank()) {
|
||||
throw new AgentRuntimeException("MCP stdio command is required: " + spec.getName());
|
||||
}
|
||||
}
|
||||
case SSE, HTTP -> {
|
||||
if (spec.getUrl() == null || spec.getUrl().isBlank()) {
|
||||
throw new AgentRuntimeException("MCP url is required: " + spec.getName());
|
||||
}
|
||||
}
|
||||
default -> throw new AgentRuntimeException("Unsupported MCP transport type: " + spec.getTransportType());
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateToolAliases(McpSpec spec) {
|
||||
Map<String, String> aliases = spec.getToolAliases();
|
||||
if (aliases == null || aliases.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<String> runtimeNames = new HashSet<>();
|
||||
for (Map.Entry<String, String> entry : aliases.entrySet()) {
|
||||
String rawName = entry.getKey();
|
||||
String runtimeName = entry.getValue();
|
||||
if (rawName == null || rawName.isBlank()) {
|
||||
throw new AgentRuntimeException("MCP raw tool name is required: " + spec.getName());
|
||||
}
|
||||
if (runtimeName == null || runtimeName.isBlank()) {
|
||||
throw new AgentRuntimeException("MCP runtime tool name is required: " + spec.getName());
|
||||
}
|
||||
if (!runtimeNames.add(runtimeName)) {
|
||||
throw new AgentRuntimeException("MCP runtime tool alias conflicts: " + runtimeName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 MCP 工具与既有工具名冲突。
|
||||
*
|
||||
* @param businessToolSpecs 普通工具声明
|
||||
* @param mcpToolSpecs MCP 工具声明
|
||||
* @param operateToolSpecs 操作工具声明
|
||||
*/
|
||||
public static void validateToolConflicts(List<AgentToolSpec> businessToolSpecs,
|
||||
List<AgentToolSpec> mcpToolSpecs,
|
||||
List<AgentOperateToolSpec> operateToolSpecs) {
|
||||
Set<String> names = new HashSet<>();
|
||||
addToolNames(names, businessToolSpecs, "Agent tool conflicts with existing tool: ");
|
||||
addToolNames(names, mcpToolSpecs, "MCP tool conflicts with existing tool: ");
|
||||
Set<String> operateToolNames = new AgentOperateToolAdapter().enabledToolNames(operateToolSpecs);
|
||||
for (String operateToolName : operateToolNames) {
|
||||
if (!names.add(operateToolName)) {
|
||||
throw new AgentRuntimeException("Agent operate tool conflicts with existing tool: " + operateToolName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void addToolNames(Set<String> names, List<AgentToolSpec> toolSpecs, String messagePrefix) {
|
||||
if (toolSpecs == null || toolSpecs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (AgentToolSpec toolSpec : toolSpecs) {
|
||||
if (toolSpec == null || toolSpec.getName() == null || toolSpec.getName().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
if (!names.add(toolSpec.getName())) {
|
||||
throw new AgentRuntimeException(messagePrefix + toolSpec.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package com.easyagents.agent.runtime.mcp;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRuntimeException;
|
||||
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolCategory;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolSpec;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolVisibility;
|
||||
import io.agentscope.core.tool.Toolkit;
|
||||
import io.agentscope.core.tool.mcp.McpClientWrapper;
|
||||
import io.agentscope.core.tool.mcp.McpTool;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* 将 MCP 运行时声明注册到 AgentScope Toolkit。
|
||||
*/
|
||||
public class McpToolkitAdapter {
|
||||
|
||||
private final McpClientFactory clientFactory;
|
||||
|
||||
/**
|
||||
* 使用默认 MCP client factory 创建适配器。
|
||||
*/
|
||||
public McpToolkitAdapter() {
|
||||
this(new McpClientFactory());
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定 MCP client factory 创建适配器。
|
||||
*
|
||||
* @param clientFactory MCP client factory
|
||||
*/
|
||||
public McpToolkitAdapter(McpClientFactory clientFactory) {
|
||||
this.clientFactory = clientFactory == null ? new McpClientFactory() : clientFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 MCP 声明注册到 Toolkit。
|
||||
*
|
||||
* @param specs MCP 声明
|
||||
* @param toolkit AgentScope Toolkit
|
||||
* @return MCP 注册结果
|
||||
*/
|
||||
public McpRegistration register(List<McpSpec> specs, Toolkit toolkit) {
|
||||
if (specs == null || specs.isEmpty()) {
|
||||
return McpRegistration.empty();
|
||||
}
|
||||
if (toolkit == null) {
|
||||
throw new AgentRuntimeException("AgentScope toolkit is required for MCP registration.");
|
||||
}
|
||||
List<McpClientWrapper> clients = new ArrayList<>();
|
||||
List<AgentToolSpec> toolSpecs = new ArrayList<>();
|
||||
try {
|
||||
for (McpSpec spec : specs) {
|
||||
if (spec == null) {
|
||||
continue;
|
||||
}
|
||||
McpSpecValidator.validateConnection(spec);
|
||||
McpClientWrapper client = clientFactory.create(spec);
|
||||
client = applyAliases(spec, client);
|
||||
clients.add(client);
|
||||
registerClient(spec, client, toolkit);
|
||||
toolSpecs.addAll(toToolSpecs(spec, registeredTools(spec, client)));
|
||||
}
|
||||
} catch (RuntimeException error) {
|
||||
closeQuietly(clients);
|
||||
throw error;
|
||||
}
|
||||
return new McpRegistration(clients, toolSpecs);
|
||||
}
|
||||
|
||||
private McpClientWrapper applyAliases(McpSpec spec, McpClientWrapper client) {
|
||||
boolean hasExplicitAliases = spec.getToolAliases() != null && !spec.getToolAliases().isEmpty();
|
||||
boolean hasDynamicPrefix = spec.getToolNamePrefix() != null && !spec.getToolNamePrefix().isBlank();
|
||||
if (!hasExplicitAliases && !hasDynamicPrefix) {
|
||||
return client;
|
||||
}
|
||||
return new AliasedMcpClientWrapper(client, spec.getToolAliases(), spec.getToolNamePrefix());
|
||||
}
|
||||
|
||||
private void registerClient(McpSpec spec, McpClientWrapper client, Toolkit toolkit) {
|
||||
String groupName = blankToNull(spec.getGroupName());
|
||||
if (groupName != null && toolkit.getToolGroup(groupName) == null) {
|
||||
toolkit.createToolGroup(groupName, spec.getDescription(), true);
|
||||
}
|
||||
toolkit.registration()
|
||||
.mcpClient(client)
|
||||
.enableTools(emptyToNull(spec.getEnableTools()))
|
||||
.disableTools(emptyToNull(spec.getDisableTools()))
|
||||
.group(groupName)
|
||||
.presetParameters(emptyToNull(spec.getPresetParameters()))
|
||||
.apply();
|
||||
}
|
||||
|
||||
private List<McpSchema.Tool> registeredTools(McpSpec spec, McpClientWrapper client) {
|
||||
List<McpSchema.Tool> tools = client.listTools().block();
|
||||
if (tools == null || tools.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<McpSchema.Tool> filtered = new ArrayList<>();
|
||||
for (McpSchema.Tool tool : tools) {
|
||||
if (tool != null && shouldRegister(tool.name(), spec.getEnableTools(), spec.getDisableTools())) {
|
||||
filtered.add(tool);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private boolean shouldRegister(String toolName, List<String> enableTools, List<String> disableTools) {
|
||||
if (enableTools != null && !enableTools.isEmpty()) {
|
||||
return enableTools.contains(toolName);
|
||||
}
|
||||
return disableTools == null || disableTools.isEmpty() || !disableTools.contains(toolName);
|
||||
}
|
||||
|
||||
private List<AgentToolSpec> toToolSpecs(McpSpec spec, List<McpSchema.Tool> tools) {
|
||||
if (tools == null || tools.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<AgentToolSpec> toolSpecs = new ArrayList<>();
|
||||
for (McpSchema.Tool tool : tools) {
|
||||
AgentToolSpec toolSpec = new AgentToolSpec();
|
||||
Set<String> excludedPresetNames = Set.of();
|
||||
Map<String, Object> toolPresetParameters = spec.getPresetParameters() == null
|
||||
? null
|
||||
: spec.getPresetParameters().get(tool.name());
|
||||
if (toolPresetParameters != null) {
|
||||
excludedPresetNames = toolPresetParameters.keySet();
|
||||
}
|
||||
toolSpec.setName(tool.name());
|
||||
toolSpec.setDescription(tool.description());
|
||||
toolSpec.setCategory(AgentToolCategory.MCP);
|
||||
toolSpec.setVisibility(AgentToolVisibility.VISIBLE);
|
||||
toolSpec.setParametersSchema(McpTool.convertMcpSchemaToParameters(tool.inputSchema(), excludedPresetNames));
|
||||
toolSpec.setOutputSchema(tool.outputSchema());
|
||||
AgentToolApprovalRequest toolApprovalRequest = toolApprovalRequest(spec, tool.name());
|
||||
toolSpec.setApprovalRequired(spec.isApprovalRequired() || toolApprovalRequest != null);
|
||||
toolSpec.setApprovalRequest(toolApprovalRequest == null ? spec.getApprovalRequest() : toolApprovalRequest);
|
||||
toolSpec.setMetadata(metadata(spec, tool));
|
||||
toolSpecs.add(toolSpec);
|
||||
}
|
||||
return toolSpecs;
|
||||
}
|
||||
|
||||
private AgentToolApprovalRequest toolApprovalRequest(McpSpec spec, String toolName) {
|
||||
if (spec.getToolApprovalRequests() == null || spec.getToolApprovalRequests().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return spec.getToolApprovalRequests().get(toolName);
|
||||
}
|
||||
|
||||
private Map<String, Object> metadata(McpSpec spec, McpSchema.Tool tool) {
|
||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
if (spec.getMetadata() != null) {
|
||||
spec.getMetadata().forEach((key, value) -> {
|
||||
if (!isSensitiveMetadataKey(key)) {
|
||||
metadata.put(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
metadata.put("source", "MCP");
|
||||
metadata.put("mcpName", spec.getName());
|
||||
metadata.put("mcpToolName", tool.name());
|
||||
metadata.put("rawMcpToolName", rawToolName(spec, tool));
|
||||
metadata.put("toolDisplayName", toolDisplayName(spec, tool));
|
||||
metadata.put("transportType", spec.getTransportType().configValue());
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private String rawToolName(McpSpec spec, McpSchema.Tool tool) {
|
||||
if (tool != null && tool.meta() != null) {
|
||||
Object rawName = tool.meta().get(AliasedMcpClientWrapper.RAW_TOOL_NAME_META_KEY);
|
||||
if (rawName != null && !String.valueOf(rawName).isBlank()) {
|
||||
return String.valueOf(rawName);
|
||||
}
|
||||
}
|
||||
String toolName = tool == null ? null : tool.name();
|
||||
if (spec.getToolAliases() != null && !spec.getToolAliases().isEmpty()) {
|
||||
for (Map.Entry<String, String> entry : spec.getToolAliases().entrySet()) {
|
||||
if (Objects.equals(entry.getValue(), toolName)) {
|
||||
return entry.getKey();
|
||||
}
|
||||
}
|
||||
}
|
||||
return toolName;
|
||||
}
|
||||
|
||||
private String toolDisplayName(McpSpec spec, McpSchema.Tool tool) {
|
||||
String rawToolName = rawToolName(spec, tool);
|
||||
String mcpName = spec == null ? null : spec.getDescription();
|
||||
if (mcpName == null || mcpName.isBlank()) {
|
||||
mcpName = spec == null ? null : spec.getName();
|
||||
}
|
||||
if (mcpName == null || mcpName.isBlank()) {
|
||||
return rawToolName;
|
||||
}
|
||||
if (rawToolName == null || rawToolName.isBlank()) {
|
||||
return mcpName;
|
||||
}
|
||||
return mcpName + " - " + rawToolName;
|
||||
}
|
||||
|
||||
private boolean isSensitiveMetadataKey(String key) {
|
||||
if (key == null || key.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String normalized = key.toLowerCase(Locale.ROOT).replace("-", "").replace("_", "");
|
||||
return normalized.contains("key")
|
||||
|| normalized.contains("token")
|
||||
|| normalized.contains("secret")
|
||||
|| normalized.contains("password")
|
||||
|| normalized.contains("authorization")
|
||||
|| normalized.contains("credential");
|
||||
}
|
||||
|
||||
private void closeQuietly(List<McpClientWrapper> clients) {
|
||||
for (McpClientWrapper client : clients) {
|
||||
if (client == null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
client.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private <T> List<T> emptyToNull(List<T> values) {
|
||||
return values == null || values.isEmpty() ? null : values;
|
||||
}
|
||||
|
||||
private <K, V> Map<K, V> emptyToNull(Map<K, V> values) {
|
||||
return values == null || values.isEmpty() ? null : values;
|
||||
}
|
||||
|
||||
private String blankToNull(String value) {
|
||||
return value == null || value.isBlank() ? null : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.easyagents.agent.runtime.mcp;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRuntimeException;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* MCP 连接方式。
|
||||
*/
|
||||
public enum McpTransportType {
|
||||
|
||||
/**
|
||||
* 标准输入输出进程通信。
|
||||
*/
|
||||
STDIO,
|
||||
|
||||
/**
|
||||
* HTTP Server-Sent Events 通信。
|
||||
*/
|
||||
SSE,
|
||||
|
||||
/**
|
||||
* Streamable HTTP 通信。
|
||||
*/
|
||||
HTTP;
|
||||
|
||||
/**
|
||||
* 解析 MCP 连接方式。
|
||||
*
|
||||
* @param value 连接方式文本
|
||||
* @return MCP 连接方式
|
||||
*/
|
||||
public static McpTransportType from(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return STDIO;
|
||||
}
|
||||
String normalized = value.trim().toLowerCase(Locale.ROOT);
|
||||
return switch (normalized) {
|
||||
case "stdio" -> STDIO;
|
||||
case "sse", "http-sse" -> SSE;
|
||||
case "http", "streamable-http", "http-stream" -> HTTP;
|
||||
default -> throw new AgentRuntimeException("Unsupported MCP transport type: " + value);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 Easy MCP 配置兼容值。
|
||||
*
|
||||
* @return transport 配置值
|
||||
*/
|
||||
public String configValue() {
|
||||
return switch (this) {
|
||||
case STDIO -> "stdio";
|
||||
case SSE -> "http-sse";
|
||||
case HTTP -> "http-stream";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,12 @@ package com.easyagents.agent.runtime.memory;
|
||||
public class AgentMemoryCompressionParameter {
|
||||
|
||||
private boolean enabled = true;
|
||||
private int msgThreshold = 20;
|
||||
private Integer msgThreshold;
|
||||
private int lastKeep = 8;
|
||||
private double tokenRatio = 0.7D;
|
||||
private long maxToken = 12000L;
|
||||
private Double tokenRatio;
|
||||
private Long maxToken;
|
||||
private long largePayloadThreshold = 2048L;
|
||||
private int minCompressionTokenThreshold = 1000;
|
||||
private Integer minCompressionTokenThreshold;
|
||||
private double currentRoundCompressionRatio = 0.5D;
|
||||
private int minConsecutiveToolMessages = 4;
|
||||
|
||||
@@ -38,7 +38,7 @@ public class AgentMemoryCompressionParameter {
|
||||
*
|
||||
* @return 消息阈值
|
||||
*/
|
||||
public int getMsgThreshold() {
|
||||
public Integer getMsgThreshold() {
|
||||
return msgThreshold;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ public class AgentMemoryCompressionParameter {
|
||||
* @param msgThreshold 消息阈值
|
||||
*/
|
||||
public void setMsgThreshold(int msgThreshold) {
|
||||
this.msgThreshold = msgThreshold <= 0 ? 20 : msgThreshold;
|
||||
this.msgThreshold = msgThreshold <= 0 ? null : msgThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,7 +74,7 @@ public class AgentMemoryCompressionParameter {
|
||||
*
|
||||
* @return Token 比例
|
||||
*/
|
||||
public double getTokenRatio() {
|
||||
public Double getTokenRatio() {
|
||||
return tokenRatio;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ public class AgentMemoryCompressionParameter {
|
||||
* @param tokenRatio Token 比例
|
||||
*/
|
||||
public void setTokenRatio(double tokenRatio) {
|
||||
this.tokenRatio = tokenRatio <= 0D ? 0.7D : tokenRatio;
|
||||
this.tokenRatio = tokenRatio <= 0D ? null : tokenRatio;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,7 +92,7 @@ public class AgentMemoryCompressionParameter {
|
||||
*
|
||||
* @return 最大 Token 数
|
||||
*/
|
||||
public long getMaxToken() {
|
||||
public Long getMaxToken() {
|
||||
return maxToken;
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ public class AgentMemoryCompressionParameter {
|
||||
* @param maxToken 最大 Token 数
|
||||
*/
|
||||
public void setMaxToken(long maxToken) {
|
||||
this.maxToken = maxToken <= 0L ? 12000L : maxToken;
|
||||
this.maxToken = maxToken <= 0L ? null : maxToken;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,7 +128,7 @@ public class AgentMemoryCompressionParameter {
|
||||
*
|
||||
* @return 最小压缩 Token 阈值
|
||||
*/
|
||||
public int getMinCompressionTokenThreshold() {
|
||||
public Integer getMinCompressionTokenThreshold() {
|
||||
return minCompressionTokenThreshold;
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ public class AgentMemoryCompressionParameter {
|
||||
* @param minCompressionTokenThreshold 最小压缩 Token 阈值
|
||||
*/
|
||||
public void setMinCompressionTokenThreshold(int minCompressionTokenThreshold) {
|
||||
this.minCompressionTokenThreshold = Math.max(0, minCompressionTokenThreshold);
|
||||
this.minCompressionTokenThreshold = minCompressionTokenThreshold <= 0 ? null : minCompressionTokenThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,7 +31,7 @@ public class AgentMemorySnapshot {
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加one message。
|
||||
* 添加消息。
|
||||
*
|
||||
* @param message 消息
|
||||
*/
|
||||
|
||||
@@ -13,6 +13,7 @@ public class AgentKnowledgeReference {
|
||||
private String documentId;
|
||||
private String documentName;
|
||||
private String chunkId;
|
||||
private String chunkContent;
|
||||
private String sourceUri;
|
||||
private Double score;
|
||||
private Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
@@ -107,6 +108,24 @@ public class AgentKnowledgeReference {
|
||||
this.chunkId = chunkId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取命中分片原文。
|
||||
*
|
||||
* @return 命中分片原文
|
||||
*/
|
||||
public String getChunkContent() {
|
||||
return chunkContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置命中分片原文。
|
||||
*
|
||||
* @param chunkContent 命中分片原文
|
||||
*/
|
||||
public void setChunkContent(String chunkContent) {
|
||||
this.chunkContent = chunkContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取来源 URI。
|
||||
*
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
package com.easyagents.agent.runtime.persistence;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 运行时状态项。
|
||||
*
|
||||
* <p>MVP 中 value 是 JVM 对象态,不是稳定的传输格式,
|
||||
* 非内存 {@link AgentSessionStore} 实现必须显式完成序列化,
|
||||
* 再写入外部存储。</p>
|
||||
*/
|
||||
public class AgentRuntimeState {
|
||||
|
||||
private String name;
|
||||
private Object value;
|
||||
private Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 创建a 状态项。
|
||||
*
|
||||
* @param name 状态名称
|
||||
* @param value 状态值
|
||||
* @return 状态项
|
||||
*/
|
||||
public static AgentRuntimeState of(String name, Object value) {
|
||||
AgentRuntimeState state = new AgentRuntimeState();
|
||||
state.setName(name);
|
||||
state.setValue(value);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态名称。
|
||||
*
|
||||
* @return 状态名称
|
||||
*/
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置状态名称。
|
||||
*
|
||||
* @param name 状态名称
|
||||
*/
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态值。
|
||||
*
|
||||
* @return 状态值
|
||||
*/
|
||||
public Object getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置状态值。
|
||||
*
|
||||
* @param value 状态值
|
||||
*/
|
||||
public void setValue(Object value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元数据。
|
||||
*
|
||||
* @return 元数据
|
||||
*/
|
||||
public Map<String, Object> getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置元数据。
|
||||
*
|
||||
* @param metadata 元数据
|
||||
*/
|
||||
public void setMetadata(Map<String, Object> metadata) {
|
||||
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package com.easyagents.agent.runtime.persistence;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 用于 AgentScope 类运行时会话状态的存储 SPI。
|
||||
*
|
||||
*/
|
||||
public interface AgentSessionStore {
|
||||
|
||||
/**
|
||||
* 保存one state value。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
* @param name 状态名称
|
||||
* @param state 状态值
|
||||
*/
|
||||
void save(String sessionKey, String name, AgentRuntimeState state);
|
||||
|
||||
/**
|
||||
* 保存a state list。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
* @param name 状态名称
|
||||
* @param states 状态列表
|
||||
*/
|
||||
void saveList(String sessionKey, String name, List<AgentRuntimeState> states);
|
||||
|
||||
/**
|
||||
* 获取one state value。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
* @param name 状态名称
|
||||
* @return 可选状态
|
||||
*/
|
||||
Optional<AgentRuntimeState> get(String sessionKey, String name);
|
||||
|
||||
/**
|
||||
* 获取a state list。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
* @param name 状态名称
|
||||
* @return 状态列表
|
||||
*/
|
||||
List<AgentRuntimeState> getList(String sessionKey, String name);
|
||||
|
||||
/**
|
||||
* 返回是否session key exists。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
* @return 存在时为 true
|
||||
*/
|
||||
boolean exists(String sessionKey);
|
||||
|
||||
/**
|
||||
* 删除a session key。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
*/
|
||||
void delete(String sessionKey);
|
||||
|
||||
/**
|
||||
* 列出会话键列表。
|
||||
*
|
||||
* @return 会话键列表
|
||||
*/
|
||||
Set<String> listSessionKeys();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.easyagents.agent.runtime.persistence;
|
||||
package com.easyagents.agent.runtime.persistence.conversation;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRunRequest;
|
||||
import com.easyagents.agent.runtime.AgentRuntimeExecutionContext;
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||
|
||||
/**
|
||||
@@ -14,5 +14,5 @@ public interface AgentConversationRecorder {
|
||||
* @param request 运行请求
|
||||
* @param event 运行时事件
|
||||
*/
|
||||
void record(AgentRunRequest request, AgentRuntimeEvent event);
|
||||
void record(AgentRuntimeExecutionContext request, AgentRuntimeEvent event);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.easyagents.agent.runtime.persistence.conversation.noop;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRuntimeExecutionContext;
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||
import com.easyagents.agent.runtime.persistence.conversation.AgentConversationRecorder;
|
||||
|
||||
/**
|
||||
* 空操作会话记录器。
|
||||
*/
|
||||
public enum NoopAgentConversationRecorder implements AgentConversationRecorder {
|
||||
INSTANCE;
|
||||
|
||||
@Override
|
||||
public void record(AgentRuntimeExecutionContext request, AgentRuntimeEvent event) {
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package com.easyagents.agent.runtime.persistence.json;
|
||||
|
||||
import com.easyagents.agent.runtime.persistence.AgentRuntimeState;
|
||||
import com.easyagents.agent.runtime.persistence.AgentSessionStoreException;
|
||||
import io.agentscope.core.state.State;
|
||||
import io.agentscope.core.util.JsonUtils;
|
||||
|
||||
/**
|
||||
* AgentScope 状态的 JSON 编解码器。
|
||||
*/
|
||||
public class AgentSessionStateCodec {
|
||||
|
||||
/**
|
||||
* 将状态对象编码为可持久化记录。
|
||||
*
|
||||
* @param state 状态对象
|
||||
* @return 可持久化记录
|
||||
*/
|
||||
public SerializedAgentRuntimeState encode(AgentRuntimeState state) {
|
||||
if (state == null || state.getValue() == null) {
|
||||
return null;
|
||||
}
|
||||
Object value = state.getValue();
|
||||
if (!(value instanceof State)) {
|
||||
throw new AgentSessionStoreException("Only AgentScope State values can be serialized: "
|
||||
+ value.getClass().getName());
|
||||
}
|
||||
SerializedAgentRuntimeState serialized = new SerializedAgentRuntimeState();
|
||||
serialized.setName(state.getName());
|
||||
serialized.setStateClassName(value.getClass().getName());
|
||||
serialized.setStateJson(JsonUtils.getJsonCodec().toJson(value));
|
||||
serialized.setMetadata(state.getMetadata());
|
||||
return serialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将持久化记录解码为运行时状态。
|
||||
*
|
||||
* @param serialized 可持久化记录
|
||||
* @return 运行时状态
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public AgentRuntimeState decode(SerializedAgentRuntimeState serialized) {
|
||||
if (serialized == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Class<?> clazz = Class.forName(serialized.getStateClassName());
|
||||
if (!State.class.isAssignableFrom(clazz)) {
|
||||
throw new AgentSessionStoreException("Serialized state class is not AgentScope State: "
|
||||
+ serialized.getStateClassName());
|
||||
}
|
||||
State value = JsonUtils.getJsonCodec().fromJson(serialized.getStateJson(), (Class<? extends State>) clazz);
|
||||
AgentRuntimeState state = AgentRuntimeState.of(serialized.getName(), value);
|
||||
state.setMetadata(serialized.getMetadata());
|
||||
return state;
|
||||
} catch (ClassNotFoundException e) {
|
||||
throw new AgentSessionStoreException("Serialized state class not found: "
|
||||
+ serialized.getStateClassName(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package com.easyagents.agent.runtime.persistence.json;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* JSON 会话状态的底层键值存储后端。
|
||||
*/
|
||||
public interface AgentSessionStoreBackend {
|
||||
|
||||
/**
|
||||
* 保存单个状态记录。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
* @param name 状态名称
|
||||
* @param state 状态记录
|
||||
*/
|
||||
void save(String sessionKey, String name, SerializedAgentRuntimeState state);
|
||||
|
||||
/**
|
||||
* 保存状态记录列表。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
* @param name 状态名称
|
||||
* @param states 状态记录列表
|
||||
*/
|
||||
void saveList(String sessionKey, String name, List<SerializedAgentRuntimeState> states);
|
||||
|
||||
/**
|
||||
* 获取单个状态记录。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
* @param name 状态名称
|
||||
* @return 状态记录
|
||||
*/
|
||||
Optional<SerializedAgentRuntimeState> get(String sessionKey, String name);
|
||||
|
||||
/**
|
||||
* 获取状态记录列表。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
* @param name 状态名称
|
||||
* @return 状态记录列表
|
||||
*/
|
||||
List<SerializedAgentRuntimeState> getList(String sessionKey, String name);
|
||||
|
||||
/**
|
||||
* 判断会话是否存在。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
* @return 存在时为 true
|
||||
*/
|
||||
boolean exists(String sessionKey);
|
||||
|
||||
/**
|
||||
* 删除会话。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
*/
|
||||
void delete(String sessionKey);
|
||||
|
||||
/**
|
||||
* 列出全部会话键。
|
||||
*
|
||||
* @return 会话键集合
|
||||
*/
|
||||
Set<String> listSessionKeys();
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package com.easyagents.agent.runtime.persistence.json;
|
||||
|
||||
import com.easyagents.agent.runtime.persistence.AgentSessionStoreException;
|
||||
import io.agentscope.core.util.JsonUtils;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* 基于本地文件的 JSON 会话状态后端。
|
||||
*/
|
||||
public class FileAgentSessionStoreBackend implements AgentSessionStoreBackend {
|
||||
|
||||
private static final Pattern SAFE_NAME = Pattern.compile("[A-Za-z0-9._-]+");
|
||||
private final Path rootDirectory;
|
||||
|
||||
/**
|
||||
* 创建文件后端。
|
||||
*
|
||||
* @param rootDirectory 根目录
|
||||
*/
|
||||
public FileAgentSessionStoreBackend(Path rootDirectory) {
|
||||
if (rootDirectory == null) {
|
||||
throw new AgentSessionStoreException("Agent session root directory is required.");
|
||||
}
|
||||
this.rootDirectory = rootDirectory;
|
||||
try {
|
||||
Files.createDirectories(rootDirectory);
|
||||
} catch (IOException e) {
|
||||
throw new AgentSessionStoreException("Failed to create agent session root directory: " + rootDirectory, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(String sessionKey, String name, SerializedAgentRuntimeState state) {
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
Path path = statePath(sessionKey, name);
|
||||
createParent(path);
|
||||
try {
|
||||
Files.writeString(path, JsonUtils.getJsonCodec().toPrettyJson(state), StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
|
||||
} catch (IOException e) {
|
||||
throw new AgentSessionStoreException("Failed to save agent session state: " + sessionKey + "/" + name, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveList(String sessionKey, String name, List<SerializedAgentRuntimeState> states) {
|
||||
Path path = listPath(sessionKey, name);
|
||||
createParent(path);
|
||||
try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) {
|
||||
if (states == null) {
|
||||
return;
|
||||
}
|
||||
for (SerializedAgentRuntimeState state : states) {
|
||||
writer.write(JsonUtils.getJsonCodec().toJson(state));
|
||||
writer.newLine();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new AgentSessionStoreException("Failed to save agent session state list: " + sessionKey + "/" + name, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<SerializedAgentRuntimeState> get(String sessionKey, String name) {
|
||||
Path path = statePath(sessionKey, name);
|
||||
if (!Files.exists(path)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
String json = Files.readString(path, StandardCharsets.UTF_8);
|
||||
return Optional.of(JsonUtils.getJsonCodec().fromJson(json, SerializedAgentRuntimeState.class));
|
||||
} catch (IOException e) {
|
||||
throw new AgentSessionStoreException("Failed to load agent session state: " + sessionKey + "/" + name, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SerializedAgentRuntimeState> getList(String sessionKey, String name) {
|
||||
Path path = listPath(sessionKey, name);
|
||||
if (!Files.exists(path)) {
|
||||
return List.of();
|
||||
}
|
||||
List<SerializedAgentRuntimeState> states = new ArrayList<>();
|
||||
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (!line.isBlank()) {
|
||||
states.add(JsonUtils.getJsonCodec().fromJson(line, SerializedAgentRuntimeState.class));
|
||||
}
|
||||
}
|
||||
return states;
|
||||
} catch (IOException e) {
|
||||
throw new AgentSessionStoreException("Failed to load agent session state list: " + sessionKey + "/" + name, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String sessionKey) {
|
||||
return Files.isDirectory(sessionDirectory(sessionKey));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String sessionKey) {
|
||||
Path directory = sessionDirectory(sessionKey);
|
||||
if (!Files.exists(directory)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> paths = Files.walk(directory)) {
|
||||
List<Path> ordered = paths.sorted(Comparator.reverseOrder()).collect(Collectors.toList());
|
||||
for (Path path : ordered) {
|
||||
Files.deleteIfExists(path);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new AgentSessionStoreException("Failed to delete agent session: " + sessionKey, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> listSessionKeys() {
|
||||
if (!Files.exists(rootDirectory)) {
|
||||
return Set.of();
|
||||
}
|
||||
try (Stream<Path> paths = Files.list(rootDirectory)) {
|
||||
return paths.filter(Files::isDirectory)
|
||||
.map(path -> path.getFileName().toString())
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
} catch (IOException e) {
|
||||
throw new AgentSessionStoreException("Failed to list agent sessions.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private Path statePath(String sessionKey, String name) {
|
||||
return sessionDirectory(sessionKey).resolve(safeName(name) + ".json");
|
||||
}
|
||||
|
||||
private Path listPath(String sessionKey, String name) {
|
||||
return sessionDirectory(sessionKey).resolve(safeName(name) + ".jsonl");
|
||||
}
|
||||
|
||||
private Path sessionDirectory(String sessionKey) {
|
||||
return rootDirectory.resolve(safeName(sessionKey));
|
||||
}
|
||||
|
||||
private String safeName(String value) {
|
||||
if (value == null || value.isBlank() || !SAFE_NAME.matcher(value).matches()
|
||||
|| value.contains("..")) {
|
||||
throw new AgentSessionStoreException("Unsafe agent session storage key: " + value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private void createParent(Path path) {
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
} catch (IOException e) {
|
||||
throw new AgentSessionStoreException("Failed to create agent session directory: " + path.getParent(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package com.easyagents.agent.runtime.persistence.json;
|
||||
|
||||
import com.easyagents.agent.runtime.persistence.AgentRuntimeState;
|
||||
import com.easyagents.agent.runtime.persistence.AgentSessionStore;
|
||||
import com.easyagents.agent.runtime.persistence.AgentSessionStoreException;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 基于 JSON 记录的默认非内存会话存储。
|
||||
*/
|
||||
public class JsonAgentSessionStore implements AgentSessionStore {
|
||||
|
||||
private final AgentSessionStoreBackend backend;
|
||||
private final AgentSessionStateCodec codec;
|
||||
|
||||
/**
|
||||
* 创建文件型 JSON 会话存储。
|
||||
*
|
||||
* @param rootDirectory 根目录
|
||||
*/
|
||||
public JsonAgentSessionStore(Path rootDirectory) {
|
||||
this(new FileAgentSessionStoreBackend(rootDirectory), new AgentSessionStateCodec());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建可替换后端的 JSON 会话存储。
|
||||
*
|
||||
* @param backend 底层存储后端
|
||||
*/
|
||||
public JsonAgentSessionStore(AgentSessionStoreBackend backend) {
|
||||
this(backend, new AgentSessionStateCodec());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建可替换后端和编解码器的 JSON 会话存储。
|
||||
*
|
||||
* @param backend 底层存储后端
|
||||
* @param codec 状态编解码器
|
||||
*/
|
||||
public JsonAgentSessionStore(AgentSessionStoreBackend backend, AgentSessionStateCodec codec) {
|
||||
if (backend == null) {
|
||||
throw new AgentSessionStoreException("Agent session store backend is required.");
|
||||
}
|
||||
this.backend = backend;
|
||||
this.codec = codec == null ? new AgentSessionStateCodec() : codec;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(String sessionKey, String name, AgentRuntimeState state) {
|
||||
SerializedAgentRuntimeState serialized = codec.encode(state);
|
||||
if (serialized != null) {
|
||||
backend.save(sessionKey, name, serialized);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveList(String sessionKey, String name, List<AgentRuntimeState> states) {
|
||||
List<SerializedAgentRuntimeState> serialized = new ArrayList<>();
|
||||
if (states != null) {
|
||||
for (AgentRuntimeState state : states) {
|
||||
SerializedAgentRuntimeState item = codec.encode(state);
|
||||
if (item != null) {
|
||||
serialized.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
backend.saveList(sessionKey, name, serialized);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AgentRuntimeState> get(String sessionKey, String name) {
|
||||
return backend.get(sessionKey, name).map(codec::decode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AgentRuntimeState> getList(String sessionKey, String name) {
|
||||
List<AgentRuntimeState> states = new ArrayList<>();
|
||||
for (SerializedAgentRuntimeState item : backend.getList(sessionKey, name)) {
|
||||
AgentRuntimeState state = codec.decode(item);
|
||||
if (state != null) {
|
||||
states.add(state);
|
||||
}
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String sessionKey) {
|
||||
return backend.exists(sessionKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String sessionKey) {
|
||||
backend.delete(sessionKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> listSessionKeys() {
|
||||
return backend.listSessionKeys();
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package com.easyagents.agent.runtime.persistence.json;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 可跨进程持久化的 AgentScope 状态记录。
|
||||
*/
|
||||
public class SerializedAgentRuntimeState {
|
||||
|
||||
private String name;
|
||||
private String stateClassName;
|
||||
private String stateJson;
|
||||
private Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
/**
|
||||
* 获取状态名称。
|
||||
*
|
||||
* @return 状态名称
|
||||
*/
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置状态名称。
|
||||
*
|
||||
* @param name 状态名称
|
||||
*/
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态类名。
|
||||
*
|
||||
* @return 状态类名
|
||||
*/
|
||||
public String getStateClassName() {
|
||||
return stateClassName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置状态类名。
|
||||
*
|
||||
* @param stateClassName 状态类名
|
||||
*/
|
||||
public void setStateClassName(String stateClassName) {
|
||||
this.stateClassName = stateClassName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态 JSON。
|
||||
*
|
||||
* @return 状态 JSON
|
||||
*/
|
||||
public String getStateJson() {
|
||||
return stateJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置状态 JSON。
|
||||
*
|
||||
* @param stateJson 状态 JSON
|
||||
*/
|
||||
public void setStateJson(String stateJson) {
|
||||
this.stateJson = stateJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元数据。
|
||||
*
|
||||
* @return 元数据
|
||||
*/
|
||||
public Map<String, Object> getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置元数据。
|
||||
*
|
||||
* @param metadata 元数据
|
||||
*/
|
||||
public void setMetadata(Map<String, Object> metadata) {
|
||||
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取创建时间。
|
||||
*
|
||||
* @return 创建时间
|
||||
*/
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置创建时间。
|
||||
*
|
||||
* @param createdAt 创建时间
|
||||
*/
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt == null ? Instant.now() : createdAt;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package com.easyagents.agent.runtime.persistence.memory;
|
||||
|
||||
import com.easyagents.agent.runtime.persistence.AgentRuntimeState;
|
||||
import com.easyagents.agent.runtime.persistence.AgentSessionStore;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 用于测试和单节点 MVP 的内存会话存储。
|
||||
*/
|
||||
public class InMemoryAgentSessionStore implements AgentSessionStore {
|
||||
|
||||
private final Map<String, Map<String, List<AgentRuntimeState>>> states = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void save(String sessionKey, String name, AgentRuntimeState state) {
|
||||
if (sessionKey == null || name == null || state == null) {
|
||||
return;
|
||||
}
|
||||
saveList(sessionKey, name, List.of(state));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveList(String sessionKey, String name, List<AgentRuntimeState> states) {
|
||||
if (sessionKey == null || name == null) {
|
||||
return;
|
||||
}
|
||||
this.states.computeIfAbsent(sessionKey, key -> new ConcurrentHashMap<>())
|
||||
.put(name, states == null ? new ArrayList<>() : new ArrayList<>(states));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AgentRuntimeState> get(String sessionKey, String name) {
|
||||
List<AgentRuntimeState> list = getList(sessionKey, name);
|
||||
return list.isEmpty() ? Optional.empty() : Optional.ofNullable(list.get(0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AgentRuntimeState> getList(String sessionKey, String name) {
|
||||
Map<String, List<AgentRuntimeState>> byName = states.getOrDefault(sessionKey, new LinkedHashMap<>());
|
||||
return new ArrayList<>(byName.getOrDefault(name, new ArrayList<>()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String sessionKey) {
|
||||
return states.containsKey(sessionKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String sessionKey) {
|
||||
states.remove(sessionKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> listSessionKeys() {
|
||||
return new LinkedHashSet<>(states.keySet());
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.easyagents.agent.runtime.persistence.noop;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRunRequest;
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||
import com.easyagents.agent.runtime.persistence.AgentConversationRecorder;
|
||||
|
||||
/**
|
||||
* 空操作会话记录器。
|
||||
*/
|
||||
public enum NoopAgentConversationRecorder implements AgentConversationRecorder {
|
||||
INSTANCE;
|
||||
|
||||
@Override
|
||||
public void record(AgentRunRequest request, AgentRuntimeEvent event) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.easyagents.agent.runtime.persistence.session;
|
||||
|
||||
import io.agentscope.core.state.State;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 智能体会话状态存储接口。
|
||||
* <p>
|
||||
* 该接口承接 AgentScope {@code Session} 的持久化读写能力,用于按会话键保存和恢复
|
||||
* Agent 运行过程中产生的状态,例如 memory、toolkit、plan notebook 或有状态工具数据。
|
||||
* 具体实现可以基于内存、Redis、MySQL 等存储介质。
|
||||
*/
|
||||
public interface AgentSessionStore {
|
||||
|
||||
/**
|
||||
* 保存单个状态项。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
* @param name 状态名称
|
||||
* @param state 状态值
|
||||
*/
|
||||
void save(String sessionKey, String name, State state);
|
||||
|
||||
/**
|
||||
* 保存状态列表。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
* @param name 状态名称
|
||||
* @param states 状态列表
|
||||
*/
|
||||
void saveList(String sessionKey, String name, List<? extends State> states);
|
||||
|
||||
/**
|
||||
* 获取单个状态项。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
* @param name 状态名称
|
||||
* @param type 状态类型
|
||||
* @param <T> 状态类型
|
||||
* @return 可选状态
|
||||
*/
|
||||
<T extends State> Optional<T> get(String sessionKey, String name, Class<T> type);
|
||||
|
||||
/**
|
||||
* 获取状态列表。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
* @param name 状态名称
|
||||
* @param itemType 状态元素类型
|
||||
* @param <T> 状态元素类型
|
||||
* @return 状态列表
|
||||
*/
|
||||
<T extends State> List<T> getList(String sessionKey, String name, Class<T> itemType);
|
||||
|
||||
/**
|
||||
* 判断会话键是否存在。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
* @return 存在时为 true
|
||||
*/
|
||||
boolean exists(String sessionKey);
|
||||
|
||||
/**
|
||||
* 删除指定会话键下的全部状态。
|
||||
*
|
||||
* @param sessionKey 会话键
|
||||
*/
|
||||
void delete(String sessionKey);
|
||||
|
||||
/**
|
||||
* 列出当前存储中的会话键。
|
||||
*
|
||||
* @return 会话键列表
|
||||
*/
|
||||
Set<String> listSessionKeys();
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.easyagents.agent.runtime.persistence;
|
||||
package com.easyagents.agent.runtime.persistence.session;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRuntimeException;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.easyagents.agent.runtime.persistence.session.memory;
|
||||
|
||||
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
|
||||
import io.agentscope.core.state.State;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 用于测试和单节点 MVP 的内存会话存储。
|
||||
*/
|
||||
public class InMemoryAgentSessionStore implements AgentSessionStore {
|
||||
|
||||
private final Map<String, Map<String, List<State>>> states = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void save(String sessionKey, String name, State state) {
|
||||
if (sessionKey == null || name == null || state == null) {
|
||||
return;
|
||||
}
|
||||
saveList(sessionKey, name, List.of(state));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveList(String sessionKey, String name, List<? extends State> states) {
|
||||
if (sessionKey == null || name == null) {
|
||||
return;
|
||||
}
|
||||
this.states.computeIfAbsent(sessionKey, key -> new ConcurrentHashMap<>())
|
||||
.put(name, states == null ? new ArrayList<>() : new ArrayList<>(states));
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T extends State> Optional<T> get(String sessionKey, String name, Class<T> type) {
|
||||
List<T> list = getList(sessionKey, name, type);
|
||||
return list.isEmpty() ? Optional.empty() : Optional.of(list.get(0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T extends State> List<T> getList(String sessionKey, String name, Class<T> itemType) {
|
||||
Map<String, List<State>> byName = states.getOrDefault(sessionKey, new LinkedHashMap<>());
|
||||
List<State> values = byName.getOrDefault(name, new ArrayList<>());
|
||||
if (itemType == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<T> typedValues = new ArrayList<>();
|
||||
for (State value : values) {
|
||||
if (itemType.isInstance(value)) {
|
||||
typedValues.add(itemType.cast(value));
|
||||
}
|
||||
}
|
||||
return typedValues;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String sessionKey) {
|
||||
return states.containsKey(sessionKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String sessionKey) {
|
||||
states.remove(sessionKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> listSessionKeys() {
|
||||
return new LinkedHashSet<>(states.keySet());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.easyagents.agent.runtime.persistence.noop;
|
||||
package com.easyagents.agent.runtime.persistence.session.noop;
|
||||
|
||||
import com.easyagents.agent.runtime.persistence.AgentRuntimeState;
|
||||
import com.easyagents.agent.runtime.persistence.AgentSessionStore;
|
||||
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
|
||||
import io.agentscope.core.state.State;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -15,20 +15,20 @@ public enum NoopAgentSessionStore implements AgentSessionStore {
|
||||
INSTANCE;
|
||||
|
||||
@Override
|
||||
public void save(String sessionKey, String name, AgentRuntimeState state) {
|
||||
public void save(String sessionKey, String name, State state) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveList(String sessionKey, String name, List<AgentRuntimeState> states) {
|
||||
public void saveList(String sessionKey, String name, List<? extends State> states) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AgentRuntimeState> get(String sessionKey, String name) {
|
||||
public <T extends State> Optional<T> get(String sessionKey, String name, Class<T> type) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AgentRuntimeState> getList(String sessionKey, String name) {
|
||||
public <T extends State> List<T> getList(String sessionKey, String name, Class<T> itemType) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@@ -111,6 +111,26 @@ public class AgentSkillRuntimeContext {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 AgentScope 的真实 Skill 激活状态同步本地旁路上下文。
|
||||
*
|
||||
* <p>该状态只用于 Easy-Agents 判断旁路展示事件归属,不参与 AgentScope
|
||||
* memory/session,也不决定工具是否真的可被模型调用。</p>
|
||||
*
|
||||
* @param skillId Skill ID
|
||||
* @param active 是否激活
|
||||
*/
|
||||
public void syncSkillActive(String skillId, boolean active) {
|
||||
if (skillId == null || skillId.isBlank()) {
|
||||
return;
|
||||
}
|
||||
if (active) {
|
||||
activeSkills.put(skillId, true);
|
||||
return;
|
||||
}
|
||||
activeSkills.remove(skillId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 Skill 是否已激活。
|
||||
*
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.easyagents.agent.runtime.tool;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRuntimeContext;
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* 传递给动态工具调用的上下文。
|
||||
@@ -17,6 +19,7 @@ public class AgentToolContext {
|
||||
private String toolCallId;
|
||||
private AgentRuntimeContext runtimeContext = new AgentRuntimeContext();
|
||||
private Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
private Consumer<AgentRuntimeEvent> eventEmitter;
|
||||
|
||||
/**
|
||||
* 获取请求ID。
|
||||
@@ -143,4 +146,36 @@ public class AgentToolContext {
|
||||
public void setMetadata(Map<String, Object> metadata) {
|
||||
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发射运行时旁路事件。
|
||||
*
|
||||
* <p>该方法主要供 runtime 包装层发射业务无关事件,例如异步工具生命周期事件。
|
||||
* 未配置事件发射器时静默忽略,避免非流式测试路径产生额外副作用。</p>
|
||||
*
|
||||
* @param event 运行时事件
|
||||
*/
|
||||
public void emitEvent(AgentRuntimeEvent event) {
|
||||
if (eventEmitter != null && event != null) {
|
||||
eventEmitter.accept(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取运行时事件发射器。
|
||||
*
|
||||
* @return 运行时事件发射器
|
||||
*/
|
||||
public Consumer<AgentRuntimeEvent> getEventEmitter() {
|
||||
return eventEmitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置运行时事件发射器。
|
||||
*
|
||||
* @param eventEmitter 运行时事件发射器
|
||||
*/
|
||||
public void setEventEmitter(Consumer<AgentRuntimeEvent> eventEmitter) {
|
||||
this.eventEmitter = eventEmitter;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.easyagents.agent.runtime.tool.asynctool;
|
||||
|
||||
import com.easyagents.agent.runtime.tool.AgentToolContext;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 调用方实现的异步业务子工具集合。
|
||||
*/
|
||||
public interface AsyncSubTools {
|
||||
|
||||
/**
|
||||
* 提交异步任务。
|
||||
*
|
||||
* @param arguments 模型传入的业务参数
|
||||
* @param context 工具调用上下文
|
||||
* @return 提交结果
|
||||
*/
|
||||
AsyncToolSubmitResult submit(Map<String, Object> arguments, AgentToolContext context);
|
||||
|
||||
/**
|
||||
* 非阻塞观察任务状态和增量事件。
|
||||
*
|
||||
* @param request 观察请求
|
||||
* @param context 工具调用上下文
|
||||
* @return 当前任务视图
|
||||
*/
|
||||
AsyncToolTaskView observe(AsyncToolObserveRequest request, AgentToolContext context);
|
||||
|
||||
/**
|
||||
* 获取任务结果,未完成时返回当前观察态。
|
||||
*
|
||||
* @param request 结果请求
|
||||
* @param context 工具调用上下文
|
||||
* @return 当前任务视图
|
||||
*/
|
||||
AsyncToolTaskView result(AsyncToolResultRequest request, AgentToolContext context);
|
||||
|
||||
/**
|
||||
* 请求取消任务。
|
||||
*
|
||||
* @param request 取消请求
|
||||
* @param context 工具调用上下文
|
||||
* @return 取消结果
|
||||
*/
|
||||
AsyncToolCancelResult cancel(AsyncToolCancelRequest request, AgentToolContext context);
|
||||
|
||||
/**
|
||||
* 查询当前上下文可见的任务列表。
|
||||
*
|
||||
* @param request 列表请求
|
||||
* @param context 工具调用上下文
|
||||
* @return 任务列表
|
||||
*/
|
||||
AsyncToolTaskListResult list(AsyncToolListRequest request, AgentToolContext context);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.easyagents.agent.runtime.tool.asynctool;
|
||||
|
||||
/**
|
||||
* 异步工具取消请求。
|
||||
*/
|
||||
public class AsyncToolCancelRequest {
|
||||
|
||||
private String taskId;
|
||||
private String reason;
|
||||
|
||||
/**
|
||||
* 创建空取消请求。
|
||||
*/
|
||||
public AsyncToolCancelRequest() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务 ID。
|
||||
*
|
||||
* @return 任务 ID
|
||||
*/
|
||||
public String getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置任务 ID。
|
||||
*
|
||||
* @param taskId 任务 ID
|
||||
*/
|
||||
public void setTaskId(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取取消原因。
|
||||
*
|
||||
* @return 取消原因
|
||||
*/
|
||||
public String getReason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置取消原因。
|
||||
*
|
||||
* @param reason 取消原因
|
||||
*/
|
||||
public void setReason(String reason) {
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.easyagents.agent.runtime.tool.asynctool;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 异步工具取消结果。
|
||||
*/
|
||||
public class AsyncToolCancelResult {
|
||||
|
||||
private String taskId;
|
||||
private AsyncToolTaskStatus status;
|
||||
private String message;
|
||||
private String errorMessage;
|
||||
private Map<String, Object> payload = new LinkedHashMap<>();
|
||||
private Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 创建空取消结果。
|
||||
*/
|
||||
public AsyncToolCancelResult() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务 ID。
|
||||
*
|
||||
* @return 任务 ID
|
||||
*/
|
||||
public String getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置任务 ID。
|
||||
*
|
||||
* @param taskId 任务 ID
|
||||
*/
|
||||
public void setTaskId(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务状态。
|
||||
*
|
||||
* @return 任务状态
|
||||
*/
|
||||
public AsyncToolTaskStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置任务状态。
|
||||
*
|
||||
* @param status 任务状态
|
||||
*/
|
||||
public void setStatus(AsyncToolTaskStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取取消消息。
|
||||
*
|
||||
* @return 取消消息
|
||||
*/
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置取消消息。
|
||||
*
|
||||
* @param message 取消消息
|
||||
*/
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误消息。
|
||||
*
|
||||
* @return 错误消息
|
||||
*/
|
||||
public String getErrorMessage() {
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置错误消息。
|
||||
*
|
||||
* @param errorMessage 错误消息
|
||||
*/
|
||||
public void setErrorMessage(String errorMessage) {
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取业务扩展载荷。
|
||||
*
|
||||
* @return 业务扩展载荷
|
||||
*/
|
||||
public Map<String, Object> getPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置业务扩展载荷。
|
||||
*
|
||||
* @param payload 业务扩展载荷
|
||||
*/
|
||||
public void setPayload(Map<String, Object> payload) {
|
||||
this.payload = payload == null ? new LinkedHashMap<>() : payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元数据。
|
||||
*
|
||||
* @return 元数据
|
||||
*/
|
||||
public Map<String, Object> getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置元数据。
|
||||
*
|
||||
* @param metadata 元数据
|
||||
*/
|
||||
public void setMetadata(Map<String, Object> metadata) {
|
||||
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.easyagents.agent.runtime.tool.asynctool;
|
||||
|
||||
/**
|
||||
* 异步工具列表请求。
|
||||
*/
|
||||
public class AsyncToolListRequest {
|
||||
|
||||
private AsyncToolTaskStatus status;
|
||||
|
||||
/**
|
||||
* 创建空列表请求。
|
||||
*/
|
||||
public AsyncToolListRequest() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态过滤条件。
|
||||
*
|
||||
* @return 状态过滤条件
|
||||
*/
|
||||
public AsyncToolTaskStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置状态过滤条件。
|
||||
*
|
||||
* @param status 状态过滤条件
|
||||
*/
|
||||
public void setStatus(AsyncToolTaskStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.easyagents.agent.runtime.tool.asynctool;
|
||||
|
||||
/**
|
||||
* 异步工具观察请求。
|
||||
*/
|
||||
public class AsyncToolObserveRequest {
|
||||
|
||||
private String taskId;
|
||||
/**
|
||||
* 调用方已读取到的事件位置,用于增量读取任务事件,避免重复返回全量日志。
|
||||
*/
|
||||
private Long cursor;
|
||||
private Integer limit;
|
||||
|
||||
/**
|
||||
* 创建空观察请求。
|
||||
*/
|
||||
public AsyncToolObserveRequest() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务 ID。
|
||||
*
|
||||
* @return 任务 ID
|
||||
*/
|
||||
public String getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置任务 ID。
|
||||
*
|
||||
* @param taskId 任务 ID
|
||||
*/
|
||||
public void setTaskId(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已读事件位置。
|
||||
*
|
||||
* @return 已读事件位置
|
||||
*/
|
||||
public Long getCursor() {
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置已读事件位置。
|
||||
*
|
||||
* @param cursor 已读事件位置
|
||||
*/
|
||||
public void setCursor(Long cursor) {
|
||||
this.cursor = cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取事件读取数量。
|
||||
*
|
||||
* @return 事件读取数量
|
||||
*/
|
||||
public Integer getLimit() {
|
||||
return limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置事件读取数量。
|
||||
*
|
||||
* @param limit 事件读取数量
|
||||
*/
|
||||
public void setLimit(Integer limit) {
|
||||
this.limit = limit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.easyagents.agent.runtime.tool.asynctool;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 异步工具 runtime 包装层的通用选项。
|
||||
*/
|
||||
public class AsyncToolOptions {
|
||||
|
||||
private Duration submitTimeout = Duration.ofSeconds(5);
|
||||
private Duration observeTimeout = Duration.ofSeconds(3);
|
||||
private Duration resultTimeout = Duration.ofSeconds(3);
|
||||
private Duration cancelTimeout = Duration.ofSeconds(3);
|
||||
private Duration listTimeout = Duration.ofSeconds(3);
|
||||
private int defaultEventLimit = 20;
|
||||
private int maxEventLimit = 100;
|
||||
private int maxModelContentLength = 1200;
|
||||
private int maxEventTextLength = 800;
|
||||
|
||||
/**
|
||||
* 创建默认异步工具选项实例。
|
||||
*/
|
||||
public AsyncToolOptions() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认异步工具选项。
|
||||
*
|
||||
* @return 默认选项
|
||||
*/
|
||||
public static AsyncToolOptions defaults() {
|
||||
return new AsyncToolOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提交子工具超时时间。
|
||||
*
|
||||
* @return 超时时间
|
||||
*/
|
||||
public Duration getSubmitTimeout() {
|
||||
return submitTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置提交子工具超时时间。
|
||||
*
|
||||
* @param submitTimeout 超时时间
|
||||
*/
|
||||
public void setSubmitTimeout(Duration submitTimeout) {
|
||||
this.submitTimeout = submitTimeout == null ? Duration.ofSeconds(5) : submitTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取观察子工具超时时间。
|
||||
*
|
||||
* @return 超时时间
|
||||
*/
|
||||
public Duration getObserveTimeout() {
|
||||
return observeTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置观察子工具超时时间。
|
||||
*
|
||||
* @param observeTimeout 超时时间
|
||||
*/
|
||||
public void setObserveTimeout(Duration observeTimeout) {
|
||||
this.observeTimeout = observeTimeout == null ? Duration.ofSeconds(3) : observeTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取结果子工具超时时间。
|
||||
*
|
||||
* @return 超时时间
|
||||
*/
|
||||
public Duration getResultTimeout() {
|
||||
return resultTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置结果子工具超时时间。
|
||||
*
|
||||
* @param resultTimeout 超时时间
|
||||
*/
|
||||
public void setResultTimeout(Duration resultTimeout) {
|
||||
this.resultTimeout = resultTimeout == null ? Duration.ofSeconds(3) : resultTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取取消子工具超时时间。
|
||||
*
|
||||
* @return 超时时间
|
||||
*/
|
||||
public Duration getCancelTimeout() {
|
||||
return cancelTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置取消子工具超时时间。
|
||||
*
|
||||
* @param cancelTimeout 超时时间
|
||||
*/
|
||||
public void setCancelTimeout(Duration cancelTimeout) {
|
||||
this.cancelTimeout = cancelTimeout == null ? Duration.ofSeconds(3) : cancelTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取列表子工具超时时间。
|
||||
*
|
||||
* @return 超时时间
|
||||
*/
|
||||
public Duration getListTimeout() {
|
||||
return listTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置列表子工具超时时间。
|
||||
*
|
||||
* @param listTimeout 超时时间
|
||||
*/
|
||||
public void setListTimeout(Duration listTimeout) {
|
||||
this.listTimeout = listTimeout == null ? Duration.ofSeconds(3) : listTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认事件读取数量。
|
||||
*
|
||||
* @return 默认事件数量
|
||||
*/
|
||||
public int getDefaultEventLimit() {
|
||||
return defaultEventLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置默认事件读取数量。
|
||||
*
|
||||
* @param defaultEventLimit 默认事件数量
|
||||
*/
|
||||
public void setDefaultEventLimit(int defaultEventLimit) {
|
||||
this.defaultEventLimit = defaultEventLimit <= 0 ? 20 : defaultEventLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最大事件读取数量。
|
||||
*
|
||||
* @return 最大事件数量
|
||||
*/
|
||||
public int getMaxEventLimit() {
|
||||
return maxEventLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置最大事件读取数量。
|
||||
*
|
||||
* @param maxEventLimit 最大事件数量
|
||||
*/
|
||||
public void setMaxEventLimit(int maxEventLimit) {
|
||||
this.maxEventLimit = maxEventLimit <= 0 ? 100 : maxEventLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型可见内容最大长度。
|
||||
*
|
||||
* @return 最大长度
|
||||
*/
|
||||
public int getMaxModelContentLength() {
|
||||
return maxModelContentLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置模型可见内容最大长度。
|
||||
*
|
||||
* @param maxModelContentLength 最大长度
|
||||
*/
|
||||
public void setMaxModelContentLength(int maxModelContentLength) {
|
||||
this.maxModelContentLength = maxModelContentLength <= 0 ? 1200 : maxModelContentLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取事件文本最大长度。
|
||||
*
|
||||
* @return 最大长度
|
||||
*/
|
||||
public int getMaxEventTextLength() {
|
||||
return maxEventTextLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置事件文本最大长度。
|
||||
*
|
||||
* @param maxEventTextLength 最大长度
|
||||
*/
|
||||
public void setMaxEventTextLength(int maxEventTextLength) {
|
||||
this.maxEventTextLength = maxEventTextLength <= 0 ? 800 : maxEventTextLength;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.easyagents.agent.runtime.tool.asynctool;
|
||||
|
||||
/**
|
||||
* 异步工具结果请求。
|
||||
*/
|
||||
public class AsyncToolResultRequest {
|
||||
|
||||
private String taskId;
|
||||
/**
|
||||
* 调用方已读取到的事件位置,用于增量读取任务事件,避免 result 重复返回全量日志。
|
||||
*/
|
||||
private Long cursor;
|
||||
private Integer limit;
|
||||
|
||||
/**
|
||||
* 创建空结果请求。
|
||||
*/
|
||||
public AsyncToolResultRequest() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务 ID。
|
||||
*
|
||||
* @return 任务 ID
|
||||
*/
|
||||
public String getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置任务 ID。
|
||||
*
|
||||
* @param taskId 任务 ID
|
||||
*/
|
||||
public void setTaskId(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已读事件位置。
|
||||
*
|
||||
* @return 已读事件位置
|
||||
*/
|
||||
public Long getCursor() {
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置已读事件位置。
|
||||
*
|
||||
* @param cursor 已读事件位置
|
||||
*/
|
||||
public void setCursor(Long cursor) {
|
||||
this.cursor = cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取事件读取数量。
|
||||
*
|
||||
* @return 事件读取数量
|
||||
*/
|
||||
public Integer getLimit() {
|
||||
return limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置事件读取数量。
|
||||
*
|
||||
* @param limit 事件读取数量
|
||||
*/
|
||||
public void setLimit(Integer limit) {
|
||||
this.limit = limit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.easyagents.agent.runtime.tool.asynctool;
|
||||
|
||||
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 异步工具声明。
|
||||
*
|
||||
* <p>一个声明会被 runtime 展开为 submit、observe、result、cancel 和 list 五个普通工具。</p>
|
||||
*/
|
||||
public class AsyncToolSpec {
|
||||
|
||||
private String name;
|
||||
private String description;
|
||||
private Map<String, Object> submitParametersSchema = new LinkedHashMap<>();
|
||||
private AsyncSubTools subTools;
|
||||
private AsyncToolOptions options = AsyncToolOptions.defaults();
|
||||
private boolean approvalRequired;
|
||||
private AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest();
|
||||
private Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 创建空异步工具声明。
|
||||
*/
|
||||
public AsyncToolSpec() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取异步工具基础名称。
|
||||
*
|
||||
* @return 工具名称
|
||||
*/
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置异步工具基础名称。
|
||||
*
|
||||
* @param name 工具名称
|
||||
*/
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具描述。
|
||||
*
|
||||
* @return 工具描述
|
||||
*/
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置工具描述。
|
||||
*
|
||||
* @param description 工具描述
|
||||
*/
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提交子工具参数 Schema。
|
||||
*
|
||||
* @return 参数 Schema
|
||||
*/
|
||||
public Map<String, Object> getSubmitParametersSchema() {
|
||||
return submitParametersSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置提交子工具参数 Schema。
|
||||
*
|
||||
* @param submitParametersSchema 参数 Schema
|
||||
*/
|
||||
public void setSubmitParametersSchema(Map<String, Object> submitParametersSchema) {
|
||||
this.submitParametersSchema = submitParametersSchema == null ? new LinkedHashMap<>() : submitParametersSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取业务子工具实现。
|
||||
*
|
||||
* @return 子工具实现
|
||||
*/
|
||||
public AsyncSubTools getSubTools() {
|
||||
return subTools;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置业务子工具实现。
|
||||
*
|
||||
* @param subTools 子工具实现
|
||||
*/
|
||||
public void setSubTools(AsyncSubTools subTools) {
|
||||
this.subTools = subTools;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取异步工具选项。
|
||||
*
|
||||
* @return 工具选项
|
||||
*/
|
||||
public AsyncToolOptions getOptions() {
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置异步工具选项。
|
||||
*
|
||||
* @param options 工具选项
|
||||
*/
|
||||
public void setOptions(AsyncToolOptions options) {
|
||||
this.options = options == null ? AsyncToolOptions.defaults() : options;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回提交子工具是否需要人工审批。
|
||||
*
|
||||
* @return 需要审批时为 true
|
||||
*/
|
||||
public boolean isApprovalRequired() {
|
||||
return approvalRequired;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置提交子工具是否需要人工审批。
|
||||
*
|
||||
* @param approvalRequired 审批标记
|
||||
*/
|
||||
public void setApprovalRequired(boolean approvalRequired) {
|
||||
this.approvalRequired = approvalRequired;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提交子工具的审批请求配置。
|
||||
*
|
||||
* @return 审批请求配置
|
||||
*/
|
||||
public AgentToolApprovalRequest getApprovalRequest() {
|
||||
return approvalRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置提交子工具的审批请求配置。
|
||||
*
|
||||
* @param approvalRequest 审批请求配置
|
||||
*/
|
||||
public void setApprovalRequest(AgentToolApprovalRequest approvalRequest) {
|
||||
this.approvalRequest = approvalRequest == null ? new AgentToolApprovalRequest() : approvalRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元数据。
|
||||
*
|
||||
* @return 元数据
|
||||
*/
|
||||
public Map<String, Object> getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置元数据。
|
||||
*
|
||||
* @param metadata 元数据
|
||||
*/
|
||||
public void setMetadata(Map<String, Object> metadata) {
|
||||
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
package com.easyagents.agent.runtime.tool.asynctool;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.easyagents.agent.runtime.AgentRuntimeException;
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolCategory;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolContext;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolResult;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolSpec;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolVisibility;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 异步工具声明展开器。
|
||||
*
|
||||
* <p>该类将一个业务无关的 {@link AsyncToolSpec} 展开为五个普通
|
||||
* {@link AgentToolSpec} 与 {@link AgentToolInvoker},业务方只需要实现
|
||||
* {@link AsyncSubTools}。</p>
|
||||
*/
|
||||
public class AsyncToolSpecExpander {
|
||||
|
||||
private static final Pattern SAFE_NAME = Pattern.compile("^[a-z][a-z0-9_]*$");
|
||||
private static final String PHASE_SUBMIT = "submit";
|
||||
private static final String PHASE_OBSERVE = "observe";
|
||||
private static final String PHASE_RESULT = "result";
|
||||
private static final String PHASE_CANCEL = "cancel";
|
||||
private static final String PHASE_LIST = "list";
|
||||
private static final String ERROR_TYPE_TIMEOUT = "TIMEOUT";
|
||||
private static final String ERROR_TYPE_EXCEPTION = "EXCEPTION";
|
||||
|
||||
private final ExecutorService executor;
|
||||
|
||||
/**
|
||||
* 使用公共 ForkJoinPool 创建展开器。
|
||||
*/
|
||||
public AsyncToolSpecExpander() {
|
||||
this(ForkJoinPool.commonPool());
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定执行器创建展开器。
|
||||
*
|
||||
* @param executor 执行器
|
||||
*/
|
||||
public AsyncToolSpecExpander(Executor executor) {
|
||||
if (executor instanceof ExecutorService executorService) {
|
||||
this.executor = executorService;
|
||||
} else {
|
||||
this.executor = new DelegatingExecutorService(executor);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 展开工具声明。
|
||||
*
|
||||
* @param spec 异步工具声明
|
||||
* @return 五个普通工具声明
|
||||
*/
|
||||
public List<AgentToolSpec> expandSpecs(AsyncToolSpec spec) {
|
||||
AsyncToolSpec safeSpec = validate(spec);
|
||||
List<AgentToolSpec> specs = new ArrayList<>(5);
|
||||
specs.add(toolSpec(safeSpec, PHASE_SUBMIT, safeSpec.getSubmitParametersSchema(), submitOutputSchema()));
|
||||
specs.add(toolSpec(safeSpec, PHASE_OBSERVE, observeSchema(safeSpec), taskViewOutputSchema()));
|
||||
specs.add(toolSpec(safeSpec, PHASE_RESULT, observeSchema(safeSpec), taskViewOutputSchema()));
|
||||
specs.add(toolSpec(safeSpec, PHASE_CANCEL, cancelSchema(), cancelOutputSchema()));
|
||||
specs.add(toolSpec(safeSpec, PHASE_LIST, listSchema(), listOutputSchema()));
|
||||
return specs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 展开工具调用器。
|
||||
*
|
||||
* @param spec 异步工具声明
|
||||
* @return 按工具名索引的调用器
|
||||
*/
|
||||
public Map<String, AgentToolInvoker> expandInvokers(AsyncToolSpec spec) {
|
||||
AsyncToolSpec safeSpec = validate(spec);
|
||||
Map<String, AgentToolInvoker> invokers = new LinkedHashMap<>();
|
||||
invokers.put(toolName(safeSpec, PHASE_SUBMIT), (arguments, context) -> submit(safeSpec, arguments, context));
|
||||
invokers.put(toolName(safeSpec, PHASE_OBSERVE), (arguments, context) -> observe(safeSpec, arguments, context));
|
||||
invokers.put(toolName(safeSpec, PHASE_RESULT), (arguments, context) -> result(safeSpec, arguments, context));
|
||||
invokers.put(toolName(safeSpec, PHASE_CANCEL), (arguments, context) -> cancel(safeSpec, arguments, context));
|
||||
invokers.put(toolName(safeSpec, PHASE_LIST), (arguments, context) -> list(safeSpec, arguments, context));
|
||||
return invokers;
|
||||
}
|
||||
|
||||
private AsyncToolSpec validate(AsyncToolSpec spec) {
|
||||
if (spec == null) {
|
||||
throw new AgentRuntimeException("Async tool spec is required.");
|
||||
}
|
||||
if (spec.getName() == null || spec.getName().isBlank()) {
|
||||
throw new AgentRuntimeException("Async tool name is required.");
|
||||
}
|
||||
if (!SAFE_NAME.matcher(spec.getName()).matches()) {
|
||||
throw new AgentRuntimeException("Async tool name must be safe snake_case: " + spec.getName());
|
||||
}
|
||||
if (spec.getSubTools() == null) {
|
||||
throw new AgentRuntimeException("Async sub tools are required: " + spec.getName());
|
||||
}
|
||||
if (spec.getSubmitParametersSchema() == null || spec.getSubmitParametersSchema().isEmpty()) {
|
||||
spec.setSubmitParametersSchema(emptyObjectSchema());
|
||||
}
|
||||
if (spec.getOptions() == null) {
|
||||
spec.setOptions(AsyncToolOptions.defaults());
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
private AgentToolSpec toolSpec(AsyncToolSpec spec,
|
||||
String phase,
|
||||
Map<String, Object> parametersSchema,
|
||||
Map<String, Object> outputSchema) {
|
||||
AgentToolSpec toolSpec = new AgentToolSpec();
|
||||
toolSpec.setName(toolName(spec, phase));
|
||||
toolSpec.setDescription(description(spec, phase));
|
||||
toolSpec.setCategory(AgentToolCategory.CUSTOM);
|
||||
toolSpec.setVisibility(AgentToolVisibility.VISIBLE);
|
||||
toolSpec.setParametersSchema(parametersSchema);
|
||||
toolSpec.setOutputSchema(outputSchema);
|
||||
toolSpec.setApprovalRequired(PHASE_SUBMIT.equals(phase) && spec.isApprovalRequired());
|
||||
toolSpec.setApprovalRequest(spec.getApprovalRequest());
|
||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
metadata.putAll(spec.getMetadata());
|
||||
metadata.put("asyncTool", true);
|
||||
metadata.put("asyncToolName", spec.getName());
|
||||
metadata.put("asyncToolPhase", phase);
|
||||
toolSpec.setMetadata(metadata);
|
||||
return toolSpec;
|
||||
}
|
||||
|
||||
private String description(AsyncToolSpec spec, String phase) {
|
||||
String prefix = spec.getDescription() == null || spec.getDescription().isBlank()
|
||||
? "Async tool " + spec.getName()
|
||||
: spec.getDescription();
|
||||
return switch (phase) {
|
||||
case PHASE_SUBMIT -> prefix
|
||||
+ " This is the default entry point when the user asks to run this tool. Submit an asynchronous task with the normal tool arguments and return task_id.";
|
||||
case PHASE_OBSERVE -> prefix
|
||||
+ " Use immediately after submit with the returned task_id to check progress and incremental events. Do not ask the user for task_id immediately after submit.";
|
||||
case PHASE_RESULT -> prefix
|
||||
+ " Use only when a known task_id should return the final result, or the current observation if the task is still running.";
|
||||
case PHASE_CANCEL -> prefix
|
||||
+ " Use only when the user explicitly asks to cancel a known asynchronous task by task_id.";
|
||||
case PHASE_LIST -> prefix
|
||||
+ " Use only when the user explicitly asks to list visible asynchronous tasks in the current context.";
|
||||
default -> prefix;
|
||||
};
|
||||
}
|
||||
|
||||
private AgentToolResult submit(AsyncToolSpec spec, Map<String, Object> arguments, AgentToolContext context) {
|
||||
return execute(spec, PHASE_SUBMIT, context, spec.getOptions().getSubmitTimeout(),
|
||||
guardedContext -> {
|
||||
AsyncToolSubmitResult result = spec.getSubTools().submit(safeMap(arguments), guardedContext);
|
||||
return wrapSubmit(spec, result, guardedContext);
|
||||
});
|
||||
}
|
||||
|
||||
private AgentToolResult observe(AsyncToolSpec spec, Map<String, Object> arguments, AgentToolContext context) {
|
||||
return execute(spec, PHASE_OBSERVE, context, spec.getOptions().getObserveTimeout(),
|
||||
guardedContext -> {
|
||||
AsyncToolObserveRequest request = observeRequest(arguments, spec.getOptions());
|
||||
return wrapTaskView(spec, PHASE_OBSERVE,
|
||||
spec.getSubTools().observe(request, guardedContext), guardedContext);
|
||||
});
|
||||
}
|
||||
|
||||
private AgentToolResult result(AsyncToolSpec spec, Map<String, Object> arguments, AgentToolContext context) {
|
||||
return execute(spec, PHASE_RESULT, context, spec.getOptions().getResultTimeout(),
|
||||
guardedContext -> {
|
||||
AsyncToolResultRequest request = resultRequest(arguments, spec.getOptions());
|
||||
return wrapTaskView(spec, PHASE_RESULT,
|
||||
spec.getSubTools().result(request, guardedContext), guardedContext);
|
||||
});
|
||||
}
|
||||
|
||||
private AgentToolResult cancel(AsyncToolSpec spec, Map<String, Object> arguments, AgentToolContext context) {
|
||||
return execute(spec, PHASE_CANCEL, context, spec.getOptions().getCancelTimeout(),
|
||||
guardedContext -> {
|
||||
AsyncToolCancelRequest request = cancelRequest(arguments);
|
||||
return wrapCancel(spec, spec.getSubTools().cancel(request, guardedContext), guardedContext);
|
||||
});
|
||||
}
|
||||
|
||||
private AgentToolResult list(AsyncToolSpec spec, Map<String, Object> arguments, AgentToolContext context) {
|
||||
return execute(spec, PHASE_LIST, context, spec.getOptions().getListTimeout(),
|
||||
guardedContext -> {
|
||||
AsyncToolListRequest request = listRequest(arguments);
|
||||
return wrapList(spec, spec.getSubTools().list(request, guardedContext), guardedContext);
|
||||
});
|
||||
}
|
||||
|
||||
private AgentToolResult execute(AsyncToolSpec spec,
|
||||
String phase,
|
||||
AgentToolContext context,
|
||||
Duration timeout,
|
||||
Function<AgentToolContext, AgentToolResult> supplier) {
|
||||
AtomicBoolean active = new AtomicBoolean(true);
|
||||
AgentToolContext guardedContext = guardedContext(context, active);
|
||||
Future<AgentToolResult> future = executor.submit(() -> supplier.apply(guardedContext));
|
||||
try {
|
||||
return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
|
||||
} catch (TimeoutException error) {
|
||||
active.set(false);
|
||||
future.cancel(true);
|
||||
AgentToolResult result = failureResult(spec, phase, null, AsyncToolTaskStatus.TIMEOUT,
|
||||
ERROR_TYPE_TIMEOUT, "Async tool " + phase + " timed out.");
|
||||
emitFailure(spec, phase, context, null, AsyncToolTaskStatus.TIMEOUT, result.getErrorMessage());
|
||||
return result;
|
||||
} catch (InterruptedException error) {
|
||||
active.set(false);
|
||||
Thread.currentThread().interrupt();
|
||||
AgentToolResult result = failureResult(spec, phase, null, AsyncToolTaskStatus.FAILED,
|
||||
ERROR_TYPE_EXCEPTION, "Async tool " + phase + " interrupted.");
|
||||
emitFailure(spec, phase, context, null, AsyncToolTaskStatus.FAILED, result.getErrorMessage());
|
||||
return result;
|
||||
} catch (ExecutionException error) {
|
||||
active.set(false);
|
||||
Throwable cause = error.getCause() == null ? error : error.getCause();
|
||||
String message = cause.getMessage() == null || cause.getMessage().isBlank()
|
||||
? "Async tool " + phase + " failed."
|
||||
: cause.getMessage();
|
||||
AgentToolResult result = failureResult(spec, phase, null, AsyncToolTaskStatus.FAILED,
|
||||
ERROR_TYPE_EXCEPTION, message);
|
||||
emitFailure(spec, phase, context, null, AsyncToolTaskStatus.FAILED, result.getErrorMessage());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private AgentToolContext guardedContext(AgentToolContext source, AtomicBoolean active) {
|
||||
if (source == null) {
|
||||
return null;
|
||||
}
|
||||
AgentToolContext context = new AgentToolContext();
|
||||
context.setRequestId(source.getRequestId());
|
||||
context.setTraceId(source.getTraceId());
|
||||
context.setSessionId(source.getSessionId());
|
||||
context.setAgentId(source.getAgentId());
|
||||
context.setToolCallId(source.getToolCallId());
|
||||
context.setRuntimeContext(source.getRuntimeContext());
|
||||
context.setMetadata(new LinkedHashMap<>(source.getMetadata()));
|
||||
context.setEventEmitter(event -> {
|
||||
// 超时后底层业务可能仍在运行,迟到事件不能再覆盖 runtime 已返回的失败语义。
|
||||
if (active.get()) {
|
||||
source.emitEvent(event);
|
||||
}
|
||||
});
|
||||
return context;
|
||||
}
|
||||
|
||||
private AgentToolResult wrapSubmit(AsyncToolSpec spec, AsyncToolSubmitResult result, AgentToolContext context) {
|
||||
AsyncToolSubmitResult safe = result == null ? new AsyncToolSubmitResult() : result;
|
||||
AsyncToolTaskStatus status = status(safe.getStatus(), AsyncToolTaskStatus.RUNNING);
|
||||
safe.setStatus(status);
|
||||
if (safe.getTaskId() == null || safe.getTaskId().isBlank()) {
|
||||
String message = "Async tool submit must return taskId.";
|
||||
AgentToolResult toolResult = failureResult(spec, PHASE_SUBMIT, null, AsyncToolTaskStatus.FAILED,
|
||||
ERROR_TYPE_EXCEPTION, message, safe);
|
||||
emitFailure(spec, PHASE_SUBMIT, context, null, AsyncToolTaskStatus.FAILED, message);
|
||||
return toolResult;
|
||||
}
|
||||
if (safe.getNextAction() == null || safe.getNextAction().isBlank()) {
|
||||
safe.setNextAction(toolName(spec, PHASE_OBSERVE) + " 查看任务进度。");
|
||||
}
|
||||
AgentToolResult toolResult = successResult(spec, PHASE_SUBMIT, safe.getTaskId(), status,
|
||||
modelContent(safe.getTaskId(), status, safe.getNextAction(), safe.getSummary()), safe);
|
||||
emit(spec, PHASE_SUBMIT, AgentRuntimeEventType.ASYNC_TOOL_SUBMITTED, context, safe.getTaskId(), status,
|
||||
safe.getCursor(), null, safe.getSummary(), null);
|
||||
return toolResult;
|
||||
}
|
||||
|
||||
private AgentToolResult wrapTaskView(AsyncToolSpec spec,
|
||||
String phase,
|
||||
AsyncToolTaskView view,
|
||||
AgentToolContext context) {
|
||||
AsyncToolTaskView safe = view == null ? new AsyncToolTaskView() : view;
|
||||
AsyncToolTaskStatus status = status(safe.getStatus(), AsyncToolTaskStatus.RUNNING);
|
||||
safe.setStatus(status);
|
||||
if (safe.getTerminal() == null) {
|
||||
safe.setTerminal(status.isTerminal());
|
||||
}
|
||||
if (safe.getResultAvailable() == null) {
|
||||
safe.setResultAvailable(status.isSuccess() && safe.getResult() != null);
|
||||
}
|
||||
if (safe.getNextAction() == null || safe.getNextAction().isBlank()) {
|
||||
safe.setNextAction(status.isTerminal()
|
||||
? "任务已结束。"
|
||||
: toolName(spec, PHASE_OBSERVE) + " 继续查看任务进度。");
|
||||
}
|
||||
AgentToolResult toolResult = successResult(spec, phase, safe.getTaskId(), status,
|
||||
modelContent(safe.getTaskId(), status, safe.getNextAction(), safe.getSummary(),
|
||||
Boolean.TRUE.equals(safe.getResultAvailable()), safe.getResult()), safe);
|
||||
emit(spec, phase, PHASE_RESULT.equals(phase)
|
||||
? AgentRuntimeEventType.ASYNC_TOOL_RESULT
|
||||
: AgentRuntimeEventType.ASYNC_TOOL_OBSERVED,
|
||||
context, safe.getTaskId(), status, safe.getCursor(), safe.getNextCursor(), safe.getSummary(),
|
||||
safe.getErrorMessage(), safe.getResultAvailable());
|
||||
return toolResult;
|
||||
}
|
||||
|
||||
private AgentToolResult wrapCancel(AsyncToolSpec spec, AsyncToolCancelResult result, AgentToolContext context) {
|
||||
AsyncToolCancelResult safe = result == null ? new AsyncToolCancelResult() : result;
|
||||
AsyncToolTaskStatus status = status(safe.getStatus(), AsyncToolTaskStatus.CANCELLING);
|
||||
safe.setStatus(status);
|
||||
boolean success = safe.getErrorMessage() == null || safe.getErrorMessage().isBlank();
|
||||
AgentToolResult toolResult = success
|
||||
? successResult(spec, PHASE_CANCEL, safe.getTaskId(), status,
|
||||
modelContent(safe.getTaskId(), status, "继续使用 " + toolName(spec, PHASE_OBSERVE) + " 查看取消状态。",
|
||||
safe.getMessage()), safe)
|
||||
: failureResult(spec, PHASE_CANCEL, safe.getTaskId(), status, ERROR_TYPE_EXCEPTION, safe.getErrorMessage(), safe);
|
||||
emit(spec, PHASE_CANCEL, success ? AgentRuntimeEventType.ASYNC_TOOL_CANCELLED : AgentRuntimeEventType.ASYNC_TOOL_FAILED,
|
||||
context, safe.getTaskId(), status, null, null, safe.getMessage(), safe.getErrorMessage());
|
||||
return toolResult;
|
||||
}
|
||||
|
||||
private AgentToolResult wrapList(AsyncToolSpec spec, AsyncToolTaskListResult result, AgentToolContext context) {
|
||||
AsyncToolTaskListResult safe = result == null ? new AsyncToolTaskListResult() : result;
|
||||
String summary = "共 " + safe.getTasks().size() + " 个任务。";
|
||||
AgentToolResult toolResult = successResult(spec, PHASE_LIST, null, null,
|
||||
modelContent(null, null, "按 task_id 使用观察或结果工具查看详情。", summary), safe);
|
||||
emit(spec, PHASE_LIST, AgentRuntimeEventType.ASYNC_TOOL_LISTED, context, null, null, null, null, summary, null);
|
||||
return toolResult;
|
||||
}
|
||||
|
||||
private AgentToolResult successResult(AsyncToolSpec spec,
|
||||
String phase,
|
||||
String taskId,
|
||||
AsyncToolTaskStatus status,
|
||||
String modelContent,
|
||||
Object displayContent) {
|
||||
AgentToolResult result = AgentToolResult.success(truncate(modelContent, spec.getOptions().getMaxModelContentLength()));
|
||||
result.setDisplayContent(displayContent);
|
||||
result.setMetadata(metadata(spec, phase, taskId, status));
|
||||
return result;
|
||||
}
|
||||
|
||||
private AgentToolResult failureResult(AsyncToolSpec spec,
|
||||
String phase,
|
||||
String taskId,
|
||||
AsyncToolTaskStatus status,
|
||||
String errorType,
|
||||
String errorMessage) {
|
||||
return failureResult(spec, phase, taskId, status, errorType, errorMessage, null);
|
||||
}
|
||||
|
||||
private AgentToolResult failureResult(AsyncToolSpec spec,
|
||||
String phase,
|
||||
String taskId,
|
||||
AsyncToolTaskStatus status,
|
||||
String errorType,
|
||||
String errorMessage,
|
||||
Object displayContent) {
|
||||
String message = errorMessage == null || errorMessage.isBlank() ? "Async tool failed." : errorMessage;
|
||||
AgentToolResult result = AgentToolResult.failure(message);
|
||||
result.setDisplayContent(displayContent == null ? Map.of("errorType", errorType, "message", message) : displayContent);
|
||||
result.setMetadata(metadata(spec, phase, taskId, status));
|
||||
result.getMetadata().put("errorType", errorType);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void emitFailure(AsyncToolSpec spec,
|
||||
String phase,
|
||||
AgentToolContext context,
|
||||
String taskId,
|
||||
AsyncToolTaskStatus status,
|
||||
String errorMessage) {
|
||||
emit(spec, phase, AgentRuntimeEventType.ASYNC_TOOL_FAILED, context, taskId, status, null, null, null, errorMessage);
|
||||
}
|
||||
|
||||
private void emit(AsyncToolSpec spec,
|
||||
String phase,
|
||||
AgentRuntimeEventType type,
|
||||
AgentToolContext context,
|
||||
String taskId,
|
||||
AsyncToolTaskStatus status,
|
||||
Long cursor,
|
||||
Long nextCursor,
|
||||
String summary,
|
||||
String errorMessage) {
|
||||
emit(spec, phase, type, context, taskId, status, cursor, nextCursor, summary, errorMessage, null);
|
||||
}
|
||||
|
||||
private void emit(AsyncToolSpec spec,
|
||||
String phase,
|
||||
AgentRuntimeEventType type,
|
||||
AgentToolContext context,
|
||||
String taskId,
|
||||
AsyncToolTaskStatus status,
|
||||
Long cursor,
|
||||
Long nextCursor,
|
||||
String summary,
|
||||
String errorMessage,
|
||||
Boolean resultAvailable) {
|
||||
if (context == null) {
|
||||
return;
|
||||
}
|
||||
AgentRuntimeEvent event = AgentRuntimeEvent.of(type);
|
||||
event.setTraceId(context.getTraceId());
|
||||
event.setSessionId(context.getSessionId());
|
||||
event.setAgentId(context.getAgentId());
|
||||
event.setToolCallId(context.getToolCallId());
|
||||
event.getMetadata().putAll(spec.getMetadata());
|
||||
putIfNotNull(event.getMetadata(), "requestId", context.getRequestId());
|
||||
event.getPayload().put("asyncToolName", spec.getName());
|
||||
event.getPayload().put("phase", phase);
|
||||
putIfNotNull(event.getPayload(), "toolDisplayName", spec.getMetadata().get("toolDisplayName"));
|
||||
putIfNotNull(event.getPayload(), "taskId", taskId);
|
||||
putIfNotNull(event.getPayload(), "status", status == null ? null : status.name());
|
||||
putIfNotNull(event.getPayload(), "cursor", cursor);
|
||||
putIfNotNull(event.getPayload(), "nextCursor", nextCursor);
|
||||
putIfNotNull(event.getPayload(), "summary", truncate(summary, spec.getOptions().getMaxEventTextLength()));
|
||||
putIfNotNull(event.getPayload(), "errorMessage", truncate(errorMessage, spec.getOptions().getMaxEventTextLength()));
|
||||
putIfNotNull(event.getPayload(), "resultAvailable", resultAvailable);
|
||||
event.getMetadata().put("asyncTool", true);
|
||||
event.getMetadata().put("asyncToolName", spec.getName());
|
||||
event.getMetadata().put("asyncToolPhase", phase);
|
||||
context.emitEvent(event);
|
||||
}
|
||||
|
||||
private Map<String, Object> metadata(AsyncToolSpec spec, String phase, String taskId, AsyncToolTaskStatus status) {
|
||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
metadata.putAll(spec.getMetadata());
|
||||
metadata.put("asyncTool", true);
|
||||
metadata.put("asyncToolName", spec.getName());
|
||||
metadata.put("asyncToolPhase", phase);
|
||||
putIfNotNull(metadata, "taskId", taskId);
|
||||
putIfNotNull(metadata, "status", status == null ? null : status.name());
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private AsyncToolObserveRequest observeRequest(Map<String, Object> arguments, AsyncToolOptions options) {
|
||||
AsyncToolObserveRequest request = new AsyncToolObserveRequest();
|
||||
request.setTaskId(stringValue(arguments, "taskId"));
|
||||
request.setCursor(longValue(arguments, "cursor"));
|
||||
request.setLimit(limit(arguments, options));
|
||||
return request;
|
||||
}
|
||||
|
||||
private AsyncToolResultRequest resultRequest(Map<String, Object> arguments, AsyncToolOptions options) {
|
||||
AsyncToolResultRequest request = new AsyncToolResultRequest();
|
||||
request.setTaskId(stringValue(arguments, "taskId"));
|
||||
request.setCursor(longValue(arguments, "cursor"));
|
||||
request.setLimit(limit(arguments, options));
|
||||
return request;
|
||||
}
|
||||
|
||||
private AsyncToolCancelRequest cancelRequest(Map<String, Object> arguments) {
|
||||
AsyncToolCancelRequest request = new AsyncToolCancelRequest();
|
||||
request.setTaskId(stringValue(arguments, "taskId"));
|
||||
request.setReason(stringValue(arguments, "reason"));
|
||||
return request;
|
||||
}
|
||||
|
||||
private AsyncToolListRequest listRequest(Map<String, Object> arguments) {
|
||||
AsyncToolListRequest request = new AsyncToolListRequest();
|
||||
String status = stringValue(arguments, "status");
|
||||
if (status != null && !status.isBlank()) {
|
||||
request.setStatus(AsyncToolTaskStatus.valueOf(status.trim().toUpperCase(Locale.ROOT)));
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
private Integer limit(Map<String, Object> arguments, AsyncToolOptions options) {
|
||||
Integer limit = intValue(arguments, "limit");
|
||||
if (limit == null || limit <= 0) {
|
||||
return options.getDefaultEventLimit();
|
||||
}
|
||||
return Math.min(limit, options.getMaxEventLimit());
|
||||
}
|
||||
|
||||
private String modelContent(String taskId, AsyncToolTaskStatus status, String nextAction, String summary) {
|
||||
return modelContent(taskId, status, nextAction, summary, false, null);
|
||||
}
|
||||
|
||||
private String modelContent(String taskId,
|
||||
AsyncToolTaskStatus status,
|
||||
String nextAction,
|
||||
String summary,
|
||||
boolean resultAvailable,
|
||||
Object result) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
if (taskId != null && !taskId.isBlank()) {
|
||||
builder.append("task_id: ").append(taskId).append('\n');
|
||||
}
|
||||
if (status != null) {
|
||||
builder.append("status: ").append(status.name()).append('\n');
|
||||
}
|
||||
if (summary != null && !summary.isBlank()) {
|
||||
builder.append("summary: ").append(summary).append('\n');
|
||||
}
|
||||
if (resultAvailable) {
|
||||
builder.append("result_available: true").append('\n');
|
||||
builder.append("result: ").append(modelResult(result)).append('\n');
|
||||
}
|
||||
if (nextAction != null && !nextAction.isBlank()) {
|
||||
builder.append("next_action: ").append(nextAction);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String modelResult(Object result) {
|
||||
if (result == null) {
|
||||
return "";
|
||||
}
|
||||
if (result instanceof CharSequence
|
||||
|| result instanceof Number
|
||||
|| result instanceof Boolean
|
||||
|| result instanceof Character
|
||||
|| result instanceof Enum<?>) {
|
||||
return String.valueOf(result);
|
||||
}
|
||||
try {
|
||||
return JSON.toJSONString(result);
|
||||
} catch (Exception ignored) {
|
||||
return String.valueOf(result);
|
||||
}
|
||||
}
|
||||
|
||||
private String toolName(AsyncToolSpec spec, String phase) {
|
||||
return spec.getName() + "_" + phase;
|
||||
}
|
||||
|
||||
private AsyncToolTaskStatus status(AsyncToolTaskStatus status, AsyncToolTaskStatus defaultStatus) {
|
||||
return status == null ? defaultStatus : status;
|
||||
}
|
||||
|
||||
private Map<String, Object> emptyObjectSchema() {
|
||||
Map<String, Object> schema = new LinkedHashMap<>();
|
||||
schema.put("type", "object");
|
||||
schema.put("properties", new LinkedHashMap<>());
|
||||
return schema;
|
||||
}
|
||||
|
||||
private Map<String, Object> observeSchema(AsyncToolSpec spec) {
|
||||
Map<String, Object> schema = new LinkedHashMap<>();
|
||||
schema.put("type", "object");
|
||||
Map<String, Object> properties = new LinkedHashMap<>();
|
||||
properties.put("taskId", property("string", "Task id returned by submit."));
|
||||
properties.put("cursor", property("integer", "Event cursor returned by previous observe/result."));
|
||||
properties.put("limit", property("integer", "Maximum number of incremental events."));
|
||||
schema.put("properties", properties);
|
||||
schema.put("required", List.of("taskId"));
|
||||
return schema;
|
||||
}
|
||||
|
||||
private Map<String, Object> cancelSchema() {
|
||||
Map<String, Object> schema = new LinkedHashMap<>();
|
||||
schema.put("type", "object");
|
||||
Map<String, Object> properties = new LinkedHashMap<>();
|
||||
properties.put("taskId", property("string", "Task id returned by submit."));
|
||||
properties.put("reason", property("string", "Optional cancellation reason."));
|
||||
schema.put("properties", properties);
|
||||
schema.put("required", List.of("taskId"));
|
||||
return schema;
|
||||
}
|
||||
|
||||
private Map<String, Object> listSchema() {
|
||||
Map<String, Object> schema = new LinkedHashMap<>();
|
||||
schema.put("type", "object");
|
||||
Map<String, Object> properties = new LinkedHashMap<>();
|
||||
properties.put("status", property("string", "Optional task status filter."));
|
||||
schema.put("properties", properties);
|
||||
return schema;
|
||||
}
|
||||
|
||||
private Map<String, Object> submitOutputSchema() {
|
||||
return outputSchema("AsyncToolSubmitResult");
|
||||
}
|
||||
|
||||
private Map<String, Object> taskViewOutputSchema() {
|
||||
return outputSchema("AsyncToolTaskView");
|
||||
}
|
||||
|
||||
private Map<String, Object> cancelOutputSchema() {
|
||||
return outputSchema("AsyncToolCancelResult");
|
||||
}
|
||||
|
||||
private Map<String, Object> listOutputSchema() {
|
||||
return outputSchema("AsyncToolTaskListResult");
|
||||
}
|
||||
|
||||
private Map<String, Object> outputSchema(String title) {
|
||||
Map<String, Object> schema = new LinkedHashMap<>();
|
||||
schema.put("type", "object");
|
||||
schema.put("title", title);
|
||||
return schema;
|
||||
}
|
||||
|
||||
private Map<String, Object> property(String type, String description) {
|
||||
Map<String, Object> property = new LinkedHashMap<>();
|
||||
property.put("type", type);
|
||||
property.put("description", description);
|
||||
return property;
|
||||
}
|
||||
|
||||
private Map<String, Object> safeMap(Map<String, Object> arguments) {
|
||||
return arguments == null ? new LinkedHashMap<>() : new LinkedHashMap<>(arguments);
|
||||
}
|
||||
|
||||
private String stringValue(Map<String, Object> arguments, String key) {
|
||||
Object value = arguments == null ? null : arguments.get(key);
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
|
||||
private Long longValue(Map<String, Object> arguments, String key) {
|
||||
Object value = arguments == null ? null : arguments.get(key);
|
||||
if (value instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (value == null || String.valueOf(value).isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return Long.parseLong(String.valueOf(value));
|
||||
}
|
||||
|
||||
private Integer intValue(Map<String, Object> arguments, String key) {
|
||||
Object value = arguments == null ? null : arguments.get(key);
|
||||
if (value instanceof Number number) {
|
||||
return number.intValue();
|
||||
}
|
||||
if (value == null || String.valueOf(value).isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return Integer.parseInt(String.valueOf(value));
|
||||
}
|
||||
|
||||
private void putIfNotNull(Map<String, Object> target, String key, Object value) {
|
||||
if (value != null) {
|
||||
target.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private String truncate(String value, int maxLength) {
|
||||
if (value == null || value.length() <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
return value.substring(0, Math.max(0, maxLength)) + "...";
|
||||
}
|
||||
|
||||
private static class DelegatingExecutorService extends AbstractExecutorService {
|
||||
|
||||
private final Executor executor;
|
||||
private volatile boolean shutdown;
|
||||
|
||||
private DelegatingExecutorService(Executor executor) {
|
||||
this.executor = executor == null ? ForkJoinPool.commonPool() : executor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
shutdown = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Runnable> shutdownNow() {
|
||||
shutdown = true;
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isShutdown() {
|
||||
return shutdown;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTerminated() {
|
||||
return shutdown;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean awaitTermination(long timeout, TimeUnit unit) {
|
||||
return shutdown;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Runnable command) {
|
||||
executor.execute(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.easyagents.agent.runtime.tool.asynctool;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 异步工具提交结果。
|
||||
*/
|
||||
public class AsyncToolSubmitResult {
|
||||
|
||||
private String taskId;
|
||||
private AsyncToolTaskStatus status;
|
||||
/**
|
||||
* 提交后调用方已读取到的事件位置,后续 observe 可从该位置继续增量读取。
|
||||
*/
|
||||
private Long cursor;
|
||||
private String summary;
|
||||
private String nextAction;
|
||||
private Map<String, Object> payload = new LinkedHashMap<>();
|
||||
private Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 创建空提交结果。
|
||||
*/
|
||||
public AsyncToolSubmitResult() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务 ID。
|
||||
*
|
||||
* @return 任务 ID
|
||||
*/
|
||||
public String getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置任务 ID。
|
||||
*
|
||||
* @param taskId 任务 ID
|
||||
*/
|
||||
public void setTaskId(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务状态。
|
||||
*
|
||||
* @return 任务状态
|
||||
*/
|
||||
public AsyncToolTaskStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置任务状态。
|
||||
*
|
||||
* @param status 任务状态
|
||||
*/
|
||||
public void setStatus(AsyncToolTaskStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前事件读取位置。
|
||||
*
|
||||
* @return 当前事件读取位置
|
||||
*/
|
||||
public Long getCursor() {
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前事件读取位置。
|
||||
*
|
||||
* @param cursor 当前事件读取位置
|
||||
*/
|
||||
public void setCursor(Long cursor) {
|
||||
this.cursor = cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取摘要。
|
||||
*
|
||||
* @return 摘要
|
||||
*/
|
||||
public String getSummary() {
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置摘要。
|
||||
*
|
||||
* @param summary 摘要
|
||||
*/
|
||||
public void setSummary(String summary) {
|
||||
this.summary = summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下一步建议。
|
||||
*
|
||||
* @return 下一步建议
|
||||
*/
|
||||
public String getNextAction() {
|
||||
return nextAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置下一步建议。
|
||||
*
|
||||
* @param nextAction 下一步建议
|
||||
*/
|
||||
public void setNextAction(String nextAction) {
|
||||
this.nextAction = nextAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取业务扩展载荷。
|
||||
*
|
||||
* @return 业务扩展载荷
|
||||
*/
|
||||
public Map<String, Object> getPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置业务扩展载荷。
|
||||
*
|
||||
* @param payload 业务扩展载荷
|
||||
*/
|
||||
public void setPayload(Map<String, Object> payload) {
|
||||
this.payload = payload == null ? new LinkedHashMap<>() : payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元数据。
|
||||
*
|
||||
* @return 元数据
|
||||
*/
|
||||
public Map<String, Object> getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置元数据。
|
||||
*
|
||||
* @param metadata 元数据
|
||||
*/
|
||||
public void setMetadata(Map<String, Object> metadata) {
|
||||
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.easyagents.agent.runtime.tool.asynctool;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 异步工具任务事件。
|
||||
*/
|
||||
public class AsyncToolTaskEvent {
|
||||
|
||||
/**
|
||||
* 任务内单调递增事件序号,用于 cursor 增量读取。
|
||||
*/
|
||||
private Long sequence;
|
||||
private String type;
|
||||
private String text;
|
||||
private Instant createdAt = Instant.now();
|
||||
private Map<String, Object> payload = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 创建空任务事件。
|
||||
*/
|
||||
public AsyncToolTaskEvent() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取事件序号。
|
||||
*
|
||||
* @return 事件序号
|
||||
*/
|
||||
public Long getSequence() {
|
||||
return sequence;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置事件序号。
|
||||
*
|
||||
* @param sequence 事件序号
|
||||
*/
|
||||
public void setSequence(Long sequence) {
|
||||
this.sequence = sequence;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取事件类型。
|
||||
*
|
||||
* @return 事件类型
|
||||
*/
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置事件类型。
|
||||
*
|
||||
* @param type 事件类型
|
||||
*/
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取事件文本。
|
||||
*
|
||||
* @return 事件文本
|
||||
*/
|
||||
public String getText() {
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置事件文本。
|
||||
*
|
||||
* @param text 事件文本
|
||||
*/
|
||||
public void setText(String text) {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取事件创建时间。
|
||||
*
|
||||
* @return 事件创建时间
|
||||
*/
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置事件创建时间。
|
||||
*
|
||||
* @param createdAt 事件创建时间
|
||||
*/
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt == null ? Instant.now() : createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取业务扩展载荷。
|
||||
*
|
||||
* @return 业务扩展载荷
|
||||
*/
|
||||
public Map<String, Object> getPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置业务扩展载荷。
|
||||
*
|
||||
* @param payload 业务扩展载荷
|
||||
*/
|
||||
public void setPayload(Map<String, Object> payload) {
|
||||
this.payload = payload == null ? new LinkedHashMap<>() : payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.easyagents.agent.runtime.tool.asynctool;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 异步工具任务列表结果。
|
||||
*/
|
||||
public class AsyncToolTaskListResult {
|
||||
|
||||
private List<AsyncToolTaskSummary> tasks = new ArrayList<>();
|
||||
private Map<String, Object> payload = new LinkedHashMap<>();
|
||||
private Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 创建空任务列表结果。
|
||||
*/
|
||||
public AsyncToolTaskListResult() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务摘要列表。
|
||||
*
|
||||
* @return 任务摘要列表
|
||||
*/
|
||||
public List<AsyncToolTaskSummary> getTasks() {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置任务摘要列表。
|
||||
*
|
||||
* @param tasks 任务摘要列表
|
||||
*/
|
||||
public void setTasks(List<AsyncToolTaskSummary> tasks) {
|
||||
this.tasks = tasks == null ? new ArrayList<>() : tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取业务扩展载荷。
|
||||
*
|
||||
* @return 业务扩展载荷
|
||||
*/
|
||||
public Map<String, Object> getPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置业务扩展载荷。
|
||||
*
|
||||
* @param payload 业务扩展载荷
|
||||
*/
|
||||
public void setPayload(Map<String, Object> payload) {
|
||||
this.payload = payload == null ? new LinkedHashMap<>() : payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元数据。
|
||||
*
|
||||
* @return 元数据
|
||||
*/
|
||||
public Map<String, Object> getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置元数据。
|
||||
*
|
||||
* @param metadata 元数据
|
||||
*/
|
||||
public void setMetadata(Map<String, Object> metadata) {
|
||||
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.easyagents.agent.runtime.tool.asynctool;
|
||||
|
||||
/**
|
||||
* 异步工具任务对 Agent Runtime 暴露的统一状态。
|
||||
*/
|
||||
public enum AsyncToolTaskStatus {
|
||||
|
||||
/**
|
||||
* 任务已创建,等待业务侧执行。
|
||||
*/
|
||||
PENDING,
|
||||
|
||||
/**
|
||||
* 任务正在执行。
|
||||
*/
|
||||
RUNNING,
|
||||
|
||||
/**
|
||||
* 任务执行成功,结果可用。
|
||||
*/
|
||||
SUCCEEDED,
|
||||
|
||||
/**
|
||||
* 任务执行失败。
|
||||
*/
|
||||
FAILED,
|
||||
|
||||
/**
|
||||
* 任务正在取消。
|
||||
*/
|
||||
CANCELLING,
|
||||
|
||||
/**
|
||||
* 任务已取消。
|
||||
*/
|
||||
CANCELLED,
|
||||
|
||||
/**
|
||||
* 任务执行超时。
|
||||
*/
|
||||
TIMEOUT;
|
||||
|
||||
/**
|
||||
* 判断状态是否为终态。
|
||||
*
|
||||
* @return 终态返回 true
|
||||
*/
|
||||
public boolean isTerminal() {
|
||||
return this == SUCCEEDED || this == FAILED || this == CANCELLED || this == TIMEOUT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断状态是否仍在执行或等待执行。
|
||||
*
|
||||
* @return 仍在运行返回 true
|
||||
*/
|
||||
public boolean isRunning() {
|
||||
return this == PENDING || this == RUNNING || this == CANCELLING;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断状态是否表示成功。
|
||||
*
|
||||
* @return 成功返回 true
|
||||
*/
|
||||
public boolean isSuccess() {
|
||||
return this == SUCCEEDED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断状态是否表示失败类终态。
|
||||
*
|
||||
* @return 失败、取消或超时返回 true
|
||||
*/
|
||||
public boolean isFailure() {
|
||||
return this == FAILED || this == CANCELLED || this == TIMEOUT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.easyagents.agent.runtime.tool.asynctool;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 异步工具任务摘要。
|
||||
*/
|
||||
public class AsyncToolTaskSummary {
|
||||
|
||||
private String taskId;
|
||||
private AsyncToolTaskStatus status;
|
||||
private String summary;
|
||||
private Instant createdAt;
|
||||
private Instant updatedAt;
|
||||
private Map<String, Object> payload = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 创建空任务摘要。
|
||||
*/
|
||||
public AsyncToolTaskSummary() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务 ID。
|
||||
*
|
||||
* @return 任务 ID
|
||||
*/
|
||||
public String getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置任务 ID。
|
||||
*
|
||||
* @param taskId 任务 ID
|
||||
*/
|
||||
public void setTaskId(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务状态。
|
||||
*
|
||||
* @return 任务状态
|
||||
*/
|
||||
public AsyncToolTaskStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置任务状态。
|
||||
*
|
||||
* @param status 任务状态
|
||||
*/
|
||||
public void setStatus(AsyncToolTaskStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务摘要。
|
||||
*
|
||||
* @return 任务摘要
|
||||
*/
|
||||
public String getSummary() {
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置任务摘要。
|
||||
*
|
||||
* @param summary 任务摘要
|
||||
*/
|
||||
public void setSummary(String summary) {
|
||||
this.summary = summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取创建时间。
|
||||
*
|
||||
* @return 创建时间
|
||||
*/
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置创建时间。
|
||||
*
|
||||
* @param createdAt 创建时间
|
||||
*/
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取更新时间。
|
||||
*
|
||||
* @return 更新时间
|
||||
*/
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置更新时间。
|
||||
*
|
||||
* @param updatedAt 更新时间
|
||||
*/
|
||||
public void setUpdatedAt(Instant updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取业务扩展载荷。
|
||||
*
|
||||
* @return 业务扩展载荷
|
||||
*/
|
||||
public Map<String, Object> getPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置业务扩展载荷。
|
||||
*
|
||||
* @param payload 业务扩展载荷
|
||||
*/
|
||||
public void setPayload(Map<String, Object> payload) {
|
||||
this.payload = payload == null ? new LinkedHashMap<>() : payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package com.easyagents.agent.runtime.tool.asynctool;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 异步工具任务视图。
|
||||
*
|
||||
* <p>observe 和 result 子工具共用该实体。未完成时表达观察态,完成时可同时携带最终结果。</p>
|
||||
*/
|
||||
public class AsyncToolTaskView {
|
||||
|
||||
private String taskId;
|
||||
private AsyncToolTaskStatus status;
|
||||
/**
|
||||
* 调用方本次传入的已读事件位置,用于增量读取任务事件。
|
||||
*/
|
||||
private Long cursor;
|
||||
/**
|
||||
* 服务端返回的下一次观察起点,调用方下次应使用该值继续读取增量事件。
|
||||
*/
|
||||
private Long nextCursor;
|
||||
private Integer progress;
|
||||
private String summary;
|
||||
private String nextAction;
|
||||
private List<AsyncToolTaskEvent> events = new ArrayList<>();
|
||||
private Object result;
|
||||
private String errorMessage;
|
||||
private String errorType;
|
||||
private Boolean terminal;
|
||||
private Boolean resultAvailable;
|
||||
private Map<String, Object> payload = new LinkedHashMap<>();
|
||||
private Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 创建空任务视图。
|
||||
*/
|
||||
public AsyncToolTaskView() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务 ID。
|
||||
*
|
||||
* @return 任务 ID
|
||||
*/
|
||||
public String getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置任务 ID。
|
||||
*
|
||||
* @param taskId 任务 ID
|
||||
*/
|
||||
public void setTaskId(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务状态。
|
||||
*
|
||||
* @return 任务状态
|
||||
*/
|
||||
public AsyncToolTaskStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置任务状态。
|
||||
*
|
||||
* @param status 任务状态
|
||||
*/
|
||||
public void setStatus(AsyncToolTaskStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本次请求的已读事件位置。
|
||||
*
|
||||
* @return 已读事件位置
|
||||
*/
|
||||
public Long getCursor() {
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置本次请求的已读事件位置。
|
||||
*
|
||||
* @param cursor 已读事件位置
|
||||
*/
|
||||
public void setCursor(Long cursor) {
|
||||
this.cursor = cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下一次观察起点。
|
||||
*
|
||||
* @return 下一次观察起点
|
||||
*/
|
||||
public Long getNextCursor() {
|
||||
return nextCursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置下一次观察起点。
|
||||
*
|
||||
* @param nextCursor 下一次观察起点
|
||||
*/
|
||||
public void setNextCursor(Long nextCursor) {
|
||||
this.nextCursor = nextCursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取进度百分比。
|
||||
*
|
||||
* @return 进度百分比
|
||||
*/
|
||||
public Integer getProgress() {
|
||||
return progress;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置进度百分比。
|
||||
*
|
||||
* @param progress 进度百分比
|
||||
*/
|
||||
public void setProgress(Integer progress) {
|
||||
this.progress = progress;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取摘要。
|
||||
*
|
||||
* @return 摘要
|
||||
*/
|
||||
public String getSummary() {
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置摘要。
|
||||
*
|
||||
* @param summary 摘要
|
||||
*/
|
||||
public void setSummary(String summary) {
|
||||
this.summary = summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下一步建议。
|
||||
*
|
||||
* @return 下一步建议
|
||||
*/
|
||||
public String getNextAction() {
|
||||
return nextAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置下一步建议。
|
||||
*
|
||||
* @param nextAction 下一步建议
|
||||
*/
|
||||
public void setNextAction(String nextAction) {
|
||||
this.nextAction = nextAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本次增量事件。
|
||||
*
|
||||
* @return 本次增量事件
|
||||
*/
|
||||
public List<AsyncToolTaskEvent> getEvents() {
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置本次增量事件。
|
||||
*
|
||||
* @param events 本次增量事件
|
||||
*/
|
||||
public void setEvents(List<AsyncToolTaskEvent> events) {
|
||||
this.events = events == null ? new ArrayList<>() : events;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最终结果。
|
||||
*
|
||||
* @return 最终结果
|
||||
*/
|
||||
public Object getResult() {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置最终结果。
|
||||
*
|
||||
* @param result 最终结果
|
||||
*/
|
||||
public void setResult(Object result) {
|
||||
this.result = result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误消息。
|
||||
*
|
||||
* @return 错误消息
|
||||
*/
|
||||
public String getErrorMessage() {
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置错误消息。
|
||||
*
|
||||
* @param errorMessage 错误消息
|
||||
*/
|
||||
public void setErrorMessage(String errorMessage) {
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误类型。
|
||||
*
|
||||
* @return 错误类型
|
||||
*/
|
||||
public String getErrorType() {
|
||||
return errorType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置错误类型。
|
||||
*
|
||||
* @param errorType 错误类型
|
||||
*/
|
||||
public void setErrorType(String errorType) {
|
||||
this.errorType = errorType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取是否终态。
|
||||
*
|
||||
* @return 是否终态
|
||||
*/
|
||||
public Boolean getTerminal() {
|
||||
return terminal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否终态。
|
||||
*
|
||||
* @param terminal 是否终态
|
||||
*/
|
||||
public void setTerminal(Boolean terminal) {
|
||||
this.terminal = terminal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最终结果是否可用。
|
||||
*
|
||||
* @return 最终结果是否可用
|
||||
*/
|
||||
public Boolean getResultAvailable() {
|
||||
return resultAvailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置最终结果是否可用。
|
||||
*
|
||||
* @param resultAvailable 最终结果是否可用
|
||||
*/
|
||||
public void setResultAvailable(Boolean resultAvailable) {
|
||||
this.resultAvailable = resultAvailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取业务扩展载荷。
|
||||
*
|
||||
* @return 业务扩展载荷
|
||||
*/
|
||||
public Map<String, Object> getPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置业务扩展载荷。
|
||||
*
|
||||
* @param payload 业务扩展载荷
|
||||
*/
|
||||
public void setPayload(Map<String, Object> payload) {
|
||||
this.payload = payload == null ? new LinkedHashMap<>() : payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元数据。
|
||||
*
|
||||
* @return 元数据
|
||||
*/
|
||||
public Map<String, Object> getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置元数据。
|
||||
*
|
||||
* @param metadata 元数据
|
||||
*/
|
||||
public void setMetadata(Map<String, Object> metadata) {
|
||||
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package com.easyagents.agent.runtime.tool.operate;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRuntimeException;
|
||||
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolCategory;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolSpec;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolVisibility;
|
||||
import io.agentscope.core.tool.Toolkit;
|
||||
import 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 暂停、恢复和审计语义一致。
|
||||
*/
|
||||
public class AgentOperateToolAdapter {
|
||||
|
||||
public static final String VIEW_TEXT_FILE_TOOL = "view_text_file";
|
||||
public static final String LIST_DIRECTORY_TOOL = "list_directory";
|
||||
public static final String WRITE_TEXT_FILE_TOOL = "write_text_file";
|
||||
public static final String INSERT_TEXT_FILE_TOOL = "insert_text_file";
|
||||
public static final String EXECUTE_SHELL_COMMAND_TOOL = "execute_shell_command";
|
||||
|
||||
/**
|
||||
* 将操作工具声明注册到 Toolkit,并返回供 HITL 拦截器使用的工具声明。
|
||||
*
|
||||
* @param specs 操作工具声明
|
||||
* @param toolkit AgentScope Toolkit
|
||||
* @return 已启用操作工具对应的 AgentToolSpec
|
||||
*/
|
||||
public List<AgentToolSpec> register(List<AgentOperateToolSpec> specs, Toolkit toolkit) {
|
||||
List<AgentToolSpec> toolSpecs = new ArrayList<>();
|
||||
if (specs == null || specs.isEmpty()) {
|
||||
return toolSpecs;
|
||||
}
|
||||
for (AgentOperateToolSpec spec : specs) {
|
||||
if (spec == null || !spec.isEnabled()) {
|
||||
continue;
|
||||
}
|
||||
registerOne(spec, toolkit, toolSpecs);
|
||||
}
|
||||
return toolSpecs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析已启用操作工具会注册到 AgentScope 的工具名称。
|
||||
*
|
||||
* @param specs 操作工具声明
|
||||
* @return 工具名称集合
|
||||
*/
|
||||
public Set<String> enabledToolNames(List<AgentOperateToolSpec> specs) {
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
if (specs == null || specs.isEmpty()) {
|
||||
return names;
|
||||
}
|
||||
for (AgentOperateToolSpec spec : specs) {
|
||||
if (spec == null || !spec.isEnabled() || spec.getType() == null) {
|
||||
continue;
|
||||
}
|
||||
switch (spec.getType()) {
|
||||
case READ_FILE -> {
|
||||
names.add(VIEW_TEXT_FILE_TOOL);
|
||||
names.add(LIST_DIRECTORY_TOOL);
|
||||
}
|
||||
case WRITE_FILE -> {
|
||||
names.add(WRITE_TEXT_FILE_TOOL);
|
||||
names.add(INSERT_TEXT_FILE_TOOL);
|
||||
}
|
||||
case SHELL -> names.add(EXECUTE_SHELL_COMMAND_TOOL);
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private void registerOne(AgentOperateToolSpec spec, Toolkit toolkit, List<AgentToolSpec> toolSpecs) {
|
||||
AgentOperateToolType type = spec.getType();
|
||||
if (type == null) {
|
||||
throw new AgentRuntimeException("Agent operate tool type is required.");
|
||||
}
|
||||
Path baseDir = validateBaseDir(spec);
|
||||
switch (type) {
|
||||
case READ_FILE -> {
|
||||
assertNoToolConflict(toolkit, VIEW_TEXT_FILE_TOOL);
|
||||
assertNoToolConflict(toolkit, LIST_DIRECTORY_TOOL);
|
||||
toolkit.registerTool(new ReadFileTool(baseDir.toString()));
|
||||
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));
|
||||
}
|
||||
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));
|
||||
}
|
||||
default -> throw new AgentRuntimeException("Unsupported agent operate tool type: " + type);
|
||||
}
|
||||
}
|
||||
|
||||
private Path validateBaseDir(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;
|
||||
}
|
||||
try {
|
||||
return Charset.forName(charsetName.trim());
|
||||
} catch (Exception error) {
|
||||
throw new AgentRuntimeException("Invalid shell charset: " + charsetName, error);
|
||||
}
|
||||
}
|
||||
|
||||
private AgentToolSpec toolSpec(AgentOperateToolSpec operateSpec,
|
||||
String toolName,
|
||||
String description,
|
||||
boolean defaultApprovalRequired) {
|
||||
boolean approvalRequired = operateSpec.getApprovalRequired() == null
|
||||
? defaultApprovalRequired : operateSpec.getApprovalRequired();
|
||||
AgentToolSpec toolSpec = new AgentToolSpec();
|
||||
toolSpec.setName(toolName);
|
||||
toolSpec.setDescription(description);
|
||||
toolSpec.setCategory(AgentToolCategory.CUSTOM);
|
||||
toolSpec.setVisibility(AgentToolVisibility.VISIBLE);
|
||||
toolSpec.setApprovalRequired(approvalRequired);
|
||||
toolSpec.setApprovalRequest(approvalRequest(operateSpec, approvalRequired));
|
||||
toolSpec.setMetadata(metadata(operateSpec));
|
||||
return toolSpec;
|
||||
}
|
||||
|
||||
private AgentToolApprovalRequest approvalRequest(AgentOperateToolSpec operateSpec, boolean approvalRequired) {
|
||||
AgentToolApprovalRequest request = operateSpec.getApprovalRequest();
|
||||
if (request != null) {
|
||||
return request;
|
||||
}
|
||||
AgentToolApprovalRequest defaultRequest = new AgentToolApprovalRequest();
|
||||
if (approvalRequired) {
|
||||
defaultRequest.setApprovalPrompt("请确认是否允许智能体执行该操作工具。");
|
||||
}
|
||||
return defaultRequest;
|
||||
}
|
||||
|
||||
private Map<String, Object> metadata(AgentOperateToolSpec spec) {
|
||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
metadata.put("operateTool", true);
|
||||
metadata.put("operateToolType", spec.getType().name());
|
||||
metadata.put("baseDir", spec.getBaseDir());
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private void assertNoToolConflict(Toolkit toolkit, String toolName) {
|
||||
if (toolkit.getTool(toolName) != null) {
|
||||
throw new AgentRuntimeException("Agent operate tool conflicts with existing tool: " + toolName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.easyagents.agent.runtime.tool.operate;
|
||||
|
||||
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Agent 操作类工具声明。
|
||||
*
|
||||
* <p>操作类工具是 runtime 直接适配的 AgentScope 内置工具,用于读文件、写文件和执行 Shell。
|
||||
* 这些工具直接作用于后端 JVM 所在宿主环境,调用方必须按 agent、session 或 user 维度传入受控
|
||||
* 的绝对工作目录。
|
||||
*/
|
||||
public class AgentOperateToolSpec {
|
||||
|
||||
private AgentOperateToolType type;
|
||||
private boolean enabled = true;
|
||||
private String baseDir;
|
||||
private Boolean approvalRequired;
|
||||
private AgentToolApprovalRequest approvalRequest;
|
||||
private Set<String> shellAllowedCommands = new LinkedHashSet<>();
|
||||
private String shellCharset;
|
||||
|
||||
/**
|
||||
* 获取操作工具类型。
|
||||
*
|
||||
* @return 操作工具类型
|
||||
*/
|
||||
public AgentOperateToolType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置操作工具类型。
|
||||
*
|
||||
* @param type 操作工具类型
|
||||
*/
|
||||
public void setType(AgentOperateToolType type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回是否启用该操作工具。
|
||||
*
|
||||
* @return 启用时为 true
|
||||
*/
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否启用该操作工具。
|
||||
*
|
||||
* @param enabled 启用标记
|
||||
*/
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作工具工作目录。
|
||||
*
|
||||
* @return 绝对工作目录
|
||||
*/
|
||||
public String getBaseDir() {
|
||||
return baseDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置操作工具工作目录。
|
||||
*
|
||||
* @param baseDir 绝对工作目录
|
||||
*/
|
||||
public void setBaseDir(String baseDir) {
|
||||
this.baseDir = baseDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审批开关覆盖值。
|
||||
*
|
||||
* @return 审批覆盖值,null 表示使用工具类型默认值
|
||||
*/
|
||||
public Boolean getApprovalRequired() {
|
||||
return approvalRequired;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置审批开关覆盖值。
|
||||
*
|
||||
* @param approvalRequired 审批覆盖值
|
||||
*/
|
||||
public void setApprovalRequired(Boolean approvalRequired) {
|
||||
this.approvalRequired = approvalRequired;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审批请求配置。
|
||||
*
|
||||
* @return 审批请求配置
|
||||
*/
|
||||
public AgentToolApprovalRequest getApprovalRequest() {
|
||||
return approvalRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置审批请求配置。
|
||||
*
|
||||
* @param approvalRequest 审批请求配置
|
||||
*/
|
||||
public void setApprovalRequest(AgentToolApprovalRequest approvalRequest) {
|
||||
this.approvalRequest = approvalRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Shell 命令白名单。
|
||||
*
|
||||
* @return Shell 命令白名单
|
||||
*/
|
||||
public Set<String> getShellAllowedCommands() {
|
||||
return shellAllowedCommands;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Shell 命令白名单。
|
||||
*
|
||||
* @param shellAllowedCommands Shell 命令白名单
|
||||
*/
|
||||
public void setShellAllowedCommands(Set<String> shellAllowedCommands) {
|
||||
this.shellAllowedCommands = shellAllowedCommands == null ? new LinkedHashSet<>() : new LinkedHashSet<>(shellAllowedCommands);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Shell 输出字符集名称。
|
||||
*
|
||||
* @return 字符集名称
|
||||
*/
|
||||
public String getShellCharset() {
|
||||
return shellCharset;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Shell 输出字符集名称。
|
||||
*
|
||||
* @param shellCharset 字符集名称
|
||||
*/
|
||||
public void setShellCharset(String shellCharset) {
|
||||
this.shellCharset = shellCharset;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.easyagents.agent.runtime.tool.operate;
|
||||
|
||||
/**
|
||||
* Agent 操作类工具类型。
|
||||
*/
|
||||
public enum AgentOperateToolType {
|
||||
|
||||
/**
|
||||
* 读取文本文件与列出目录。
|
||||
*/
|
||||
READ_FILE,
|
||||
|
||||
/**
|
||||
* 写入、覆盖或插入文本文件。
|
||||
*/
|
||||
WRITE_FILE,
|
||||
|
||||
/**
|
||||
* 在服务进程所在宿主环境执行 Shell 命令。
|
||||
*/
|
||||
SHELL
|
||||
}
|
||||
@@ -1,951 +0,0 @@
|
||||
package com.easyagents.agent.runtime.agentscope;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentDefinition;
|
||||
import com.easyagents.agent.runtime.AgentRunRequest;
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
||||
import com.easyagents.agent.runtime.hitl.AgentResumeToken;
|
||||
import com.easyagents.agent.runtime.hitl.AgentToolApprovalCoordinator;
|
||||
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
|
||||
import com.easyagents.agent.runtime.hitl.AgentToolApprovalResponse;
|
||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
|
||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
|
||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
|
||||
import com.easyagents.agent.runtime.memory.AgentMemoryCompressionParameter;
|
||||
import com.easyagents.agent.runtime.memory.AgentMemoryPolicy;
|
||||
import com.easyagents.agent.runtime.memory.AgentMemorySnapshot;
|
||||
import com.easyagents.agent.runtime.message.*;
|
||||
import com.easyagents.agent.runtime.model.AgentModelProviderType;
|
||||
import com.easyagents.agent.runtime.model.AgentModelSpec;
|
||||
import com.easyagents.agent.runtime.persistence.AgentPersistencePolicy;
|
||||
import com.easyagents.agent.runtime.persistence.AgentRuntimeState;
|
||||
import com.easyagents.agent.runtime.persistence.json.JsonAgentSessionStore;
|
||||
import com.easyagents.agent.runtime.persistence.memory.InMemoryAgentSessionStore;
|
||||
import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec;
|
||||
import com.easyagents.agent.runtime.skill.AgentSkillSpec;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolResult;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolSpec;
|
||||
import io.agentscope.core.ReActAgent;
|
||||
import io.agentscope.core.agent.Event;
|
||||
import io.agentscope.core.agent.EventType;
|
||||
import io.agentscope.core.hook.ErrorEvent;
|
||||
import io.agentscope.core.hook.HookEvent;
|
||||
import io.agentscope.core.hook.ReasoningChunkEvent;
|
||||
import io.agentscope.core.memory.autocontext.AutoContextConfig;
|
||||
import io.agentscope.core.message.*;
|
||||
import io.agentscope.core.model.AnthropicChatModel;
|
||||
import io.agentscope.core.model.GeminiChatModel;
|
||||
import io.agentscope.core.model.OpenAIChatModel;
|
||||
import io.agentscope.core.rag.Knowledge;
|
||||
import io.agentscope.core.rag.model.Document;
|
||||
import io.agentscope.core.rag.model.RetrieveConfig;
|
||||
import io.agentscope.core.session.Session;
|
||||
import io.agentscope.core.skill.SkillBox;
|
||||
import io.agentscope.core.tool.AgentTool;
|
||||
import io.agentscope.core.tool.ToolCallParam;
|
||||
import io.agentscope.core.tool.Toolkit;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import reactor.core.publisher.Sinks;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 在不发起真实模型网络调用的情况下测试 AgentScope 适配行为。
|
||||
*/
|
||||
public class AgentScopeAdapterTest {
|
||||
|
||||
@Test
|
||||
public void shouldBuildReActAgentFromDefinition() {
|
||||
AgentRunRequest request = request();
|
||||
AgentScopeReActRuntime runtime = fakeRuntime();
|
||||
|
||||
ReActAgent agent = runtime.buildAgent(request, Sinks.many().unicast().onBackpressureBuffer());
|
||||
|
||||
Assert.assertEquals("agent-name", agent.getName());
|
||||
Assert.assertEquals("system", agent.getSysPrompt());
|
||||
Assert.assertEquals(3, agent.getMaxIters());
|
||||
Assert.assertNotNull(agent.getMemory());
|
||||
Assert.assertNotNull(agent.getToolkit().getTool("echo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRegisterAndCallDynamicTool() {
|
||||
AgentRunRequest request = request();
|
||||
AgentToolSpec toolSpec = request.getAgentDefinition().getToolSpecs().get(0);
|
||||
AgentTool tool = new AgentScopeToolAdapter().adapt(toolSpec, request.getToolInvokers().get("echo"), request);
|
||||
ToolUseBlock block = ToolUseBlock.builder()
|
||||
.id("call-1")
|
||||
.name("echo")
|
||||
.input(Map.of("text", "hello"))
|
||||
.build();
|
||||
|
||||
String result = ((TextBlock) tool.callAsync(ToolCallParam.builder()
|
||||
.toolUseBlock(block)
|
||||
.input(Map.of("text", "hello"))
|
||||
.build())
|
||||
.block()
|
||||
.getOutput()
|
||||
.get(0)).getText();
|
||||
|
||||
Assert.assertTrue(result.contains("hello"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldEmitToolCallAndResultEventsFromToolkitCallback() throws Exception {
|
||||
AgentRunRequest request = request();
|
||||
Sinks.Many<AgentRuntimeEvent> sink = Sinks.many().unicast().onBackpressureBuffer();
|
||||
ReActAgent agent = fakeRuntime().buildAgent(request, sink);
|
||||
CompletableFuture<List<AgentRuntimeEvent>> eventsFuture = sink.asFlux().take(2).collectList().toFuture();
|
||||
ToolUseBlock block = ToolUseBlock.builder()
|
||||
.id("call-2")
|
||||
.name("echo")
|
||||
.input(Map.of("text", "hello"))
|
||||
.build();
|
||||
|
||||
agent.getToolkit().getTool("echo").callAsync(ToolCallParam.builder()
|
||||
.toolUseBlock(block)
|
||||
.input(Map.of("text", "hello"))
|
||||
.build())
|
||||
.block();
|
||||
List<AgentRuntimeEvent> events = eventsFuture.get(3, TimeUnit.SECONDS);
|
||||
|
||||
Assert.assertTrue(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_CALL));
|
||||
Assert.assertTrue(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_RESULT));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldEmitNormalToolEventBeforeSkillActivated() throws Exception {
|
||||
AgentRunRequest request = requestWithSkillBoundTool();
|
||||
Sinks.Many<AgentRuntimeEvent> sink = Sinks.many().unicast().onBackpressureBuffer();
|
||||
ReActAgent agent = fakeRuntime().buildAgent(request, sink);
|
||||
CompletableFuture<List<AgentRuntimeEvent>> eventsFuture = sink.asFlux().take(2).collectList().toFuture();
|
||||
ToolUseBlock block = ToolUseBlock.builder()
|
||||
.id("call-skill-tool")
|
||||
.name("echo")
|
||||
.input(Map.of("text", "hello"))
|
||||
.build();
|
||||
|
||||
agent.getToolkit().getTool("echo").callAsync(ToolCallParam.builder()
|
||||
.toolUseBlock(block)
|
||||
.input(Map.of("text", "hello"))
|
||||
.build())
|
||||
.block();
|
||||
List<AgentRuntimeEvent> events = eventsFuture.get(3, TimeUnit.SECONDS);
|
||||
|
||||
Assert.assertEquals(AgentRuntimeEventType.TOOL_CALL, events.get(0).getEventType());
|
||||
Assert.assertEquals(AgentRuntimeEventType.TOOL_RESULT, events.get(1).getEventType());
|
||||
Assert.assertFalse(events.get(0).getPayload().containsKey("skillId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldEmitSkillStepAfterSkillActivated() throws Exception {
|
||||
AgentRunRequest request = requestWithSkillBoundTool();
|
||||
com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext skillContext =
|
||||
com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext.from(request.getAgentDefinition().getSkillBoxSpec());
|
||||
skillContext.activateSkill("skill-1");
|
||||
Sinks.Many<AgentRuntimeEvent> sink = Sinks.many().unicast().onBackpressureBuffer();
|
||||
AgentTool tool = new AgentScopeToolAdapter().adapt(request.getAgentDefinition().getToolSpecs().get(0),
|
||||
request.getToolInvokers().get("echo"), request, AgentToolApprovalCoordinator.disabled(), sink,
|
||||
skillContext, skillContext.getToolBinding("echo"));
|
||||
CompletableFuture<List<AgentRuntimeEvent>> eventsFuture = sink.asFlux().take(2).collectList().toFuture();
|
||||
|
||||
tool.callAsync(ToolCallParam.builder()
|
||||
.toolUseBlock(ToolUseBlock.builder()
|
||||
.id("call-skill-tool")
|
||||
.name("echo")
|
||||
.input(Map.of("text", "hello"))
|
||||
.build())
|
||||
.input(Map.of("text", "hello"))
|
||||
.build())
|
||||
.block();
|
||||
List<AgentRuntimeEvent> events = eventsFuture.get(3, TimeUnit.SECONDS);
|
||||
|
||||
Assert.assertTrue(events.stream().allMatch(event -> event.getEventType() == AgentRuntimeEventType.SKILL_STEP));
|
||||
Assert.assertEquals("skill-1", events.get(0).getPayload().get("skillId"));
|
||||
Assert.assertEquals("TOOL_CALL", events.get(0).getPayload().get("stepType"));
|
||||
Assert.assertEquals("TOOL_RESULT", events.get(1).getPayload().get("stepType"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldEmitToolApprovalWhenToolRequiresApproval() throws Exception {
|
||||
AgentRunRequest enabled = request();
|
||||
AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest();
|
||||
approvalRequest.setApprovalPrompt("确认执行?");
|
||||
enabled.getAgentDefinition().getToolSpecs().get(0).setApprovalRequired(true);
|
||||
enabled.getAgentDefinition().getToolSpecs().get(0).setApprovalRequest(approvalRequest);
|
||||
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
|
||||
Sinks.Many<AgentRuntimeEvent> sink = Sinks.many().unicast().onBackpressureBuffer();
|
||||
AgentTool tool = new AgentScopeToolAdapter().adapt(enabled.getAgentDefinition().getToolSpecs().get(0),
|
||||
enabled.getToolInvokers().get("echo"), enabled, coordinator, sink);
|
||||
java.util.concurrent.CountDownLatch approvalLatch = new java.util.concurrent.CountDownLatch(1);
|
||||
java.util.concurrent.CopyOnWriteArrayList<AgentRuntimeEvent> events = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||
sink.asFlux().subscribe(event -> {
|
||||
events.add(event);
|
||||
if (event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) {
|
||||
approvalLatch.countDown();
|
||||
}
|
||||
});
|
||||
tool.callAsync(ToolCallParam.builder()
|
||||
.toolUseBlock(ToolUseBlock.builder()
|
||||
.id("call-hitl")
|
||||
.name("echo")
|
||||
.input(Map.of("text", "hello"))
|
||||
.build())
|
||||
.input(Map.of("text", "hello"))
|
||||
.build())
|
||||
.subscribe(result -> {
|
||||
}, error -> {
|
||||
});
|
||||
Assert.assertTrue(approvalLatch.await(5, TimeUnit.SECONDS));
|
||||
AgentRuntimeEvent approval = events.stream()
|
||||
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)
|
||||
.findFirst()
|
||||
.orElseThrow(AssertionError::new);
|
||||
Assert.assertEquals("确认执行?", approval.getPayload().get("approvalPrompt"));
|
||||
Assert.assertNotNull(approval.getPayload().get("resumeToken"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldContinueToolExecutionAfterApproval() throws Exception {
|
||||
AgentRunRequest request = request();
|
||||
AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest();
|
||||
approvalRequest.setApprovalPrompt("确认执行?");
|
||||
request.getAgentDefinition().getToolSpecs().get(0).setApprovalRequired(true);
|
||||
request.getAgentDefinition().getToolSpecs().get(0).setApprovalRequest(approvalRequest);
|
||||
|
||||
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
|
||||
Sinks.Many<AgentRuntimeEvent> sink = Sinks.many().unicast().onBackpressureBuffer();
|
||||
AgentTool tool = new AgentScopeToolAdapter().adapt(request.getAgentDefinition().getToolSpecs().get(0),
|
||||
request.getToolInvokers().get("echo"), request, coordinator, sink);
|
||||
java.util.List<AgentRuntimeEvent> events = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||
java.util.concurrent.CountDownLatch approvalLatch = new java.util.concurrent.CountDownLatch(1);
|
||||
java.util.concurrent.atomic.AtomicReference<Throwable> errorRef = new java.util.concurrent.atomic.AtomicReference<>();
|
||||
sink.asFlux().subscribe(event -> {
|
||||
events.add(event);
|
||||
if (event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) {
|
||||
approvalLatch.countDown();
|
||||
}
|
||||
});
|
||||
java.util.concurrent.CompletableFuture<io.agentscope.core.message.ToolResultBlock> future = tool.callAsync(
|
||||
ToolCallParam.builder()
|
||||
.toolUseBlock(ToolUseBlock.builder()
|
||||
.id("call-hitl")
|
||||
.name("echo")
|
||||
.input(Map.of("text", "hello"))
|
||||
.build())
|
||||
.input(Map.of("text", "hello"))
|
||||
.build()).toFuture();
|
||||
Assert.assertTrue(approvalLatch.await(5, TimeUnit.SECONDS));
|
||||
AgentRuntimeEvent approval = events.stream()
|
||||
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)
|
||||
.findFirst()
|
||||
.orElseThrow(AssertionError::new);
|
||||
|
||||
AgentToolApprovalResponse response = new AgentToolApprovalResponse();
|
||||
AgentResumeToken token = AgentResumeToken.create();
|
||||
token.setValue(String.valueOf(approval.getPayload().get("resumeToken")));
|
||||
response.setResumeToken(token);
|
||||
response.setApproved(true);
|
||||
|
||||
coordinator.submit(response);
|
||||
io.agentscope.core.message.ToolResultBlock resultBlock = future.get(5, TimeUnit.SECONDS);
|
||||
Assert.assertTrue(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_RESULT));
|
||||
Assert.assertNotNull(resultBlock);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCancelToolExecutionAfterRejection() throws Exception {
|
||||
AgentRunRequest request = request();
|
||||
AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest();
|
||||
approvalRequest.setApprovalPrompt("确认执行?");
|
||||
request.getAgentDefinition().getToolSpecs().get(0).setApprovalRequired(true);
|
||||
request.getAgentDefinition().getToolSpecs().get(0).setApprovalRequest(approvalRequest);
|
||||
|
||||
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
|
||||
Sinks.Many<AgentRuntimeEvent> sink = Sinks.many().unicast().onBackpressureBuffer();
|
||||
AgentTool tool = new AgentScopeToolAdapter().adapt(request.getAgentDefinition().getToolSpecs().get(0),
|
||||
request.getToolInvokers().get("echo"), request, coordinator, sink);
|
||||
java.util.List<AgentRuntimeEvent> events = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||
sink.asFlux().subscribe(events::add);
|
||||
java.util.concurrent.CompletableFuture<io.agentscope.core.message.ToolResultBlock> future = tool.callAsync(
|
||||
ToolCallParam.builder()
|
||||
.toolUseBlock(ToolUseBlock.builder()
|
||||
.id("call-hitl")
|
||||
.name("echo")
|
||||
.input(Map.of("text", "hello"))
|
||||
.build())
|
||||
.input(Map.of("text", "hello"))
|
||||
.build()).toFuture();
|
||||
AgentRuntimeEvent approval = null;
|
||||
long deadline = System.currentTimeMillis() + 5000L;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
approval = events.stream()
|
||||
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (approval != null) {
|
||||
break;
|
||||
}
|
||||
Thread.sleep(50L);
|
||||
}
|
||||
if (approval == null) {
|
||||
throw new AssertionError("未收到审批事件");
|
||||
}
|
||||
|
||||
AgentToolApprovalResponse response = new AgentToolApprovalResponse();
|
||||
AgentResumeToken token = AgentResumeToken.create();
|
||||
token.setValue(String.valueOf(approval.getPayload().get("resumeToken")));
|
||||
response.setResumeToken(token);
|
||||
response.setApproved(false);
|
||||
response.setRejectReason("拒绝执行");
|
||||
|
||||
coordinator.submit(response);
|
||||
try {
|
||||
future.get(5, TimeUnit.SECONDS);
|
||||
Assert.fail("拒绝后不应返回工具结果。");
|
||||
} catch (java.util.concurrent.ExecutionException exception) {
|
||||
Assert.assertTrue(exception.getCause() instanceof com.easyagents.agent.runtime.hitl.AgentToolApprovalRejectedException);
|
||||
}
|
||||
Assert.assertFalse(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_RESULT));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldNotDuplicateToolApprovalFromRuntimeEventMapper() throws Exception {
|
||||
AgentRunRequest request = request();
|
||||
AgentScopeReActRuntime runtime = fakeRuntime();
|
||||
java.lang.reflect.Method method = AgentScopeReActRuntime.class.getDeclaredMethod(
|
||||
"mapEvent", AgentRunRequest.class, Event.class);
|
||||
method.setAccessible(true);
|
||||
ToolResultBlock resultBlock = ToolResultBlock.builder()
|
||||
.id("call-hitl")
|
||||
.name("echo")
|
||||
.output(TextBlock.builder().text("need confirm").build())
|
||||
.metadata(Map.of(ToolResultBlock.METADATA_SUSPENDED, true))
|
||||
.build();
|
||||
Msg message = Msg.builder()
|
||||
.role(MsgRole.TOOL)
|
||||
.content(resultBlock)
|
||||
.build();
|
||||
Event event = new Event(EventType.TOOL_RESULT, message, true);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<AgentRuntimeEvent> events = (List<AgentRuntimeEvent>) method.invoke(runtime, request, event);
|
||||
|
||||
Assert.assertTrue(events.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldMapSkillLoadEventsFromAgentScopeToolEvents() throws Exception {
|
||||
AgentRunRequest request = requestWithSkillBoundTool();
|
||||
AgentScopeReActRuntime runtime = fakeRuntime();
|
||||
java.lang.reflect.Method method = AgentScopeReActRuntime.class.getDeclaredMethod(
|
||||
"mapEvent", AgentRunRequest.class, Event.class,
|
||||
com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext.class);
|
||||
method.setAccessible(true);
|
||||
com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext skillContext =
|
||||
com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext.from(request.getAgentDefinition().getSkillBoxSpec());
|
||||
ToolUseBlock toolUseBlock = ToolUseBlock.builder()
|
||||
.id("skill-call-1")
|
||||
.name(com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext.LOAD_SKILL_TOOL_NAME)
|
||||
.input(Map.of("skillId", "skill-1", "path", "SKILL.md"))
|
||||
.build();
|
||||
Msg reasoning = Msg.builder()
|
||||
.id("reasoning-msg")
|
||||
.role(MsgRole.ASSISTANT)
|
||||
.content(toolUseBlock)
|
||||
.build();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<AgentRuntimeEvent> callEvents = (List<AgentRuntimeEvent>) method.invoke(
|
||||
runtime, request, new Event(EventType.REASONING, reasoning, false), skillContext);
|
||||
|
||||
ToolResultBlock resultBlock = ToolResultBlock.of(
|
||||
"skill-call-1",
|
||||
com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext.LOAD_SKILL_TOOL_NAME,
|
||||
TextBlock.builder().text("Successfully loaded skill: skill-1").build());
|
||||
Msg toolResult = Msg.builder()
|
||||
.id("tool-msg")
|
||||
.role(MsgRole.TOOL)
|
||||
.content(resultBlock)
|
||||
.build();
|
||||
@SuppressWarnings("unchecked")
|
||||
List<AgentRuntimeEvent> resultEvents = (List<AgentRuntimeEvent>) method.invoke(
|
||||
runtime, request, new Event(EventType.TOOL_RESULT, toolResult, true), skillContext);
|
||||
|
||||
Assert.assertTrue(callEvents.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.SKILL_CALL));
|
||||
AgentRuntimeEvent resultEvent = resultEvents.get(0);
|
||||
Assert.assertEquals(AgentRuntimeEventType.SKILL_RESULT, resultEvent.getEventType());
|
||||
Assert.assertEquals("skill-1", resultEvent.getPayload().get("skillId"));
|
||||
Assert.assertEquals("SKILL.md", resultEvent.getPayload().get("path"));
|
||||
Assert.assertTrue(skillContext.isSkillActive("skill-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldAttachStructuredMessageOnLastAgentResult() throws Exception {
|
||||
AgentRunRequest request = request();
|
||||
AgentScopeReActRuntime runtime = fakeRuntime();
|
||||
java.lang.reflect.Method method = AgentScopeReActRuntime.class.getDeclaredMethod(
|
||||
"mapEvent", AgentRunRequest.class, Event.class);
|
||||
method.setAccessible(true);
|
||||
Msg message = Msg.builder()
|
||||
.id("assistant-msg")
|
||||
.role(MsgRole.ASSISTANT)
|
||||
.content(TextBlock.builder().text("final answer").build())
|
||||
.build();
|
||||
Event event = new Event(EventType.AGENT_RESULT, message, true);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<AgentRuntimeEvent> events = (List<AgentRuntimeEvent>) method.invoke(runtime, request, event);
|
||||
|
||||
AgentRuntimeEvent delta = events.get(0);
|
||||
Assert.assertEquals(AgentRuntimeEventType.MESSAGE_DELTA, delta.getEventType());
|
||||
Assert.assertNotNull(delta.getMessage());
|
||||
Assert.assertEquals("assistant-msg", delta.getMessage().getMessageId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldNotEmitReasoningFromHook() {
|
||||
AgentRunRequest request = request();
|
||||
AgentScopeEventHook hook = new AgentScopeEventHook(request);
|
||||
ReActAgent agent = fakeRuntime().buildAgent(request, Sinks.many().unicast().onBackpressureBuffer());
|
||||
Msg chunk = Msg.builder()
|
||||
.role(MsgRole.ASSISTANT)
|
||||
.textContent("thinking")
|
||||
.build();
|
||||
|
||||
hook.onEvent(new ReasoningChunkEvent(agent, "run-1", null, chunk, chunk)).block();
|
||||
Assert.assertNotNull(chunk);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldAggregateKnowledgeAndPreserveMetadata() {
|
||||
AgentRunRequest request = request();
|
||||
AgentKnowledgeSpec second = knowledge("kb-2", "KB2");
|
||||
second.getMetadata().put("permissionScope", "team");
|
||||
request.getAgentDefinition().getKnowledgeSpecs().add(second);
|
||||
request.getKnowledgeRetrievers().put("kb-2", retrievalRequest -> {
|
||||
AgentKnowledgeDocument document = doc("doc-2", "chunk-2", "content-2", 0.95D);
|
||||
document.getMetadata().put("pageNo", 2);
|
||||
return AgentKnowledgeRetrievalResult.of(List.of(document));
|
||||
});
|
||||
Knowledge knowledge = new AgentScopeKnowledgeAdapter().createAggregateKnowledge(request);
|
||||
|
||||
List<Document> documents = knowledge.retrieve("query", RetrieveConfig.builder().limit(2).scoreThreshold(0D).build()).block();
|
||||
|
||||
Assert.assertEquals(2, documents.size());
|
||||
Map<String, Object> payload = documents.get(0).getPayload();
|
||||
Assert.assertTrue(payload.containsKey("knowledgeMetadata"));
|
||||
Assert.assertTrue(payload.containsKey("documentMetadata"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldEmitKnowledgeRetrievalEvent() throws Exception {
|
||||
AgentRunRequest request = request();
|
||||
Sinks.Many<AgentRuntimeEvent> sink = Sinks.many().unicast().onBackpressureBuffer();
|
||||
Knowledge knowledge = new AgentScopeKnowledgeAdapter().createAggregateKnowledge(request, sink);
|
||||
CompletableFuture<List<AgentRuntimeEvent>> eventsFuture = sink.asFlux().take(1).collectList().toFuture();
|
||||
|
||||
knowledge.retrieve("query", RetrieveConfig.builder().limit(1).scoreThreshold(0.2D).build()).block();
|
||||
AgentRuntimeEvent event = eventsFuture.get(3, TimeUnit.SECONDS).get(0);
|
||||
|
||||
Assert.assertEquals(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL, event.getEventType());
|
||||
Assert.assertEquals("kb-1", event.getPayload().get("knowledgeId"));
|
||||
Assert.assertEquals(1, event.getPayload().get("documentCount"));
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> documents = (List<Map<String, Object>>) event.getPayload().get("documents");
|
||||
Assert.assertFalse(documents.get(0).containsKey("content"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldFailWhenKnowledgeRetrieverMissing() {
|
||||
AgentRunRequest request = request();
|
||||
request.getKnowledgeRetrievers().clear();
|
||||
Knowledge knowledge = new AgentScopeKnowledgeAdapter().createAggregateKnowledge(request);
|
||||
|
||||
try {
|
||||
knowledge.retrieve("query", RetrieveConfig.builder().limit(2).scoreThreshold(0D).build()).block();
|
||||
Assert.fail("Expected missing knowledge retriever to fail.");
|
||||
} catch (Exception exception) {
|
||||
Assert.assertTrue(exception.getMessage().contains("Knowledge retriever is required"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldUseKnowledgeSpecLimitAsAggregateDefault() {
|
||||
AgentRunRequest request = request();
|
||||
AgentKnowledgeSpec second = knowledge("kb-2", "KB2");
|
||||
second.setLimit(3);
|
||||
request.getAgentDefinition().getKnowledgeSpecs().get(0).setLimit(2);
|
||||
request.getAgentDefinition().getKnowledgeSpecs().add(second);
|
||||
ReActAgent agent = fakeRuntime().buildAgent(request, Sinks.many().unicast().onBackpressureBuffer());
|
||||
|
||||
Assert.assertNotNull(agent);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldUseMinimumKnowledgeScoreThresholdAsAggregateDefault() throws Exception {
|
||||
AgentRunRequest request = request();
|
||||
request.getAgentDefinition().getKnowledgeSpecs().get(0).setScoreThreshold(0.4D);
|
||||
AgentKnowledgeSpec second = knowledge("kb-2", "KB2");
|
||||
second.setScoreThreshold(0.2D);
|
||||
request.getAgentDefinition().getKnowledgeSpecs().add(second);
|
||||
AgentScopeReActRuntime runtime = fakeRuntime();
|
||||
java.lang.reflect.Method method = AgentScopeReActRuntime.class.getDeclaredMethod(
|
||||
"defaultRetrieveConfig", AgentDefinition.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
RetrieveConfig config = (RetrieveConfig) method.invoke(runtime, request.getAgentDefinition());
|
||||
|
||||
Assert.assertEquals(0.2D, config.getScoreThreshold(), 0.0001D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRoundTripStructuredMessageBlocks() {
|
||||
AgentMessage message = new AgentMessage();
|
||||
message.setMessageId("msg-1");
|
||||
message.setRole(AgentMessageRole.ASSISTANT);
|
||||
message.getMetadata().put("scene", "chat");
|
||||
message.getContentBlocks().add(new AgentTextBlock("hello"));
|
||||
message.getContentBlocks().add(new AgentThinkingBlock("thinking"));
|
||||
AgentToolUseBlock toolUse = new AgentToolUseBlock("call-1", "echo", Map.of("text", "hi"));
|
||||
toolUse.setContent("raw-call");
|
||||
message.getContentBlocks().add(toolUse);
|
||||
AgentToolResultBlock toolResult = new AgentToolResultBlock("call-1", "echo");
|
||||
toolResult.setSuspended(true);
|
||||
toolResult.getMetadata().put("status", "ok");
|
||||
toolResult.getOutput().add(new AgentTextBlock("result"));
|
||||
message.getContentBlocks().add(toolResult);
|
||||
AgentMediaBlock media = new AgentMediaBlock("image");
|
||||
media.setUrl("https://example.com/a.png");
|
||||
media.setMimeType("image/png");
|
||||
message.getContentBlocks().add(media);
|
||||
|
||||
AgentScopeMessageAdapter adapter = new AgentScopeMessageAdapter();
|
||||
Msg msg = adapter.toMsg(message);
|
||||
AgentMessage converted = adapter.toAgentMessage(msg);
|
||||
|
||||
Assert.assertEquals("msg-1", converted.getMessageId());
|
||||
Assert.assertEquals(5, converted.getContentBlocks().size());
|
||||
Assert.assertEquals("hello", ((AgentTextBlock) converted.getContentBlocks().get(0)).getText());
|
||||
Assert.assertEquals("thinking", ((AgentThinkingBlock) converted.getContentBlocks().get(1)).getThinking());
|
||||
Assert.assertEquals("call-1", ((AgentToolUseBlock) converted.getContentBlocks().get(2)).getId());
|
||||
Assert.assertEquals("echo", ((AgentToolResultBlock) converted.getContentBlocks().get(3)).getName());
|
||||
Assert.assertEquals("image", ((AgentMediaBlock) converted.getContentBlocks().get(4)).getMediaKind());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldPreserveInvalidTimestampAsRawMetadata() {
|
||||
Msg msg = Msg.builder()
|
||||
.id("msg-invalid-time")
|
||||
.role(MsgRole.USER)
|
||||
.textContent("hello")
|
||||
.timestamp("invalid-time")
|
||||
.build();
|
||||
|
||||
AgentMessage converted = new AgentScopeMessageAdapter().toAgentMessage(msg);
|
||||
|
||||
Assert.assertEquals("invalid-time", converted.getMetadata().get("rawTimestamp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldConvertUnknownBlockToExplicitFallbackText() {
|
||||
AgentUnknownBlock unknownBlock = new AgentUnknownBlock();
|
||||
unknownBlock.setSourceClassName("com.example.CustomBlock");
|
||||
unknownBlock.setSourceTypeName("custom");
|
||||
|
||||
io.agentscope.core.message.ContentBlock converted = new AgentScopeMessageAdapter().toContentBlock(unknownBlock);
|
||||
|
||||
Assert.assertTrue(converted instanceof TextBlock);
|
||||
Assert.assertTrue(((TextBlock) converted).getText().contains("unsupported content block"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCreateAutoContextConfigFromPolicy() {
|
||||
AgentMemoryCompressionParameter parameter = new AgentMemoryCompressionParameter();
|
||||
parameter.setMsgThreshold(9);
|
||||
parameter.setLastKeep(4);
|
||||
parameter.setMaxToken(1234L);
|
||||
|
||||
AutoContextConfig config = new AgentScopeMemoryAdapter().toAutoContextConfig(parameter);
|
||||
|
||||
Assert.assertEquals(9, config.getMsgThreshold());
|
||||
Assert.assertEquals(4, config.getLastKeep());
|
||||
Assert.assertEquals(1234L, config.getMaxToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldMapOllamaGenerationOptions() throws Exception {
|
||||
AgentModelSpec modelSpec = new AgentModelSpec();
|
||||
modelSpec.setProviderType(AgentModelProviderType.OLLAMA);
|
||||
modelSpec.setModelName("llama");
|
||||
com.easyagents.agent.runtime.model.AgentGenerationOptions options = new com.easyagents.agent.runtime.model.AgentGenerationOptions();
|
||||
options.setTemperature(0.2D);
|
||||
options.setTopP(0.8D);
|
||||
options.setTopK(20);
|
||||
options.setMaxTokens(128);
|
||||
|
||||
Object model = new AgentScopeModelFactory().create(modelSpec, options);
|
||||
Field field = model.getClass().getDeclaredField("defaultOptions");
|
||||
field.setAccessible(true);
|
||||
io.agentscope.core.model.ollama.OllamaOptions ollamaOptions = (io.agentscope.core.model.ollama.OllamaOptions) field.get(model);
|
||||
|
||||
Assert.assertEquals(0.2D, ollamaOptions.getTemperature(), 0.0001D);
|
||||
Assert.assertEquals(0.8D, ollamaOptions.getTopP(), 0.0001D);
|
||||
Assert.assertEquals(Integer.valueOf(20), ollamaOptions.getTopK());
|
||||
Assert.assertEquals(Integer.valueOf(128), ollamaOptions.getMaxTokens());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCreateNativeProviderModels() {
|
||||
Assert.assertTrue(createModel(AgentModelProviderType.OPENAI) instanceof OpenAIChatModel);
|
||||
Assert.assertTrue(createModel(AgentModelProviderType.ANTHROPIC) instanceof AnthropicChatModel);
|
||||
Assert.assertTrue(createModel(AgentModelProviderType.GEMINI) instanceof GeminiChatModel);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCreateDomesticProvidersAsOpenAiCompatibleModels() {
|
||||
Assert.assertTrue(createModel(AgentModelProviderType.GLM) instanceof OpenAIChatModel);
|
||||
Assert.assertTrue(createModel(AgentModelProviderType.MINIMAX) instanceof OpenAIChatModel);
|
||||
Assert.assertTrue(createModel(AgentModelProviderType.MOONSHOT) instanceof OpenAIChatModel);
|
||||
Assert.assertTrue(createModel(AgentModelProviderType.ARK) instanceof OpenAIChatModel);
|
||||
Assert.assertTrue(createModel(AgentModelProviderType.SILICONFLOW) instanceof OpenAIChatModel);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldStoreSessionThroughAdapter() {
|
||||
InMemoryAgentSessionStore store = new InMemoryAgentSessionStore();
|
||||
Session session = new AgentScopeSessionAdapter(store);
|
||||
|
||||
session.save(AgentScopeSessionAdapter.sessionKey("s1"), "state", new TestState("v1"));
|
||||
|
||||
Assert.assertTrue(store.exists("s1"));
|
||||
Assert.assertEquals("v1", session.get(AgentScopeSessionAdapter.sessionKey("s1"), "state", TestState.class).get().value);
|
||||
Assert.assertTrue(AgentScopeSessionAdapter.toStatePersistence(AgentPersistencePolicy.memoryOnly()).memoryManaged());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldPersistSessionStateAsJsonFiles() throws Exception {
|
||||
Path directory = Files.createTempDirectory("easy-agents-session-");
|
||||
JsonAgentSessionStore store = new JsonAgentSessionStore(directory);
|
||||
Session session = new AgentScopeSessionAdapter(store);
|
||||
Msg first = Msg.builder()
|
||||
.role(MsgRole.USER)
|
||||
.content(TextBlock.builder().text("hello").build())
|
||||
.build();
|
||||
Msg second = Msg.builder()
|
||||
.role(MsgRole.ASSISTANT)
|
||||
.content(TextBlock.builder().text("world").build())
|
||||
.build();
|
||||
|
||||
session.save(AgentScopeSessionAdapter.sessionKey("s1"), "message", first);
|
||||
session.save(AgentScopeSessionAdapter.sessionKey("s1"), "messages", List.of(first, second));
|
||||
|
||||
JsonAgentSessionStore reloadedStore = new JsonAgentSessionStore(directory);
|
||||
Session reloadedSession = new AgentScopeSessionAdapter(reloadedStore);
|
||||
Msg reloaded = reloadedSession.get(AgentScopeSessionAdapter.sessionKey("s1"), "message", Msg.class).orElseThrow();
|
||||
List<Msg> messages = reloadedSession.getList(AgentScopeSessionAdapter.sessionKey("s1"), "messages", Msg.class);
|
||||
|
||||
Assert.assertTrue(reloadedStore.exists("s1"));
|
||||
Assert.assertEquals("hello", reloaded.getTextContent());
|
||||
Assert.assertEquals(2, messages.size());
|
||||
Assert.assertEquals("world", messages.get(1).getTextContent());
|
||||
}
|
||||
|
||||
@Test(expected = RuntimeException.class)
|
||||
public void shouldRejectUnsafeSessionKeysForFileStore() throws Exception {
|
||||
JsonAgentSessionStore store = new JsonAgentSessionStore(Files.createTempDirectory("easy-agents-session-"));
|
||||
|
||||
store.save("../unsafe", "state", AgentRuntimeState.of("state", new TestState("v1")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCreateSkillBox() {
|
||||
AgentSkillSpec skill = new AgentSkillSpec();
|
||||
skill.setSkillId("skill-1");
|
||||
skill.setName("skill");
|
||||
skill.setDescription("desc");
|
||||
skill.setSkillContent("content");
|
||||
AgentSkillBoxSpec spec = new AgentSkillBoxSpec();
|
||||
spec.setSkillBoxId("box");
|
||||
spec.setSkills(List.of(skill));
|
||||
|
||||
SkillBox skillBox = new AgentScopeSkillAdapter().createSkillBox(spec, new Toolkit());
|
||||
|
||||
Assert.assertNotNull(skillBox);
|
||||
Assert.assertFalse(skillBox.getAllSkillIds().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldExposeSkillBoundToolThroughAgentToolkit() {
|
||||
AgentRunRequest request = requestWithSkillBoundTool();
|
||||
ReActAgent agent = fakeRuntime().buildAgent(request, Sinks.many().unicast().onBackpressureBuffer());
|
||||
|
||||
Assert.assertNotNull(agent.getToolkit().getTool("echo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldAttachSkillMetadataToApprovalEvent() throws Exception {
|
||||
AgentRunRequest request = requestWithSkillBoundTool();
|
||||
request.getAgentDefinition().getToolSpecs().get(0).setApprovalRequired(true);
|
||||
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
|
||||
Sinks.Many<AgentRuntimeEvent> sink = Sinks.many().unicast().onBackpressureBuffer();
|
||||
com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext skillContext = activatedSkillContext(request);
|
||||
AgentTool tool = new AgentScopeToolAdapter().adapt(request.getAgentDefinition().getToolSpecs().get(0),
|
||||
request.getToolInvokers().get("echo"), request, coordinator, sink,
|
||||
skillContext, skillContext.getToolBinding("echo"));
|
||||
java.util.List<AgentRuntimeEvent> events = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||
java.util.concurrent.CountDownLatch approvalLatch = new java.util.concurrent.CountDownLatch(1);
|
||||
sink.asFlux().subscribe(event -> {
|
||||
events.add(event);
|
||||
if (event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) {
|
||||
approvalLatch.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
tool.callAsync(ToolCallParam.builder()
|
||||
.toolUseBlock(ToolUseBlock.builder()
|
||||
.id("call-skill-approval")
|
||||
.name("echo")
|
||||
.input(Map.of("text", "hello"))
|
||||
.build())
|
||||
.input(Map.of("text", "hello"))
|
||||
.build())
|
||||
.subscribe(result -> {
|
||||
}, error -> {
|
||||
});
|
||||
Assert.assertTrue(approvalLatch.await(5, TimeUnit.SECONDS));
|
||||
|
||||
AgentRuntimeEvent approval = events.stream()
|
||||
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)
|
||||
.findFirst()
|
||||
.orElseThrow(AssertionError::new);
|
||||
Assert.assertEquals("skill-1", approval.getPayload().get("skillId"));
|
||||
Assert.assertEquals("skill", approval.getPayload().get("skillName"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldEmitCompletedAndFailedEvents() {
|
||||
AgentRunRequest request = request();
|
||||
|
||||
Assert.assertTrue(fakeRuntime().stream(request)
|
||||
.collectList()
|
||||
.block()
|
||||
.stream()
|
||||
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED));
|
||||
|
||||
AgentRunRequest failed = request();
|
||||
failed.getAgentDefinition().setModelSpec(null);
|
||||
Assert.assertTrue(fakeRuntime().stream(failed)
|
||||
.collectList()
|
||||
.block()
|
||||
.stream()
|
||||
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.FAILED));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldEmitCancelledEventFromRunHandle() throws Exception {
|
||||
AgentRunRequest request = request();
|
||||
request.setCancelReason("user stopped");
|
||||
AgentScopeReActRuntime runtime = fakeRuntime();
|
||||
java.lang.reflect.Method method = AgentScopeReActRuntime.class.getDeclaredMethod(
|
||||
"cancelled", AgentRunRequest.class);
|
||||
method.setAccessible(true);
|
||||
AgentRuntimeEvent cancelledEvent = (AgentRuntimeEvent) method.invoke(runtime, request);
|
||||
List<AgentRuntimeEvent> events = List.of(cancelledEvent);
|
||||
Assert.assertTrue(events.stream().anyMatch(runtimeEvent -> runtimeEvent.getEventType() == AgentRuntimeEventType.CANCELLED));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldNotEmitFailedFromHook() {
|
||||
AgentRunRequest request = request();
|
||||
AgentScopeEventHook hook = new AgentScopeEventHook(request);
|
||||
ReActAgent agent = fakeRuntime().buildAgent(request, Sinks.many().unicast().onBackpressureBuffer());
|
||||
ErrorEvent errorEvent = new ErrorEvent(agent, new RuntimeException("boom"));
|
||||
|
||||
HookEvent returned = hook.onEvent(errorEvent).block();
|
||||
|
||||
Assert.assertSame(errorEvent, returned);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldAccumulateCompletedTextFromMessageDeltas() throws Exception {
|
||||
AgentRunRequest request = request();
|
||||
AgentScopeReActRuntime runtime = fakeRuntime();
|
||||
java.lang.reflect.Method method = AgentScopeReActRuntime.class.getDeclaredMethod(
|
||||
"updateFinalText", StringBuilder.class, AgentRuntimeEvent.class);
|
||||
method.setAccessible(true);
|
||||
StringBuilder builder = new StringBuilder();
|
||||
AgentRuntimeEvent first = AgentRuntimeEvent.of(AgentRuntimeEventType.MESSAGE_DELTA);
|
||||
first.getPayload().put("text", "hello ");
|
||||
AgentRuntimeEvent second = AgentRuntimeEvent.of(AgentRuntimeEventType.MESSAGE_DELTA);
|
||||
second.getPayload().put("text", "world");
|
||||
|
||||
method.invoke(runtime, builder, first);
|
||||
method.invoke(runtime, builder, second);
|
||||
|
||||
Assert.assertEquals("hello world", builder.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldAttachStructuredMessageOnCompletedEvent() throws Exception {
|
||||
AgentRunRequest request = request();
|
||||
AgentMessage message = AgentMessage.text(AgentMessageRole.ASSISTANT, "final answer");
|
||||
AgentScopeReActRuntime runtime = fakeRuntime();
|
||||
java.lang.reflect.Method method = AgentScopeReActRuntime.class.getDeclaredMethod(
|
||||
"completed", AgentRunRequest.class, String.class, AgentMessage.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
AgentRuntimeEvent event = (AgentRuntimeEvent) method.invoke(runtime, request, "final answer", message);
|
||||
|
||||
Assert.assertEquals(AgentRuntimeEventType.COMPLETED, event.getEventType());
|
||||
Assert.assertEquals("final answer", event.getPayload().get("text"));
|
||||
Assert.assertSame(message, event.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldAttachKnowledgeReferencesToCompletedMessage() throws Exception {
|
||||
AgentRunRequest request = request();
|
||||
AgentMessage message = AgentMessage.text(AgentMessageRole.ASSISTANT, "final answer");
|
||||
AgentScopeReActRuntime runtime = fakeRuntime();
|
||||
java.lang.reflect.Method method = AgentScopeReActRuntime.class.getDeclaredMethod(
|
||||
"completed", AgentRunRequest.class, String.class, AgentMessage.class, Map.class);
|
||||
method.setAccessible(true);
|
||||
Map<String, AgentKnowledgeReference> refs = new java.util.LinkedHashMap<>();
|
||||
AgentKnowledgeReference ref = new AgentKnowledgeReference();
|
||||
ref.setKnowledgeId("kb-1");
|
||||
ref.setKnowledgeName("KB1");
|
||||
ref.setDocumentId("doc-1");
|
||||
ref.setDocumentName("doc-1.md");
|
||||
ref.setChunkId("chunk-1");
|
||||
refs.put("kb-1|doc-1|chunk-1", ref);
|
||||
|
||||
AgentRuntimeEvent event = (AgentRuntimeEvent) method.invoke(runtime, request, "final answer", message, refs);
|
||||
|
||||
Assert.assertEquals(AgentRuntimeEventType.COMPLETED, event.getEventType());
|
||||
List<AgentKnowledgeReference> knowledgeRefs = event.getMessage().getKnowledgeReferences();
|
||||
Assert.assertEquals(1, knowledgeRefs.size());
|
||||
Assert.assertEquals("doc-1.md", knowledgeRefs.get(0).getDocumentName());
|
||||
Assert.assertEquals("kb-1", knowledgeRefs.get(0).getKnowledgeId());
|
||||
}
|
||||
|
||||
private AgentScopeReActRuntime fakeRuntime() {
|
||||
return new AgentScopeReActRuntime(new FakeAgentScopeModelFactory(), new AgentScopeToolAdapter(),
|
||||
new AgentScopeKnowledgeAdapter(), new AgentScopeMemoryAdapter(), new AgentScopeSkillAdapter(),
|
||||
new AgentScopeMessageAdapter());
|
||||
}
|
||||
|
||||
private List<AgentRuntimeEvent> invokeToolAndCollect(AgentRunRequest request, int count) throws Exception {
|
||||
Sinks.Many<AgentRuntimeEvent> sink = Sinks.many().replay().all();
|
||||
ReActAgent agent = fakeRuntime().buildAgent(request, sink);
|
||||
CompletableFuture<List<AgentRuntimeEvent>> eventsFuture = sink.asFlux().take(count).collectList().toFuture();
|
||||
ToolUseBlock block = ToolUseBlock.builder()
|
||||
.id("call-hitl")
|
||||
.name("echo")
|
||||
.input(Map.of("text", "hello"))
|
||||
.build();
|
||||
agent.getToolkit().getTool("echo").callAsync(ToolCallParam.builder()
|
||||
.toolUseBlock(block)
|
||||
.input(Map.of("text", "hello"))
|
||||
.build())
|
||||
.block();
|
||||
return eventsFuture.get(3, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private AgentRunRequest request() {
|
||||
AgentModelSpec modelSpec = new AgentModelSpec();
|
||||
modelSpec.setModelName("fake-model");
|
||||
AgentToolSpec tool = new AgentToolSpec();
|
||||
tool.setName("echo");
|
||||
tool.setDescription("Echo text");
|
||||
tool.setParametersSchema(Map.of("type", "object", "properties", Map.of("text", Map.of("type", "string"))));
|
||||
|
||||
AgentDefinition definition = new AgentDefinition();
|
||||
definition.setAgentId("agent-1");
|
||||
definition.setAgentName("agent-name");
|
||||
definition.setSystemPrompt("system");
|
||||
definition.setModelSpec(modelSpec);
|
||||
definition.getExecutionOptions().setMaxIters(3);
|
||||
definition.setToolSpecs(List.of(tool));
|
||||
definition.setKnowledgeSpecs(List.of(knowledge("kb-1", "KB1")));
|
||||
definition.setMemoryPolicy(AgentMemoryPolicy.inMemory());
|
||||
|
||||
AgentMemorySnapshot snapshot = new AgentMemorySnapshot();
|
||||
snapshot.addMessage(AgentMessage.text(AgentMessageRole.USER, "history"));
|
||||
|
||||
AgentRunRequest request = new AgentRunRequest();
|
||||
request.setRequestId("req-1");
|
||||
request.setTraceId("trace-1");
|
||||
request.setSessionId("session-1");
|
||||
request.setAgentDefinition(definition);
|
||||
request.setMemorySnapshot(snapshot);
|
||||
request.setUserMessage(AgentMessage.text(AgentMessageRole.USER, "hello"));
|
||||
request.getToolInvokers().put("echo", (arguments, context) -> AgentToolResult.success(String.valueOf(arguments.get("text"))));
|
||||
request.getKnowledgeRetrievers().put("kb-1", retrievalRequest -> AgentKnowledgeRetrievalResult.of(
|
||||
List.of(doc("doc-1", "chunk-1", "content-1", 0.8D))));
|
||||
return request;
|
||||
}
|
||||
|
||||
private AgentRunRequest requestWithSkillBoundTool() {
|
||||
AgentRunRequest request = request();
|
||||
AgentSkillSpec skill = new AgentSkillSpec();
|
||||
skill.setSkillId("skill-1");
|
||||
skill.setName("skill");
|
||||
skill.setDescription("desc");
|
||||
skill.setSkillContent("content");
|
||||
AgentSkillBoxSpec skillBoxSpec = new AgentSkillBoxSpec();
|
||||
skillBoxSpec.setSkillBoxId("box");
|
||||
skillBoxSpec.setSkills(List.of(skill));
|
||||
skillBoxSpec.setToolBindings(Map.of("skill-1", List.of("echo")));
|
||||
request.getAgentDefinition().setSkillBoxSpec(skillBoxSpec);
|
||||
return request;
|
||||
}
|
||||
|
||||
private com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext activatedSkillContext(AgentRunRequest request) {
|
||||
com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext skillContext =
|
||||
com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext.from(request.getAgentDefinition().getSkillBoxSpec());
|
||||
skillContext.activateSkill("skill-1");
|
||||
return skillContext;
|
||||
}
|
||||
|
||||
private Object createModel(AgentModelProviderType providerType) {
|
||||
AgentModelSpec modelSpec = new AgentModelSpec();
|
||||
modelSpec.setProviderType(providerType);
|
||||
modelSpec.setModelName("test-model");
|
||||
modelSpec.setApiKey("test-key");
|
||||
return new AgentScopeModelFactory().create(modelSpec, new com.easyagents.agent.runtime.model.AgentGenerationOptions());
|
||||
}
|
||||
|
||||
private AgentKnowledgeSpec knowledge(String id, String name) {
|
||||
AgentKnowledgeSpec spec = new AgentKnowledgeSpec();
|
||||
spec.setKnowledgeId(id);
|
||||
spec.setName(name);
|
||||
spec.getMetadata().put("publishVersion", "v1");
|
||||
return spec;
|
||||
}
|
||||
|
||||
private AgentKnowledgeDocument doc(String documentId, String chunkId, String content, double score) {
|
||||
AgentKnowledgeDocument document = new AgentKnowledgeDocument();
|
||||
document.setDocumentId(documentId);
|
||||
document.setDocumentName(documentId + ".md");
|
||||
document.setChunkId(chunkId);
|
||||
document.setContent(content);
|
||||
document.setScore(score);
|
||||
document.getMetadata().put("sectionTitle", "section");
|
||||
return document;
|
||||
}
|
||||
|
||||
private static class TestState implements io.agentscope.core.state.State {
|
||||
private final String value;
|
||||
|
||||
private TestState(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
package com.easyagents.agent.runtime.knowledge.citation;
|
||||
|
||||
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 测试启发式知识库引用匹配器。
|
||||
*/
|
||||
public class HeuristicKnowledgeCitationMatcherTest {
|
||||
|
||||
private final HeuristicKnowledgeCitationMatcher matcher = new HeuristicKnowledgeCitationMatcher();
|
||||
|
||||
/**
|
||||
* 当答案与候选片段存在明确文本证据时,应返回对应引用。
|
||||
*/
|
||||
@Test
|
||||
public void shouldMatchReferenceByAnswerEvidence() {
|
||||
AgentKnowledgeReference unrelated = reference("doc-1", "周期结算金额等于订单金额减去佣金金额。");
|
||||
AgentKnowledgeReference cited = reference("faq-1",
|
||||
"问题:暑假安排是什么?答案:2026 年暑假安排为 7 月 1 日到 8 月 15 日。");
|
||||
|
||||
List<AgentKnowledgeReference> references = matcher.match(
|
||||
"根据查询到的信息,2026 年暑假安排为:7 月 1 日至 8 月 15 日。",
|
||||
List.of(unrelated, cited));
|
||||
|
||||
Assert.assertEquals(1, references.size());
|
||||
Assert.assertEquals("faq-1", references.get(0).getDocumentId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 即使只有一个候选,如果答案缺少文本支撑,也不应强行返回引用。
|
||||
*/
|
||||
@Test
|
||||
public void shouldNotGuessOnlyCandidateWithoutEvidence() {
|
||||
AgentKnowledgeReference candidate = reference("doc-1", "唯一命中片段");
|
||||
|
||||
List<AgentKnowledgeReference> references = matcher.match("final answer", List.of(candidate));
|
||||
|
||||
Assert.assertTrue(references.isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* 多候选无明确文本支撑时,应返回空引用列表。
|
||||
*/
|
||||
@Test
|
||||
public void shouldNotGuessMultipleCandidatesWithoutEvidence() {
|
||||
AgentKnowledgeReference first = reference("doc-1", "周期结算金额等于订单金额减去佣金金额。");
|
||||
AgentKnowledgeReference second = reference("doc-2", "权限配置需要绑定角色与数据范围。");
|
||||
|
||||
List<AgentKnowledgeReference> references = matcher.match("final answer", List.of(first, second));
|
||||
|
||||
Assert.assertTrue(references.isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* 日期数字相同但语义内容不相关时,不应仅凭数字覆盖返回引用。
|
||||
*/
|
||||
@Test
|
||||
public void shouldNotMatchByDateNumbersOnly() {
|
||||
AgentKnowledgeReference unrelated = reference("doc-1",
|
||||
"内部文件 胜意科技AI Infra解决方案 技术文件 武汉科技大学计算机科学与技术学院 "
|
||||
+ "2026年1月 目录 1 总体目标 4 2 总体方案 5 3 详细方案 6 3.2 流程与编排 7 "
|
||||
+ "3.3 知识库管理 8 3.4 智能文档解析 9");
|
||||
|
||||
List<AgentKnowledgeReference> references = matcher.match(
|
||||
"根据系统中的信息,2026 年暑假的安排是:2026 年 7 月 1 日到 8 月 15 日。",
|
||||
List.of(unrelated));
|
||||
|
||||
Assert.assertTrue(references.isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* 空答案或空候选不应返回引用。
|
||||
*/
|
||||
@Test
|
||||
public void shouldReturnEmptyWhenAnswerOrCandidatesEmpty() {
|
||||
AgentKnowledgeReference candidate = reference("doc-1", "问题:暑假安排是什么?答案:7月1日到8月15日。");
|
||||
|
||||
Assert.assertTrue(matcher.match("", List.of(candidate)).isEmpty());
|
||||
Assert.assertTrue(matcher.match("暑假安排是什么?", List.of()).isEmpty());
|
||||
}
|
||||
|
||||
private AgentKnowledgeReference reference(String documentId, String chunkContent) {
|
||||
AgentKnowledgeReference reference = new AgentKnowledgeReference();
|
||||
reference.setKnowledgeId("kb-1");
|
||||
reference.setKnowledgeName("知识库");
|
||||
reference.setDocumentId(documentId);
|
||||
reference.setDocumentName(documentId + ".md");
|
||||
reference.setChunkId(documentId + "-chunk");
|
||||
reference.setChunkContent(chunkContent);
|
||||
return reference;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.easyagents.agent.runtime.mcp;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRuntimeException;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolSpec;
|
||||
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolAdapter;
|
||||
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolSpec;
|
||||
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolType;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 测试 MCP 声明校验器。
|
||||
*/
|
||||
public class McpSpecValidatorTest {
|
||||
|
||||
@Test(expected = AgentRuntimeException.class)
|
||||
public void shouldRejectMissingStdioCommand() {
|
||||
McpSpec spec = spec(McpTransportType.STDIO);
|
||||
|
||||
McpSpecValidator.validateConnection(spec);
|
||||
}
|
||||
|
||||
@Test(expected = AgentRuntimeException.class)
|
||||
public void shouldRejectMissingSseUrl() {
|
||||
McpSpec spec = spec(McpTransportType.SSE);
|
||||
|
||||
McpSpecValidator.validateConnection(spec);
|
||||
}
|
||||
|
||||
@Test(expected = AgentRuntimeException.class)
|
||||
public void shouldRejectMissingHttpUrl() {
|
||||
McpSpec spec = spec(McpTransportType.HTTP);
|
||||
|
||||
McpSpecValidator.validateConnection(spec);
|
||||
}
|
||||
|
||||
@Test(expected = AgentRuntimeException.class)
|
||||
public void shouldRejectMcpToolNameConflictWithBusinessTool() {
|
||||
AgentToolSpec businessTool = toolSpec("search");
|
||||
AgentToolSpec mcpTool = toolSpec("search");
|
||||
|
||||
McpSpecValidator.validateToolConflicts(List.of(businessTool), List.of(mcpTool), List.of());
|
||||
}
|
||||
|
||||
@Test(expected = AgentRuntimeException.class)
|
||||
public void shouldRejectMcpToolNameConflictWithOperateTool() {
|
||||
AgentToolSpec mcpTool = toolSpec(AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL);
|
||||
AgentOperateToolSpec operateTool = new AgentOperateToolSpec();
|
||||
operateTool.setType(AgentOperateToolType.SHELL);
|
||||
|
||||
McpSpecValidator.validateToolConflicts(List.of(), List.of(mcpTool), List.of(operateTool));
|
||||
}
|
||||
|
||||
@Test(expected = AgentRuntimeException.class)
|
||||
public void shouldRejectDuplicatedRuntimeToolAliases() {
|
||||
McpSpec spec = spec(McpTransportType.STDIO);
|
||||
spec.setCommand("node");
|
||||
spec.setToolAliases(Map.of("search", "mcp_1_tool", "search.v2", "mcp_1_tool"));
|
||||
|
||||
McpSpecValidator.validateConnection(spec);
|
||||
}
|
||||
|
||||
private McpSpec spec(McpTransportType type) {
|
||||
McpSpec spec = new McpSpec();
|
||||
spec.setName("mcp");
|
||||
spec.setTransportType(type);
|
||||
return spec;
|
||||
}
|
||||
|
||||
private AgentToolSpec toolSpec(String name) {
|
||||
AgentToolSpec spec = new AgentToolSpec();
|
||||
spec.setName(name);
|
||||
return spec;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package com.easyagents.agent.runtime.mcp;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRuntimeException;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolSpec;
|
||||
import io.agentscope.core.message.ToolResultBlock;
|
||||
import io.agentscope.core.tool.Toolkit;
|
||||
import io.agentscope.core.tool.mcp.McpClientWrapper;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* 测试 MCP Toolkit 适配器。
|
||||
*/
|
||||
public class McpToolkitAdapterTest {
|
||||
|
||||
@Test
|
||||
public void shouldRegisterEnabledMcpToolsAndBuildRuntimeToolSpecs() {
|
||||
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo",
|
||||
List.of(tool("search"), tool("write_file")));
|
||||
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
|
||||
McpSpec spec = stdioSpec();
|
||||
spec.setEnableTools(List.of("search"));
|
||||
spec.setApprovalRequired(true);
|
||||
spec.setMetadata(Map.of("owner", "runtime"));
|
||||
Toolkit toolkit = new Toolkit();
|
||||
|
||||
McpRegistration registration = adapter.register(List.of(spec), toolkit);
|
||||
|
||||
Assert.assertEquals(1, registration.getClients().size());
|
||||
Assert.assertEquals(1, registration.getToolSpecs().size());
|
||||
Assert.assertNotNull(toolkit.getTool("search"));
|
||||
Assert.assertNull(toolkit.getTool("write_file"));
|
||||
AgentToolSpec toolSpec = registration.getToolSpecs().get(0);
|
||||
Assert.assertEquals("search", toolSpec.getName());
|
||||
Assert.assertTrue(toolSpec.isApprovalRequired());
|
||||
Assert.assertEquals("MCP", toolSpec.getMetadata().get("source"));
|
||||
Assert.assertEquals("demo", toolSpec.getMetadata().get("mcpName"));
|
||||
Assert.assertEquals("stdio", toolSpec.getMetadata().get("transportType"));
|
||||
Assert.assertEquals("runtime", toolSpec.getMetadata().get("owner"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldFilterSensitiveMetadataKeysFromRuntimeToolSpec() {
|
||||
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", List.of(tool("search")));
|
||||
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
|
||||
McpSpec spec = stdioSpec();
|
||||
spec.setMetadata(Map.of(
|
||||
"owner", "runtime",
|
||||
"apiKey", "secret-key",
|
||||
"Authorization", "Bearer secret-token",
|
||||
"password", "secret-password"));
|
||||
|
||||
McpRegistration registration = adapter.register(List.of(spec), new Toolkit());
|
||||
|
||||
Map<String, Object> metadata = registration.getToolSpecs().get(0).getMetadata();
|
||||
Assert.assertEquals("runtime", metadata.get("owner"));
|
||||
Assert.assertFalse(metadata.containsKey("apiKey"));
|
||||
Assert.assertFalse(metadata.containsKey("Authorization"));
|
||||
Assert.assertFalse(metadata.containsKey("password"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRegisterAliasedMcpToolAndCallRawToolName() {
|
||||
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", List.of(tool("search")));
|
||||
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
|
||||
McpSpec spec = stdioSpec();
|
||||
spec.setEnableTools(List.of("mcp_1_search"));
|
||||
spec.setToolAliases(Map.of("search", "mcp_1_search"));
|
||||
Toolkit toolkit = new Toolkit();
|
||||
|
||||
McpRegistration registration = adapter.register(List.of(spec), toolkit);
|
||||
|
||||
Assert.assertNotNull(toolkit.getTool("mcp_1_search"));
|
||||
Assert.assertNull(toolkit.getTool("search"));
|
||||
AgentToolSpec toolSpec = registration.getToolSpecs().get(0);
|
||||
Assert.assertEquals("mcp_1_search", toolSpec.getName());
|
||||
Assert.assertEquals("search", toolSpec.getMetadata().get("rawMcpToolName"));
|
||||
registration.getClients().get(0).callTool("mcp_1_search", Map.of("q", "hello")).block();
|
||||
Assert.assertEquals("search", client.lastCalledToolName.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证未配置工具白名单时会为 MCP 下全部工具动态生成运行时别名。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRegisterAllToolsWithDynamicPrefixAndCallRawToolName() {
|
||||
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo",
|
||||
List.of(tool("search.tool"), tool("write-file")));
|
||||
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
|
||||
McpSpec spec = stdioSpec();
|
||||
spec.setDescription("Demo MCP");
|
||||
spec.setToolNamePrefix("mcp_20_");
|
||||
Toolkit toolkit = new Toolkit();
|
||||
|
||||
McpRegistration registration = adapter.register(List.of(spec), toolkit);
|
||||
|
||||
Assert.assertNotNull(toolkit.getTool("mcp_20_search_tool"));
|
||||
Assert.assertNotNull(toolkit.getTool("mcp_20_write-file"));
|
||||
Assert.assertNull(toolkit.getTool("search.tool"));
|
||||
Assert.assertEquals(2, registration.getToolSpecs().size());
|
||||
AgentToolSpec toolSpec = registration.getToolSpecs().get(0);
|
||||
Assert.assertEquals("mcp_20_search_tool", toolSpec.getName());
|
||||
Assert.assertEquals("search.tool", toolSpec.getMetadata().get("rawMcpToolName"));
|
||||
Assert.assertEquals("Demo MCP - search.tool", toolSpec.getMetadata().get("toolDisplayName"));
|
||||
registration.getClients().get(0).callTool("mcp_20_search_tool", Map.of("q", "hello")).block();
|
||||
Assert.assertEquals("search.tool", client.lastCalledToolName.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证动态别名安全化后发生重名时会自动追加序号。
|
||||
*/
|
||||
@Test
|
||||
public void shouldDeduplicateDynamicAliasesAfterSanitizingToolNames() {
|
||||
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo",
|
||||
List.of(tool("search.tool"), tool("search_tool")));
|
||||
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
|
||||
McpSpec spec = stdioSpec();
|
||||
spec.setToolNamePrefix("mcp_20_");
|
||||
Toolkit toolkit = new Toolkit();
|
||||
|
||||
McpRegistration registration = adapter.register(List.of(spec), toolkit);
|
||||
|
||||
Assert.assertNotNull(toolkit.getTool("mcp_20_search_tool"));
|
||||
Assert.assertNotNull(toolkit.getTool("mcp_20_search_tool_2"));
|
||||
Assert.assertEquals("search.tool", registration.getToolSpecs().get(0).getMetadata().get("rawMcpToolName"));
|
||||
Assert.assertEquals("search_tool", registration.getToolSpecs().get(1).getMetadata().get("rawMcpToolName"));
|
||||
registration.getClients().get(0).callTool("mcp_20_search_tool_2", Map.of("q", "hello")).block();
|
||||
Assert.assertEquals("search_tool", client.lastCalledToolName.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldReturnEmptyToolSpecsWhenMcpServerHasNoTools() {
|
||||
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", List.of());
|
||||
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
|
||||
|
||||
McpRegistration registration = adapter.register(List.of(stdioSpec()), new Toolkit());
|
||||
|
||||
Assert.assertEquals(1, registration.getClients().size());
|
||||
Assert.assertTrue(registration.getToolSpecs().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCloseCreatedClientWhenRegistrationFails() {
|
||||
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", List.of(tool("search")));
|
||||
client.failOnListTools = true;
|
||||
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
|
||||
|
||||
try {
|
||||
adapter.register(List.of(stdioSpec()), new Toolkit());
|
||||
Assert.fail("Expected MCP registration failure.");
|
||||
} catch (AgentRuntimeException | IllegalStateException ignored) {
|
||||
Assert.assertTrue(client.closed.get());
|
||||
}
|
||||
}
|
||||
|
||||
private McpSpec stdioSpec() {
|
||||
McpSpec spec = new McpSpec();
|
||||
spec.setName("demo");
|
||||
spec.setTransportType(McpTransportType.STDIO);
|
||||
spec.setCommand("node");
|
||||
spec.setTimeout(Duration.ofSeconds(10));
|
||||
spec.setInitializationTimeout(Duration.ofSeconds(3));
|
||||
return spec;
|
||||
}
|
||||
|
||||
private McpSchema.Tool tool(String name) {
|
||||
McpSchema.JsonSchema schema = new McpSchema.JsonSchema("object",
|
||||
Map.of("q", Map.of("type", "string", "description", "query")),
|
||||
List.of("q"), null, null, null);
|
||||
return new McpSchema.Tool(name, name, name + " description", schema, null, null, null);
|
||||
}
|
||||
|
||||
private static class FakeMcpClientFactory extends McpClientFactory {
|
||||
|
||||
private final McpClientWrapper client;
|
||||
|
||||
private FakeMcpClientFactory(McpClientWrapper client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
@Override
|
||||
public McpClientWrapper create(McpSpec spec) {
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
private static class FakeMcpClientWrapper extends McpClientWrapper {
|
||||
|
||||
private final List<McpSchema.Tool> tools;
|
||||
private final AtomicBoolean closed = new AtomicBoolean(false);
|
||||
private final AtomicReference<String> lastCalledToolName = new AtomicReference<>();
|
||||
private boolean failOnListTools;
|
||||
|
||||
private FakeMcpClientWrapper(String name, List<McpSchema.Tool> tools) {
|
||||
super(name);
|
||||
this.tools = tools;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> initialize() {
|
||||
initialized = true;
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<List<McpSchema.Tool>> listTools() {
|
||||
if (failOnListTools) {
|
||||
return Mono.error(new IllegalStateException("list tools failed"));
|
||||
}
|
||||
return Mono.just(tools);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<McpSchema.CallToolResult> callTool(String toolName, Map<String, Object> arguments) {
|
||||
lastCalledToolName.set(toolName);
|
||||
return Mono.just(new McpSchema.CallToolResult(List.of(), false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
closed.set(true);
|
||||
initialized = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.easyagents.agent.runtime.mcp;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRuntimeException;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* 测试 MCP 连接方式解析。
|
||||
*/
|
||||
public class McpTransportTypeTest {
|
||||
|
||||
@Test
|
||||
public void shouldParseCompatibleTransportValues() {
|
||||
Assert.assertEquals(McpTransportType.STDIO, McpTransportType.from(null));
|
||||
Assert.assertEquals(McpTransportType.STDIO, McpTransportType.from("stdio"));
|
||||
Assert.assertEquals(McpTransportType.SSE, McpTransportType.from("http-sse"));
|
||||
Assert.assertEquals(McpTransportType.SSE, McpTransportType.from("SSE"));
|
||||
Assert.assertEquals(McpTransportType.HTTP, McpTransportType.from("http-stream"));
|
||||
Assert.assertEquals(McpTransportType.HTTP, McpTransportType.from("HTTP"));
|
||||
Assert.assertEquals(McpTransportType.HTTP, McpTransportType.from("streamable-http"));
|
||||
}
|
||||
|
||||
@Test(expected = AgentRuntimeException.class)
|
||||
public void shouldRejectUnsupportedTransportValue() {
|
||||
McpTransportType.from("websocket");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package com.easyagents.agent.runtime.tool.asynctool;
|
||||
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
||||
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolContext;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolResult;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolSpec;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* 测试异步工具声明展开器。
|
||||
*/
|
||||
public class AsyncToolSpecExpanderTest {
|
||||
|
||||
@Test
|
||||
public void shouldExpandFiveToolSpecsAndInvokers() {
|
||||
AsyncToolSpec spec = spec(new StubSubTools());
|
||||
AsyncToolSpecExpander expander = new AsyncToolSpecExpander();
|
||||
|
||||
List<AgentToolSpec> specs = expander.expandSpecs(spec);
|
||||
Map<String, AgentToolInvoker> invokers = expander.expandInvokers(spec);
|
||||
|
||||
Assert.assertEquals(List.of("demo_task_submit", "demo_task_observe", "demo_task_result",
|
||||
"demo_task_cancel", "demo_task_list"), specs.stream().map(AgentToolSpec::getName).toList());
|
||||
Assert.assertEquals(5, invokers.size());
|
||||
Assert.assertEquals("object", specs.get(1).getParametersSchema().get("type"));
|
||||
Assert.assertEquals("AsyncToolTaskView", specs.get(1).getOutputSchema().get("title"));
|
||||
Assert.assertTrue(specs.get(0).getDescription().contains("default entry point when the user asks to run this tool"));
|
||||
Assert.assertTrue(specs.get(1).getDescription().contains("Use immediately after submit"));
|
||||
Assert.assertTrue(specs.get(1).getDescription().contains("Do not ask the user for task_id immediately after submit"));
|
||||
Assert.assertTrue(specs.get(2).getDescription().contains("final result"));
|
||||
Assert.assertTrue(specs.get(3).getDescription().contains("only when the user explicitly asks to cancel"));
|
||||
Assert.assertTrue(specs.get(4).getDescription().contains("only when the user explicitly asks to list"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldKeepRuntimeMetadataWhenUserMetadataUsesReservedKeys() {
|
||||
AsyncToolSpec spec = spec(new StubSubTools());
|
||||
spec.setMetadata(Map.of("asyncTool", false, "asyncToolName", "user_name", "custom", "value"));
|
||||
AsyncToolSpecExpander expander = new AsyncToolSpecExpander();
|
||||
|
||||
AgentToolSpec toolSpec = expander.expandSpecs(spec).get(0);
|
||||
AgentToolResult result = expander.expandInvokers(spec).get("demo_task_submit")
|
||||
.invoke(Map.of(), context(new ArrayList<>()));
|
||||
|
||||
Assert.assertEquals(true, toolSpec.getMetadata().get("asyncTool"));
|
||||
Assert.assertEquals("demo_task", toolSpec.getMetadata().get("asyncToolName"));
|
||||
Assert.assertEquals("value", toolSpec.getMetadata().get("custom"));
|
||||
Assert.assertEquals(true, result.getMetadata().get("asyncTool"));
|
||||
Assert.assertEquals("demo_task", result.getMetadata().get("asyncToolName"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldApplyApprovalOnlyToSubmitTool() {
|
||||
AsyncToolSpec spec = spec(new StubSubTools());
|
||||
AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest();
|
||||
approvalRequest.setApprovalPrompt("确认提交任务?");
|
||||
spec.setApprovalRequired(true);
|
||||
spec.setApprovalRequest(approvalRequest);
|
||||
|
||||
List<AgentToolSpec> specs = new AsyncToolSpecExpander().expandSpecs(spec);
|
||||
|
||||
Assert.assertTrue(specs.get(0).isApprovalRequired());
|
||||
Assert.assertEquals("确认提交任务?", specs.get(0).getApprovalRequest().getApprovalPrompt());
|
||||
Assert.assertFalse(specs.get(1).isApprovalRequired());
|
||||
Assert.assertFalse(specs.get(2).isApprovalRequired());
|
||||
Assert.assertFalse(specs.get(3).isApprovalRequired());
|
||||
Assert.assertFalse(specs.get(4).isApprovalRequired());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldSubmitAndEmitSubmittedEvent() {
|
||||
List<AgentRuntimeEvent> events = new ArrayList<>();
|
||||
AgentToolResult result = invoke("demo_task_submit", Map.of("input", "hello"), new StubSubTools(), events);
|
||||
|
||||
Assert.assertTrue(result.isSuccess());
|
||||
Assert.assertTrue(result.getModelContent().contains("task_id: task-1"));
|
||||
Assert.assertEquals("task-1", result.getMetadata().get("taskId"));
|
||||
Assert.assertEquals(AgentRuntimeEventType.ASYNC_TOOL_SUBMITTED, events.get(0).getEventType());
|
||||
Assert.assertEquals("task-1", events.get(0).getPayload().get("taskId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldFailWhenSubmitDoesNotReturnTaskId() {
|
||||
List<AgentRuntimeEvent> events = new ArrayList<>();
|
||||
AgentToolResult result = invoke("demo_task_submit", Map.of(), new StubSubTools() {
|
||||
@Override
|
||||
public AsyncToolSubmitResult submit(Map<String, Object> arguments, AgentToolContext context) {
|
||||
AsyncToolSubmitResult submitResult = super.submit(arguments, context);
|
||||
submitResult.setTaskId(null);
|
||||
return submitResult;
|
||||
}
|
||||
}, events);
|
||||
|
||||
Assert.assertFalse(result.isSuccess());
|
||||
Assert.assertTrue(result.getErrorMessage().contains("taskId"));
|
||||
Assert.assertEquals(AgentRuntimeEventType.ASYNC_TOOL_FAILED, events.get(0).getEventType());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldObserveWithCursorLimitAndContext() {
|
||||
AtomicReference<AsyncToolObserveRequest> requestRef = new AtomicReference<>();
|
||||
AtomicReference<AgentToolContext> contextRef = new AtomicReference<>();
|
||||
StubSubTools subTools = new StubSubTools() {
|
||||
@Override
|
||||
public AsyncToolTaskView observe(AsyncToolObserveRequest request, AgentToolContext context) {
|
||||
requestRef.set(request);
|
||||
contextRef.set(context);
|
||||
return super.observe(request, context);
|
||||
}
|
||||
};
|
||||
List<AgentRuntimeEvent> events = new ArrayList<>();
|
||||
|
||||
AgentToolResult result = invoke("demo_task_observe", Map.of("taskId", "task-1", "cursor", 7, "limit", 999),
|
||||
subTools, events);
|
||||
|
||||
AsyncToolTaskView view = (AsyncToolTaskView) result.getDisplayContent();
|
||||
Assert.assertEquals(Long.valueOf(7), requestRef.get().getCursor());
|
||||
Assert.assertEquals(Integer.valueOf(100), requestRef.get().getLimit());
|
||||
Assert.assertEquals(Long.valueOf(8), view.getNextCursor());
|
||||
Assert.assertEquals("request-1", contextRef.get().getRequestId());
|
||||
Assert.assertEquals(AgentRuntimeEventType.ASYNC_TOOL_OBSERVED, events.get(0).getEventType());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldWrapInvalidArgumentsAndEmitFailedEvent() {
|
||||
List<AgentRuntimeEvent> events = new ArrayList<>();
|
||||
|
||||
AgentToolResult result = invoke("demo_task_observe", Map.of("taskId", "task-1", "cursor", "bad"),
|
||||
new StubSubTools(), events);
|
||||
|
||||
Assert.assertFalse(result.isSuccess());
|
||||
Assert.assertEquals(AgentRuntimeEventType.ASYNC_TOOL_FAILED, events.get(0).getEventType());
|
||||
Assert.assertEquals("FAILED", result.getMetadata().get("status"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldReturnObservationFromResultWhenRunningAndFinalResultWhenSucceeded() {
|
||||
StubSubTools running = new StubSubTools();
|
||||
AgentToolResult runningResult = invoke("demo_task_result", Map.of("taskId", "task-1"), running, new ArrayList<>());
|
||||
AsyncToolTaskView runningView = (AsyncToolTaskView) runningResult.getDisplayContent();
|
||||
|
||||
StubSubTools succeeded = new StubSubTools() {
|
||||
@Override
|
||||
public AsyncToolTaskView result(AsyncToolResultRequest request, AgentToolContext context) {
|
||||
AsyncToolTaskView view = super.result(request, context);
|
||||
view.setStatus(AsyncToolTaskStatus.SUCCEEDED);
|
||||
view.setResult("done");
|
||||
return view;
|
||||
}
|
||||
};
|
||||
AgentToolResult successResult = invoke("demo_task_result", Map.of("taskId", "task-1"), succeeded, new ArrayList<>());
|
||||
AsyncToolTaskView successView = (AsyncToolTaskView) successResult.getDisplayContent();
|
||||
|
||||
Assert.assertEquals(AsyncToolTaskStatus.RUNNING, runningView.getStatus());
|
||||
Assert.assertEquals(AsyncToolTaskStatus.SUCCEEDED, successView.getStatus());
|
||||
Assert.assertEquals("done", successView.getResult());
|
||||
Assert.assertTrue(successView.getTerminal());
|
||||
Assert.assertTrue(successView.getResultAvailable());
|
||||
Assert.assertFalse(runningResult.getModelContent().contains("result:"));
|
||||
Assert.assertTrue(successResult.getModelContent().contains("result_available: true"));
|
||||
Assert.assertTrue(successResult.getModelContent().contains("result: done"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldExposeCompletedObservationResultToModelContentAsJson() {
|
||||
List<AgentRuntimeEvent> events = new ArrayList<>();
|
||||
LinkedHashMap<String, Object> businessResult = new LinkedHashMap<>();
|
||||
businessResult.put("answer", "42");
|
||||
businessResult.put("items", List.of(1, 2));
|
||||
StubSubTools succeeded = new StubSubTools() {
|
||||
@Override
|
||||
public AsyncToolTaskView observe(AsyncToolObserveRequest request, AgentToolContext context) {
|
||||
AsyncToolTaskView view = super.observe(request, context);
|
||||
view.setStatus(AsyncToolTaskStatus.SUCCEEDED);
|
||||
view.setResult(businessResult);
|
||||
return view;
|
||||
}
|
||||
};
|
||||
|
||||
AgentToolResult result = invoke("demo_task_observe", Map.of("taskId", "task-1"), succeeded, events);
|
||||
AsyncToolTaskView view = (AsyncToolTaskView) result.getDisplayContent();
|
||||
|
||||
Assert.assertEquals(businessResult, view.getResult());
|
||||
Assert.assertTrue(result.getModelContent().contains("result_available: true"));
|
||||
Assert.assertTrue(result.getModelContent().contains("result: {\"answer\":\"42\",\"items\":[1,2]}"));
|
||||
Assert.assertEquals(Boolean.TRUE, events.get(0).getPayload().get("resultAvailable"));
|
||||
Assert.assertFalse(events.get(0).getPayload().containsKey("result"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCancelAndRepresentUnsupportedCancelAsFailure() {
|
||||
AgentToolResult success = invoke("demo_task_cancel", Map.of("taskId", "task-1"), new StubSubTools(), new ArrayList<>());
|
||||
AgentToolResult failure = invoke("demo_task_cancel", Map.of("taskId", "task-1"), new StubSubTools() {
|
||||
@Override
|
||||
public AsyncToolCancelResult cancel(AsyncToolCancelRequest request, AgentToolContext context) {
|
||||
AsyncToolCancelResult result = super.cancel(request, context);
|
||||
result.setErrorMessage("不支持取消");
|
||||
return result;
|
||||
}
|
||||
}, new ArrayList<>());
|
||||
|
||||
Assert.assertTrue(success.isSuccess());
|
||||
Assert.assertFalse(failure.isSuccess());
|
||||
Assert.assertEquals("不支持取消", failure.getErrorMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldListTasksWithoutPagination() {
|
||||
AgentToolResult result = invoke("demo_task_list", Map.of("status", "running"), new StubSubTools(), new ArrayList<>());
|
||||
AsyncToolTaskListResult list = (AsyncToolTaskListResult) result.getDisplayContent();
|
||||
|
||||
Assert.assertTrue(result.isSuccess());
|
||||
Assert.assertEquals(1, list.getTasks().size());
|
||||
Assert.assertFalse(Arrays.stream(AsyncToolTaskListResult.class.getDeclaredFields())
|
||||
.anyMatch(field -> field.getName().contains("PageToken")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldWrapExceptionAndEmitFailedEvent() {
|
||||
List<AgentRuntimeEvent> events = new ArrayList<>();
|
||||
AgentToolResult result = invoke("demo_task_observe", Map.of("taskId", "task-1"), new StubSubTools() {
|
||||
@Override
|
||||
public AsyncToolTaskView observe(AsyncToolObserveRequest request, AgentToolContext context) {
|
||||
throw new IllegalStateException("boom");
|
||||
}
|
||||
}, events);
|
||||
|
||||
Assert.assertFalse(result.isSuccess());
|
||||
Assert.assertEquals("boom", result.getErrorMessage());
|
||||
Assert.assertEquals(AgentRuntimeEventType.ASYNC_TOOL_FAILED, events.get(0).getEventType());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldTimeoutAndEmitFailedEvent() {
|
||||
List<AgentRuntimeEvent> events = new ArrayList<>();
|
||||
AsyncToolOptions options = AsyncToolOptions.defaults();
|
||||
options.setObserveTimeout(Duration.ofMillis(20));
|
||||
StubSubTools subTools = new StubSubTools() {
|
||||
@Override
|
||||
public AsyncToolTaskView observe(AsyncToolObserveRequest request, AgentToolContext context) {
|
||||
try {
|
||||
Thread.sleep(200);
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return super.observe(request, context);
|
||||
}
|
||||
};
|
||||
AgentToolResult result = invoke("demo_task_observe", Map.of("taskId", "task-1"), subTools, events, options);
|
||||
|
||||
Assert.assertFalse(result.isSuccess());
|
||||
Assert.assertTrue(result.getErrorMessage().contains("timed out"));
|
||||
Assert.assertEquals(AgentRuntimeEventType.ASYNC_TOOL_FAILED, events.get(0).getEventType());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldNotPutLargePayloadIntoModelContentOrEventPayload() {
|
||||
List<AgentRuntimeEvent> events = new ArrayList<>();
|
||||
AgentToolResult result = invoke("demo_task_submit", Map.of(), new StubSubTools() {
|
||||
@Override
|
||||
public AsyncToolSubmitResult submit(Map<String, Object> arguments, AgentToolContext context) {
|
||||
AsyncToolSubmitResult result = super.submit(arguments, context);
|
||||
result.getPayload().put("large", "x".repeat(5000));
|
||||
return result;
|
||||
}
|
||||
}, events);
|
||||
|
||||
Assert.assertFalse(result.getModelContent().contains("xxxxx"));
|
||||
Assert.assertFalse(events.get(0).getPayload().containsKey("large"));
|
||||
Assert.assertTrue(((AsyncToolSubmitResult) result.getDisplayContent()).getPayload().containsKey("large"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldTrimLargeResultForModelContentAndKeepFullDisplayContent() {
|
||||
List<AgentRuntimeEvent> events = new ArrayList<>();
|
||||
AsyncToolOptions options = AsyncToolOptions.defaults();
|
||||
options.setMaxModelContentLength(160);
|
||||
String largeResult = "x".repeat(5000);
|
||||
StubSubTools succeeded = new StubSubTools() {
|
||||
@Override
|
||||
public AsyncToolTaskView result(AsyncToolResultRequest request, AgentToolContext context) {
|
||||
AsyncToolTaskView view = super.result(request, context);
|
||||
view.setStatus(AsyncToolTaskStatus.SUCCEEDED);
|
||||
view.setResult(Map.of("large", largeResult));
|
||||
return view;
|
||||
}
|
||||
};
|
||||
|
||||
AgentToolResult result = invoke("demo_task_result", Map.of("taskId", "task-1"), succeeded, events, options);
|
||||
AsyncToolTaskView view = (AsyncToolTaskView) result.getDisplayContent();
|
||||
|
||||
Assert.assertTrue(result.getModelContent().length() <= options.getMaxModelContentLength() + 3);
|
||||
Assert.assertTrue(result.getModelContent().contains("result_available: true"));
|
||||
Assert.assertFalse(result.getModelContent().contains(largeResult));
|
||||
Assert.assertEquals(Map.of("large", largeResult), view.getResult());
|
||||
Assert.assertEquals(Boolean.TRUE, events.get(0).getPayload().get("resultAvailable"));
|
||||
Assert.assertFalse(events.get(0).getPayload().containsKey("result"));
|
||||
}
|
||||
|
||||
private AgentToolResult invoke(String toolName,
|
||||
Map<String, Object> arguments,
|
||||
AsyncSubTools subTools,
|
||||
List<AgentRuntimeEvent> events) {
|
||||
return invoke(toolName, arguments, subTools, events, AsyncToolOptions.defaults());
|
||||
}
|
||||
|
||||
private AgentToolResult invoke(String toolName,
|
||||
Map<String, Object> arguments,
|
||||
AsyncSubTools subTools,
|
||||
List<AgentRuntimeEvent> events,
|
||||
AsyncToolOptions options) {
|
||||
AsyncToolSpec spec = spec(subTools);
|
||||
spec.setOptions(options);
|
||||
AsyncToolSpecExpander expander = new AsyncToolSpecExpander(Executors.newCachedThreadPool());
|
||||
AgentToolContext context = context(events);
|
||||
return expander.expandInvokers(spec).get(toolName).invoke(arguments, context);
|
||||
}
|
||||
|
||||
private AsyncToolSpec spec(AsyncSubTools subTools) {
|
||||
AsyncToolSpec spec = new AsyncToolSpec();
|
||||
spec.setName("demo_task");
|
||||
spec.setDescription("Demo async task.");
|
||||
spec.setSubTools(subTools);
|
||||
spec.setSubmitParametersSchema(Map.of("type", "object", "properties", Map.of("input", Map.of("type", "string"))));
|
||||
return spec;
|
||||
}
|
||||
|
||||
private AgentToolContext context(List<AgentRuntimeEvent> events) {
|
||||
AgentToolContext context = new AgentToolContext();
|
||||
context.setRequestId("request-1");
|
||||
context.setTraceId("trace-1");
|
||||
context.setSessionId("session-1");
|
||||
context.setAgentId("agent-1");
|
||||
context.setToolCallId("tool-call-1");
|
||||
context.setEventEmitter(events::add);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static class StubSubTools implements AsyncSubTools {
|
||||
|
||||
@Override
|
||||
public AsyncToolSubmitResult submit(Map<String, Object> arguments, AgentToolContext context) {
|
||||
AsyncToolSubmitResult result = new AsyncToolSubmitResult();
|
||||
result.setTaskId("task-1");
|
||||
result.setStatus(AsyncToolTaskStatus.RUNNING);
|
||||
result.setCursor(0L);
|
||||
result.setSummary("submitted");
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AsyncToolTaskView observe(AsyncToolObserveRequest request, AgentToolContext context) {
|
||||
AsyncToolTaskView view = new AsyncToolTaskView();
|
||||
view.setTaskId(request.getTaskId());
|
||||
view.setStatus(AsyncToolTaskStatus.RUNNING);
|
||||
view.setCursor(request.getCursor());
|
||||
view.setNextCursor(request.getCursor() == null ? 1L : request.getCursor() + 1);
|
||||
view.setSummary("running");
|
||||
AsyncToolTaskEvent event = new AsyncToolTaskEvent();
|
||||
event.setSequence(view.getNextCursor());
|
||||
event.setType("TASK_LOG");
|
||||
event.setText("progress");
|
||||
event.setCreatedAt(Instant.now());
|
||||
view.setEvents(List.of(event));
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AsyncToolTaskView result(AsyncToolResultRequest request, AgentToolContext context) {
|
||||
AsyncToolObserveRequest observeRequest = new AsyncToolObserveRequest();
|
||||
observeRequest.setTaskId(request.getTaskId());
|
||||
observeRequest.setCursor(request.getCursor());
|
||||
observeRequest.setLimit(request.getLimit());
|
||||
return observe(observeRequest, context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AsyncToolCancelResult cancel(AsyncToolCancelRequest request, AgentToolContext context) {
|
||||
AsyncToolCancelResult result = new AsyncToolCancelResult();
|
||||
result.setTaskId(request.getTaskId());
|
||||
result.setStatus(AsyncToolTaskStatus.CANCELLING);
|
||||
result.setMessage("cancelling");
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AsyncToolTaskListResult list(AsyncToolListRequest request, AgentToolContext context) {
|
||||
AsyncToolTaskSummary summary = new AsyncToolTaskSummary();
|
||||
summary.setTaskId("task-1");
|
||||
summary.setStatus(AsyncToolTaskStatus.RUNNING);
|
||||
summary.setSummary("running");
|
||||
AsyncToolTaskListResult result = new AsyncToolTaskListResult();
|
||||
result.setTasks(List.of(summary));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.easyagents.agent.runtime.tool.operate;
|
||||
|
||||
import com.easyagents.agent.runtime.AgentRuntimeException;
|
||||
import com.easyagents.agent.runtime.tool.AgentToolSpec;
|
||||
import io.agentscope.core.tool.Toolkit;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 测试 Agent 操作类工具适配器。
|
||||
*/
|
||||
public class AgentOperateToolAdapterTest {
|
||||
|
||||
private final AgentOperateToolAdapter adapter = new AgentOperateToolAdapter();
|
||||
|
||||
@Test
|
||||
public void shouldRegisterReadFileToolsWithDefaultHitlDisabled() {
|
||||
Toolkit toolkit = new Toolkit();
|
||||
AgentOperateToolSpec spec = spec(AgentOperateToolType.READ_FILE);
|
||||
|
||||
List<AgentToolSpec> toolSpecs = adapter.register(List.of(spec), toolkit);
|
||||
|
||||
Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.VIEW_TEXT_FILE_TOOL));
|
||||
Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.LIST_DIRECTORY_TOOL));
|
||||
Assert.assertEquals(2, toolSpecs.size());
|
||||
Assert.assertTrue(toolSpecs.stream().noneMatch(AgentToolSpec::isApprovalRequired));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRegisterWriteFileToolsWithDefaultHitlEnabled() {
|
||||
Toolkit toolkit = new Toolkit();
|
||||
AgentOperateToolSpec spec = spec(AgentOperateToolType.WRITE_FILE);
|
||||
|
||||
List<AgentToolSpec> toolSpecs = adapter.register(List.of(spec), toolkit);
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRegisterShellToolWithEmptyWhitelist() {
|
||||
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());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldSkipDisabledOperateTool() {
|
||||
Toolkit toolkit = new Toolkit();
|
||||
AgentOperateToolSpec spec = spec(AgentOperateToolType.SHELL);
|
||||
spec.setEnabled(false);
|
||||
|
||||
List<AgentToolSpec> toolSpecs = adapter.register(List.of(spec), toolkit);
|
||||
|
||||
Assert.assertNull(toolkit.getTool(AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL));
|
||||
Assert.assertTrue(toolSpecs.isEmpty());
|
||||
}
|
||||
|
||||
@Test(expected = AgentRuntimeException.class)
|
||||
public void shouldRejectMissingBaseDir() {
|
||||
AgentOperateToolSpec spec = spec(AgentOperateToolType.READ_FILE);
|
||||
spec.setBaseDir(null);
|
||||
|
||||
adapter.register(List.of(spec), new Toolkit());
|
||||
}
|
||||
|
||||
@Test(expected = AgentRuntimeException.class)
|
||||
public void shouldRejectRelativeBaseDir() {
|
||||
AgentOperateToolSpec spec = spec(AgentOperateToolType.READ_FILE);
|
||||
spec.setBaseDir("relative/workspace");
|
||||
|
||||
adapter.register(List.of(spec), new Toolkit());
|
||||
}
|
||||
|
||||
@Test(expected = AgentRuntimeException.class)
|
||||
public void shouldRejectToolNameConflict() {
|
||||
Toolkit toolkit = new Toolkit();
|
||||
AgentOperateToolSpec first = spec(AgentOperateToolType.READ_FILE);
|
||||
AgentOperateToolSpec second = spec(AgentOperateToolType.READ_FILE);
|
||||
|
||||
adapter.register(List.of(first, second), toolkit);
|
||||
}
|
||||
|
||||
private AgentOperateToolSpec spec(AgentOperateToolType type) {
|
||||
AgentOperateToolSpec spec = new AgentOperateToolSpec();
|
||||
spec.setType(type);
|
||||
spec.setBaseDir(System.getProperty("java.io.tmpdir"));
|
||||
return spec;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,11 @@
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
/*
|
||||
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
|
||||
* <p>
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* <p>
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* <p>
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.easyagents.embedding.openai;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.easyagents.core.document.Document;
|
||||
import com.easyagents.core.model.client.HttpClient;
|
||||
import com.easyagents.core.model.embedding.BaseEmbeddingModel;
|
||||
@@ -24,10 +11,9 @@ import com.easyagents.core.store.VectorData;
|
||||
import com.easyagents.core.util.JSONUtil;
|
||||
import com.easyagents.core.util.Maps;
|
||||
import com.easyagents.core.util.StringUtil;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
public class OpenAIEmbeddingModel extends BaseEmbeddingModel<OpenAIEmbeddingConfig> {
|
||||
@@ -69,19 +55,63 @@ public class OpenAIEmbeddingModel extends BaseEmbeddingModel<OpenAIEmbeddingConf
|
||||
|
||||
VectorData vectorData = new VectorData();
|
||||
double[] embedding = JSONUtil.readDoubleArray(jsonObject, "$.data[0].embedding");
|
||||
if (embedding == null || embedding.length == 0) {
|
||||
throw new ModelException(buildMissingEmbeddingMessage());
|
||||
}
|
||||
vectorData.setVector(embedding);
|
||||
|
||||
return vectorData;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Builds the embeddings request payload for OpenAI-compatible providers.
|
||||
*
|
||||
* @param text document to embed
|
||||
* @param options embedding request options
|
||||
* @param config model configuration
|
||||
* @return JSON payload for the embeddings endpoint
|
||||
*/
|
||||
public static String promptToEmbeddingsPayload(Document text, EmbeddingOptions options, OpenAIEmbeddingConfig config) {
|
||||
// https://platform.openai.com/docs/api-reference/making-requests
|
||||
return Maps.of("model", options.getModelOrDefault(config.getModel()))
|
||||
String model = options.getModelOrDefault(config.getModel());
|
||||
return Maps.of("model", model)
|
||||
.set("encoding_format", options.getEncodingFormatOrDefault("float"))
|
||||
.set("input", text.getContent())
|
||||
.setIfNotEmpty("user", options.getUser())
|
||||
.setIfNotEmpty("dimensions", options.getDimensions())
|
||||
.setIf(
|
||||
supportsDimensionsParameter(model) && options.getDimensions() != null,
|
||||
"dimensions",
|
||||
options.getDimensions()
|
||||
)
|
||||
.toJSON();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the upstream embeddings endpoint supports dynamic dimensions.
|
||||
*
|
||||
* @param model model name sent to the provider
|
||||
* @return true when dimensions should be sent
|
||||
*/
|
||||
static boolean supportsDimensionsParameter(String model) {
|
||||
if (StringUtil.noText(model)) {
|
||||
return false;
|
||||
}
|
||||
String normalizedModel = model.toLowerCase(Locale.ROOT);
|
||||
return normalizedModel.contains("qwen3-embedding")
|
||||
|| normalizedModel.contains("qwen");
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a safe diagnostic message for malformed embeddings responses.
|
||||
*
|
||||
* @return diagnostic message without secrets or raw response content
|
||||
*/
|
||||
private String buildMissingEmbeddingMessage() {
|
||||
return "Embedding response does not contain data[0].embedding."
|
||||
+ " Please check provider, model, request path, dimensions, or response format."
|
||||
+ " provider=" + config.getProvider()
|
||||
+ ", model=" + config.getModel()
|
||||
+ ", endpoint=" + config.getEndpoint()
|
||||
+ ", requestPath=" + config.getRequestPath();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.easyagents.embedding.openai;
|
||||
|
||||
import com.easyagents.core.document.Document;
|
||||
import com.easyagents.core.model.client.HttpClient;
|
||||
import com.easyagents.core.model.embedding.EmbeddingOptions;
|
||||
import com.easyagents.core.model.exception.ModelException;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Tests for OpenAI-compatible embeddings request payload generation.
|
||||
*/
|
||||
public class OpenAIEmbeddingModelTest {
|
||||
|
||||
/**
|
||||
* Verifies that fixed-dimension embeddings models do not receive dimensions.
|
||||
*/
|
||||
@Test
|
||||
public void shouldNotSendDimensionsForBgeModel() {
|
||||
OpenAIEmbeddingConfig config = new OpenAIEmbeddingConfig();
|
||||
config.setModel("BAAI/bge-m3");
|
||||
EmbeddingOptions options = new EmbeddingOptions();
|
||||
options.setDimensions(1024);
|
||||
|
||||
String payload = OpenAIEmbeddingModel.promptToEmbeddingsPayload(Document.of("hello"), options, config);
|
||||
|
||||
Assert.assertFalse(payload.contains("\"dimensions\""));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that Qwen3 embedding models keep the dynamic dimensions parameter.
|
||||
*/
|
||||
@Test
|
||||
public void shouldSendDimensionsForQwen3EmbeddingModel() {
|
||||
OpenAIEmbeddingConfig config = new OpenAIEmbeddingConfig();
|
||||
config.setModel("Qwen/Qwen3-Embedding-8B");
|
||||
EmbeddingOptions options = new EmbeddingOptions();
|
||||
options.setDimensions(1024);
|
||||
|
||||
String payload = OpenAIEmbeddingModel.promptToEmbeddingsPayload(Document.of("hello"), options, config);
|
||||
|
||||
Assert.assertTrue(payload.contains("\"dimensions\":1024"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that malformed embeddings responses fail with a clear model exception.
|
||||
*/
|
||||
@Test
|
||||
public void shouldThrowModelExceptionWhenEmbeddingMissing() {
|
||||
OpenAIEmbeddingConfig config = new OpenAIEmbeddingConfig();
|
||||
config.setProvider("test-provider");
|
||||
config.setModel("BAAI/bge-m3");
|
||||
config.setApiKey("test-key");
|
||||
OpenAIEmbeddingModel model = new OpenAIEmbeddingModel(config);
|
||||
model.setHttpClient(new HttpClient() {
|
||||
@Override
|
||||
public String post(String url, Map<String, String> headers, String payload) {
|
||||
return "{\"data\":[{}]}";
|
||||
}
|
||||
});
|
||||
|
||||
ModelException exception = Assert.assertThrows(
|
||||
ModelException.class,
|
||||
() -> model.embed(Document.of("hello"))
|
||||
);
|
||||
|
||||
Assert.assertTrue(exception.getMessage().contains("data[0].embedding"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
|
||||
* <p>
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
*/
|
||||
package com.easyagents.mcp.client;
|
||||
|
||||
/**
|
||||
* MCP 单项检测结果。
|
||||
*/
|
||||
public class McpCheckItem {
|
||||
|
||||
private String name;
|
||||
private McpCheckStatus status = McpCheckStatus.SUCCESS;
|
||||
private String message;
|
||||
private String detail;
|
||||
|
||||
/**
|
||||
* 创建检测项。
|
||||
*
|
||||
* @param name 检测项名称
|
||||
* @param status 检测状态
|
||||
* @param message 检测消息
|
||||
* @param detail 检测详情
|
||||
* @return 检测项
|
||||
*/
|
||||
public static McpCheckItem of(String name, McpCheckStatus status, String message, String detail) {
|
||||
McpCheckItem item = new McpCheckItem();
|
||||
item.setName(name);
|
||||
item.setStatus(status);
|
||||
item.setMessage(message);
|
||||
item.setDetail(detail);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取检测项名称。
|
||||
*
|
||||
* @return 检测项名称
|
||||
*/
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置检测项名称。
|
||||
*
|
||||
* @param name 检测项名称
|
||||
*/
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取检测状态。
|
||||
*
|
||||
* @return 检测状态
|
||||
*/
|
||||
public McpCheckStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置检测状态。
|
||||
*
|
||||
* @param status 检测状态
|
||||
*/
|
||||
public void setStatus(McpCheckStatus status) {
|
||||
this.status = status == null ? McpCheckStatus.SUCCESS : status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取检测消息。
|
||||
*
|
||||
* @return 检测消息
|
||||
*/
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置检测消息。
|
||||
*
|
||||
* @param message 检测消息
|
||||
*/
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取检测详情。
|
||||
*
|
||||
* @return 检测详情
|
||||
*/
|
||||
public String getDetail() {
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置检测详情。
|
||||
*
|
||||
* @param detail 检测详情
|
||||
*/
|
||||
public void setDetail(String detail) {
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
|
||||
* <p>
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
*/
|
||||
package com.easyagents.mcp.client;
|
||||
|
||||
/**
|
||||
* MCP 检测状态。
|
||||
*/
|
||||
public enum McpCheckStatus {
|
||||
|
||||
/**
|
||||
* 检测通过。
|
||||
*/
|
||||
SUCCESS,
|
||||
|
||||
/**
|
||||
* 检测存在警告但不一定阻断使用。
|
||||
*/
|
||||
WARNING,
|
||||
|
||||
/**
|
||||
* 检测失败。
|
||||
*/
|
||||
FAILED
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
|
||||
* <p>
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
*/
|
||||
package com.easyagents.mcp.client;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MCP 环境检测结果。
|
||||
*/
|
||||
public class McpEnvironmentCheckResult {
|
||||
|
||||
private McpCheckStatus overallStatus = McpCheckStatus.SUCCESS;
|
||||
private List<McpServerCheckResult> servers = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 合并两个状态。
|
||||
*
|
||||
* @param current 当前状态
|
||||
* @param incoming 新状态
|
||||
* @return 合并后的状态
|
||||
*/
|
||||
public static McpCheckStatus mergeStatus(McpCheckStatus current, McpCheckStatus incoming) {
|
||||
if (current == McpCheckStatus.FAILED || incoming == McpCheckStatus.FAILED) {
|
||||
return McpCheckStatus.FAILED;
|
||||
}
|
||||
if (current == McpCheckStatus.WARNING || incoming == McpCheckStatus.WARNING) {
|
||||
return McpCheckStatus.WARNING;
|
||||
}
|
||||
return McpCheckStatus.SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 Server 检测结果。
|
||||
*
|
||||
* @param server Server 检测结果
|
||||
*/
|
||||
public void addServer(McpServerCheckResult server) {
|
||||
if (server == null) {
|
||||
return;
|
||||
}
|
||||
this.servers.add(server);
|
||||
this.overallStatus = mergeStatus(this.overallStatus, server.getStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取整体状态。
|
||||
*
|
||||
* @return 整体状态
|
||||
*/
|
||||
public McpCheckStatus getOverallStatus() {
|
||||
return overallStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置整体状态。
|
||||
*
|
||||
* @param overallStatus 整体状态
|
||||
*/
|
||||
public void setOverallStatus(McpCheckStatus overallStatus) {
|
||||
this.overallStatus = overallStatus == null ? McpCheckStatus.SUCCESS : overallStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Server 检测结果。
|
||||
*
|
||||
* @return Server 检测结果
|
||||
*/
|
||||
public List<McpServerCheckResult> getServers() {
|
||||
return servers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Server 检测结果。
|
||||
*
|
||||
* @param servers Server 检测结果
|
||||
*/
|
||||
public void setServers(List<McpServerCheckResult> servers) {
|
||||
this.servers = servers == null ? new ArrayList<>() : new ArrayList<>(servers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/*
|
||||
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
|
||||
* <p>
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
*/
|
||||
package com.easyagents.mcp.client;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import io.modelcontextprotocol.client.McpClient;
|
||||
import io.modelcontextprotocol.client.McpSyncClient;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* MCP 配置与运行环境检测器。
|
||||
*/
|
||||
public class McpEnvironmentChecker {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(McpEnvironmentChecker.class);
|
||||
private static final Duration COMMAND_TIMEOUT = Duration.ofSeconds(3);
|
||||
private static final Duration MCP_REQUEST_TIMEOUT = Duration.ofSeconds(10);
|
||||
private static final Set<String> SUPPORTED_TRANSPORTS = Set.of("stdio", "http-sse", "http-stream");
|
||||
private static final Set<String> KNOWN_VERSION_COMMANDS = Set.of(
|
||||
"node", "npm", "npx", "pnpm", "python", "python3", "pip", "pip3");
|
||||
private final boolean probeEnabled;
|
||||
private final Function<String, McpTransportFactory> transportFactoryProvider;
|
||||
|
||||
/**
|
||||
* 创建启用连接探测的检测器。
|
||||
*/
|
||||
public McpEnvironmentChecker() {
|
||||
this(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建可控制连接探测行为的检测器。
|
||||
*
|
||||
* @param probeEnabled 是否启用 MCP 连接探测
|
||||
*/
|
||||
McpEnvironmentChecker(boolean probeEnabled) {
|
||||
this(probeEnabled, McpEnvironmentChecker::defaultTransportFactory);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建可注入 transport 工厂的检测器。
|
||||
*
|
||||
* @param probeEnabled 是否启用 MCP 连接探测
|
||||
* @param transportFactoryProvider transport 工厂提供器
|
||||
*/
|
||||
McpEnvironmentChecker(boolean probeEnabled,
|
||||
Function<String, McpTransportFactory> transportFactoryProvider) {
|
||||
this.probeEnabled = probeEnabled;
|
||||
this.transportFactoryProvider = transportFactoryProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测 MCP 配置。
|
||||
*
|
||||
* @param configJson MCP 配置 JSON
|
||||
* @return 检测结果
|
||||
*/
|
||||
public McpEnvironmentCheckResult check(String configJson) {
|
||||
McpEnvironmentCheckResult result = new McpEnvironmentCheckResult();
|
||||
if (configJson == null || configJson.isBlank()) {
|
||||
result.setOverallStatus(McpCheckStatus.FAILED);
|
||||
result.addServer(failedServer("config", null, "configJson", "MCP 配置 JSON 不能为空", null));
|
||||
return result;
|
||||
}
|
||||
|
||||
McpConfig config;
|
||||
try {
|
||||
config = JSON.parseObject(configJson, McpConfig.class);
|
||||
} catch (Exception error) {
|
||||
result.setOverallStatus(McpCheckStatus.FAILED);
|
||||
result.addServer(failedServer("config", null, "json", "MCP 配置 JSON 格式错误", sanitize(error)));
|
||||
return result;
|
||||
}
|
||||
|
||||
if (config == null || config.getMcpServers() == null || config.getMcpServers().isEmpty()) {
|
||||
result.setOverallStatus(McpCheckStatus.FAILED);
|
||||
result.addServer(failedServer("config", null, "mcpServers", "mcpServers 不能为空", null));
|
||||
return result;
|
||||
}
|
||||
|
||||
for (Map.Entry<String, McpConfig.ServerSpec> entry : config.getMcpServers().entrySet()) {
|
||||
result.addServer(checkServer(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private McpServerCheckResult checkServer(String serverName, McpConfig.ServerSpec spec) {
|
||||
McpServerCheckResult result = new McpServerCheckResult();
|
||||
result.setServerName(serverName);
|
||||
result.setTransport(transport(spec));
|
||||
|
||||
if (serverName == null || serverName.isBlank()) {
|
||||
result.addCheck(McpCheckItem.of("serverName", McpCheckStatus.FAILED,
|
||||
"MCP 服务名称不能为空", null));
|
||||
}
|
||||
if (spec == null) {
|
||||
result.addCheck(McpCheckItem.of("server", McpCheckStatus.FAILED,
|
||||
"MCP 服务配置不能为空", null));
|
||||
return result;
|
||||
}
|
||||
|
||||
String transport = transport(spec);
|
||||
result.setTransport(transport);
|
||||
if (!SUPPORTED_TRANSPORTS.contains(transport)) {
|
||||
result.addCheck(McpCheckItem.of("transport", McpCheckStatus.FAILED,
|
||||
"不支持的 MCP 传输类型", transport));
|
||||
return result;
|
||||
}
|
||||
result.addCheck(McpCheckItem.of("transport", McpCheckStatus.SUCCESS,
|
||||
"MCP 传输类型可用", transport));
|
||||
|
||||
Map<String, String> resolvedEnv = resolveEnv(spec.getEnv(), result);
|
||||
if ("stdio".equals(transport)) {
|
||||
validateStdio(spec, result);
|
||||
} else {
|
||||
validateHttp(spec, result);
|
||||
}
|
||||
|
||||
if (probeEnabled && result.getStatus() != McpCheckStatus.FAILED) {
|
||||
probe(serverName, spec, resolvedEnv, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void validateStdio(McpConfig.ServerSpec spec, McpServerCheckResult result) {
|
||||
if (spec.getCommand() == null || spec.getCommand().isBlank()) {
|
||||
result.addCheck(McpCheckItem.of("command", McpCheckStatus.FAILED,
|
||||
"stdio MCP 必须配置 command", null));
|
||||
return;
|
||||
}
|
||||
result.addCheck(checkCommand(spec.getCommand()));
|
||||
}
|
||||
|
||||
private void validateHttp(McpConfig.ServerSpec spec, McpServerCheckResult result) {
|
||||
if (spec.getUrl() == null || spec.getUrl().isBlank()) {
|
||||
result.addCheck(McpCheckItem.of("url", McpCheckStatus.FAILED,
|
||||
"HTTP MCP 必须配置 url", null));
|
||||
return;
|
||||
}
|
||||
result.addCheck(McpCheckItem.of("url", McpCheckStatus.SUCCESS,
|
||||
"MCP 连接地址已配置", spec.getUrl()));
|
||||
}
|
||||
|
||||
private McpCheckItem checkCommand(String command) {
|
||||
String executable = executableName(command);
|
||||
boolean known = KNOWN_VERSION_COMMANDS.contains(executable);
|
||||
ProcessBuilder builder = known
|
||||
? new ProcessBuilder(command, "--version")
|
||||
: new ProcessBuilder(command);
|
||||
try {
|
||||
Process process = builder.redirectErrorStream(true).start();
|
||||
boolean finished = process.waitFor(COMMAND_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
|
||||
if (!finished) {
|
||||
process.destroyForcibly();
|
||||
return McpCheckItem.of("command", McpCheckStatus.SUCCESS,
|
||||
command + " 可启动", "版本检测超时,已终止检测进程");
|
||||
}
|
||||
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim();
|
||||
if (process.exitValue() == 0 || !known) {
|
||||
return McpCheckItem.of("command", McpCheckStatus.SUCCESS,
|
||||
command + " 可用", firstLine(output));
|
||||
}
|
||||
return McpCheckItem.of("command", McpCheckStatus.WARNING,
|
||||
command + " 可启动但返回非零状态", firstLine(output));
|
||||
} catch (Exception error) {
|
||||
return McpCheckItem.of("command", McpCheckStatus.FAILED,
|
||||
"容器内未找到命令:" + command, sanitize(error));
|
||||
}
|
||||
}
|
||||
|
||||
private void probe(String serverName,
|
||||
McpConfig.ServerSpec spec,
|
||||
Map<String, String> resolvedEnv,
|
||||
McpServerCheckResult result) {
|
||||
CloseableTransport transport = null;
|
||||
McpSyncClient client = null;
|
||||
try {
|
||||
transport = transportFactoryProvider.apply(spec.getTransport()).create(spec, resolvedEnv);
|
||||
client = McpClient.sync(transport.getTransport())
|
||||
.requestTimeout(MCP_REQUEST_TIMEOUT)
|
||||
.build();
|
||||
client.initialize();
|
||||
McpSchema.ListToolsResult toolsResult = client.listTools();
|
||||
int toolCount = toolsResult == null || toolsResult.tools() == null ? 0 : toolsResult.tools().size();
|
||||
result.setToolCount(toolCount);
|
||||
if (toolCount == 0) {
|
||||
result.addCheck(McpCheckItem.of("tools", McpCheckStatus.WARNING,
|
||||
"MCP 已连接,但没有发现工具", null));
|
||||
} else {
|
||||
result.addCheck(McpCheckItem.of("tools", McpCheckStatus.SUCCESS,
|
||||
"MCP 工具列表获取成功", String.valueOf(toolCount)));
|
||||
}
|
||||
} catch (Exception error) {
|
||||
log.debug("MCP check failed for server: {}", serverName, error);
|
||||
result.addCheck(McpCheckItem.of("connection", McpCheckStatus.FAILED,
|
||||
"MCP 初始化或工具发现失败", sanitize(error)));
|
||||
} finally {
|
||||
closeQuietly(client);
|
||||
closeQuietly(transport);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> resolveEnv(Map<String, String> env, McpServerCheckResult result) {
|
||||
Map<String, String> resolved = new HashMap<>();
|
||||
if (env == null || env.isEmpty()) {
|
||||
result.addCheck(McpCheckItem.of("env", McpCheckStatus.SUCCESS,
|
||||
"未配置额外环境变量", null));
|
||||
return resolved;
|
||||
}
|
||||
for (Map.Entry<String, String> entry : env.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
String value = entry.getValue();
|
||||
if (value != null && value.startsWith("${input:") && value.endsWith("}")) {
|
||||
String inputId = value.substring("${input:".length(), value.length() - 1);
|
||||
String resolvedValue = System.getProperty("mcp.input." + inputId);
|
||||
if (resolvedValue == null || resolvedValue.isBlank()) {
|
||||
result.addCheck(McpCheckItem.of("env", McpCheckStatus.FAILED,
|
||||
"环境变量未解析:" + key, "input:" + inputId));
|
||||
continue;
|
||||
}
|
||||
resolved.put(key, resolvedValue);
|
||||
result.addCheck(McpCheckItem.of("env", McpCheckStatus.SUCCESS,
|
||||
"环境变量已解析:" + key, "input:" + inputId));
|
||||
continue;
|
||||
}
|
||||
resolved.put(key, value);
|
||||
result.addCheck(McpCheckItem.of("env", McpCheckStatus.SUCCESS,
|
||||
"环境变量已配置:" + key, null));
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private McpServerCheckResult failedServer(String serverName,
|
||||
String transport,
|
||||
String name,
|
||||
String message,
|
||||
String detail) {
|
||||
McpServerCheckResult server = new McpServerCheckResult();
|
||||
server.setServerName(serverName);
|
||||
server.setTransport(transport);
|
||||
server.addCheck(McpCheckItem.of(name, McpCheckStatus.FAILED, message, detail));
|
||||
return server;
|
||||
}
|
||||
|
||||
private static McpTransportFactory defaultTransportFactory(String transportType) {
|
||||
return switch (transport(transportType)) {
|
||||
case "stdio" -> new StdioTransportFactory();
|
||||
case "http-sse" -> new HttpSseTransportFactory();
|
||||
case "http-stream" -> new HttpStreamTransportFactory();
|
||||
default -> throw new IllegalArgumentException("Unsupported transport: " + transportType);
|
||||
};
|
||||
}
|
||||
|
||||
private String transport(McpConfig.ServerSpec spec) {
|
||||
return spec == null ? "stdio" : transport(spec.getTransport());
|
||||
}
|
||||
|
||||
private static String transport(String value) {
|
||||
return value == null || value.isBlank() ? "stdio" : value.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String executableName(String command) {
|
||||
int slash = Math.max(command.lastIndexOf('/'), command.lastIndexOf('\\'));
|
||||
String name = slash >= 0 ? command.substring(slash + 1) : command;
|
||||
return name.endsWith(".cmd") ? name.substring(0, name.length() - 4) : name;
|
||||
}
|
||||
|
||||
private String firstLine(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
int lineEnd = value.indexOf('\n');
|
||||
String line = lineEnd >= 0 ? value.substring(0, lineEnd) : value;
|
||||
return sanitize(line.trim());
|
||||
}
|
||||
|
||||
private String sanitize(Throwable error) {
|
||||
if (error == null) {
|
||||
return null;
|
||||
}
|
||||
String message = error.getMessage();
|
||||
return message == null || message.isBlank() ? error.getClass().getSimpleName() : sanitize(message);
|
||||
}
|
||||
|
||||
private String sanitize(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String sanitized = value.replaceAll("(?i)(api[_-]?key|token|secret|password)=([^\\s,;]+)", "$1=******");
|
||||
return sanitized.length() > 500 ? sanitized.substring(0, 500) : sanitized;
|
||||
}
|
||||
|
||||
private void closeQuietly(AutoCloseable closeable) {
|
||||
if (closeable == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
closeable.close();
|
||||
} catch (Exception error) {
|
||||
log.debug("Failed to close MCP check resource.", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
|
||||
* <p>
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
*/
|
||||
package com.easyagents.mcp.client;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 单个 MCP Server 的检测结果。
|
||||
*/
|
||||
public class McpServerCheckResult {
|
||||
|
||||
private String serverName;
|
||||
private String transport;
|
||||
private McpCheckStatus status = McpCheckStatus.SUCCESS;
|
||||
private int toolCount;
|
||||
private List<McpCheckItem> checks = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 添加检测项并刷新整体状态。
|
||||
*
|
||||
* @param item 检测项
|
||||
*/
|
||||
public void addCheck(McpCheckItem item) {
|
||||
if (item == null) {
|
||||
return;
|
||||
}
|
||||
this.checks.add(item);
|
||||
this.status = McpEnvironmentCheckResult.mergeStatus(this.status, item.getStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Server 名称。
|
||||
*
|
||||
* @return Server 名称
|
||||
*/
|
||||
public String getServerName() {
|
||||
return serverName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Server 名称。
|
||||
*
|
||||
* @param serverName Server 名称
|
||||
*/
|
||||
public void setServerName(String serverName) {
|
||||
this.serverName = serverName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取传输类型。
|
||||
*
|
||||
* @return 传输类型
|
||||
*/
|
||||
public String getTransport() {
|
||||
return transport;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置传输类型。
|
||||
*
|
||||
* @param transport 传输类型
|
||||
*/
|
||||
public void setTransport(String transport) {
|
||||
this.transport = transport;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取检测状态。
|
||||
*
|
||||
* @return 检测状态
|
||||
*/
|
||||
public McpCheckStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置检测状态。
|
||||
*
|
||||
* @param status 检测状态
|
||||
*/
|
||||
public void setStatus(McpCheckStatus status) {
|
||||
this.status = status == null ? McpCheckStatus.SUCCESS : status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具数量。
|
||||
*
|
||||
* @return 工具数量
|
||||
*/
|
||||
public int getToolCount() {
|
||||
return toolCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置工具数量。
|
||||
*
|
||||
* @param toolCount 工具数量
|
||||
*/
|
||||
public void setToolCount(int toolCount) {
|
||||
this.toolCount = Math.max(toolCount, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取检测项。
|
||||
*
|
||||
* @return 检测项
|
||||
*/
|
||||
public List<McpCheckItem> getChecks() {
|
||||
return checks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置检测项。
|
||||
*
|
||||
* @param checks 检测项
|
||||
*/
|
||||
public void setChecks(List<McpCheckItem> checks) {
|
||||
this.checks = checks == null ? new ArrayList<>() : new ArrayList<>(checks);
|
||||
}
|
||||
}
|
||||
@@ -20,46 +20,24 @@ import io.modelcontextprotocol.client.transport.StdioClientTransport;
|
||||
import io.modelcontextprotocol.json.McpJsonMapper;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class StdioTransportFactory implements McpTransportFactory {
|
||||
|
||||
@Override
|
||||
public CloseableTransport create(McpConfig.ServerSpec spec, Map<String, String> resolvedEnv) {
|
||||
// ProcessBuilder pb = new ProcessBuilder();
|
||||
// List<String> args = spec.getArgs();
|
||||
// if (args != null && !args.isEmpty()) {
|
||||
// pb.command(spec.getCommand(), args.toArray(new String[0]));
|
||||
// } else {
|
||||
// pb.command(spec.getCommand());
|
||||
// }
|
||||
// if (!resolvedEnv.isEmpty()) {
|
||||
// pb.environment().putAll(resolvedEnv);
|
||||
// }
|
||||
// pb.redirectErrorStream(true);
|
||||
|
||||
try {
|
||||
// Process process = pb.start();
|
||||
// OutputStream stdin = process.getOutputStream();
|
||||
// InputStream stdout = process.getInputStream();
|
||||
|
||||
// StdioClientTransport transport = new StdioClientTransport(
|
||||
// stdin, stdout, McpJsonMapper.getDefault(), () -> {}
|
||||
// );
|
||||
|
||||
|
||||
// ServerParameters params = ServerParameters.builder("npx")
|
||||
// .args("-y", "@modelcontextprotocol/server-everything")
|
||||
// .build();
|
||||
|
||||
List<String> args = spec.getArgs() == null ? Collections.emptyList() : spec.getArgs();
|
||||
Map<String, String> env = resolvedEnv == null ? Collections.emptyMap() : resolvedEnv;
|
||||
|
||||
ServerParameters parameters = ServerParameters.builder(spec.getCommand())
|
||||
.args(spec.getArgs())
|
||||
.args(args)
|
||||
.env(env)
|
||||
.build();
|
||||
|
||||
StdioClientTransport transport = new StdioClientTransport(parameters, McpJsonMapper.getDefault());
|
||||
|
||||
|
||||
return new CloseableTransport() {
|
||||
@Override
|
||||
public McpClientTransport getTransport() {
|
||||
@@ -73,17 +51,6 @@ public class StdioTransportFactory implements McpTransportFactory {
|
||||
} catch (Exception e) {
|
||||
// ignore
|
||||
}
|
||||
// if (process.isAlive()) {
|
||||
// process.destroy();
|
||||
// try {
|
||||
// if (!process.waitFor(3, TimeUnit.SECONDS)) {
|
||||
// process.destroyForcibly();
|
||||
// }
|
||||
// } catch (InterruptedException ex) {
|
||||
// Thread.currentThread().interrupt();
|
||||
// process.destroyForcibly();
|
||||
// }
|
||||
// }
|
||||
}
|
||||
};
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.easyagents.mcp.client;
|
||||
|
||||
import io.modelcontextprotocol.json.TypeRef;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import org.junit.Test;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* MCP 环境检测测试。
|
||||
*/
|
||||
public class McpEnvironmentCheckerTest {
|
||||
|
||||
@Test
|
||||
public void checkValidStdioConfigWithoutProbe() {
|
||||
String json = """
|
||||
{
|
||||
"mcpServers": {
|
||||
"test": {
|
||||
"transport": "stdio",
|
||||
"command": "java",
|
||||
"args": ["-version"]
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
McpEnvironmentCheckResult result = new McpEnvironmentChecker(false).check(json);
|
||||
|
||||
assertEquals(McpCheckStatus.SUCCESS, result.getOverallStatus());
|
||||
assertEquals("test", result.getServers().get(0).getServerName());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void checkMissingCommand() {
|
||||
String json = """
|
||||
{
|
||||
"mcpServers": {
|
||||
"test": {
|
||||
"transport": "stdio"
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
McpEnvironmentCheckResult result = new McpEnvironmentChecker(false).check(json);
|
||||
|
||||
assertEquals(McpCheckStatus.FAILED, result.getOverallStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void checkMissingHttpUrl() {
|
||||
String json = """
|
||||
{
|
||||
"mcpServers": {
|
||||
"test": {
|
||||
"transport": "http-sse"
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
McpEnvironmentCheckResult result = new McpEnvironmentChecker(false).check(json);
|
||||
|
||||
assertEquals(McpCheckStatus.FAILED, result.getOverallStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void checkUnresolvedInputEnv() {
|
||||
String json = """
|
||||
{
|
||||
"mcpServers": {
|
||||
"test": {
|
||||
"transport": "stdio",
|
||||
"command": "java",
|
||||
"env": {
|
||||
"API_KEY": "${input:api_key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
McpEnvironmentCheckResult result = new McpEnvironmentChecker(false).check(json);
|
||||
|
||||
assertEquals(McpCheckStatus.FAILED, result.getOverallStatus());
|
||||
assertFalse(result.getServers().get(0).getChecks().toString().contains("secret"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void checkInvalidJson() {
|
||||
McpEnvironmentCheckResult result = new McpEnvironmentChecker(false).check("{ invalid json }");
|
||||
|
||||
assertEquals(McpCheckStatus.FAILED, result.getOverallStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closeTransportWhenProbeFailed() {
|
||||
TrackingCloseableTransport closeableTransport = new TrackingCloseableTransport();
|
||||
McpEnvironmentChecker checker = new McpEnvironmentChecker(true, transport -> (spec, resolvedEnv) -> closeableTransport);
|
||||
String json = """
|
||||
{
|
||||
"mcpServers": {
|
||||
"test": {
|
||||
"transport": "stdio",
|
||||
"command": "java"
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
McpEnvironmentCheckResult result = checker.check(json);
|
||||
|
||||
assertEquals(McpCheckStatus.FAILED, result.getOverallStatus());
|
||||
assertTrue(closeableTransport.closed);
|
||||
}
|
||||
|
||||
private static class TrackingCloseableTransport implements CloseableTransport {
|
||||
private boolean closed;
|
||||
|
||||
@Override
|
||||
public McpClientTransport getTransport() {
|
||||
return new FailingClientTransport();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static class FailingClientTransport implements McpClientTransport {
|
||||
@Override
|
||||
public Mono<Void> connect(java.util.function.Function<Mono<McpSchema.JSONRPCMessage>,
|
||||
Mono<McpSchema.JSONRPCMessage>> handler) {
|
||||
return Mono.error(new IllegalStateException("probe failed"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.easyagents.mcp.client;
|
||||
|
||||
import io.modelcontextprotocol.client.transport.ServerParameters;
|
||||
import io.modelcontextprotocol.client.transport.StdioClientTransport;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* Stdio MCP transport factory tests.
|
||||
*/
|
||||
public class StdioTransportFactoryTest {
|
||||
|
||||
@Test
|
||||
public void createWithResolvedEnv() throws Exception {
|
||||
McpConfig.ServerSpec spec = new McpConfig.ServerSpec();
|
||||
spec.setCommand("npx");
|
||||
spec.setArgs(List.of("-y", "test-mcp-server"));
|
||||
|
||||
CloseableTransport closeableTransport = new StdioTransportFactory()
|
||||
.create(spec, Map.of("API_KEY", "resolved-secret"));
|
||||
|
||||
assertTrue(closeableTransport.getTransport() instanceof StdioClientTransport);
|
||||
StdioClientTransport transport = (StdioClientTransport) closeableTransport.getTransport();
|
||||
ServerParameters parameters = extractParameters(transport);
|
||||
|
||||
assertEquals("npx", parameters.getCommand());
|
||||
assertEquals(List.of("-y", "test-mcp-server"), parameters.getArgs());
|
||||
assertEquals("resolved-secret", parameters.getEnv().get("API_KEY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createWithNullArgsAndEnv() throws Exception {
|
||||
McpConfig.ServerSpec spec = new McpConfig.ServerSpec();
|
||||
spec.setCommand("python");
|
||||
spec.setArgs(null);
|
||||
|
||||
CloseableTransport closeableTransport = new StdioTransportFactory().create(spec, null);
|
||||
|
||||
assertTrue(closeableTransport.getTransport() instanceof StdioClientTransport);
|
||||
StdioClientTransport transport = (StdioClientTransport) closeableTransport.getTransport();
|
||||
ServerParameters parameters = extractParameters(transport);
|
||||
|
||||
assertEquals("python", parameters.getCommand());
|
||||
assertEquals(List.of(), parameters.getArgs());
|
||||
}
|
||||
|
||||
private ServerParameters extractParameters(StdioClientTransport transport) throws Exception {
|
||||
Field paramsField = StdioClientTransport.class.getDeclaredField("params");
|
||||
paramsField.setAccessible(true);
|
||||
return (ServerParameters) paramsField.get(transport);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-helloworld</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<version>1.0.0</version>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>17</maven.compiler.release>
|
||||
@@ -17,7 +17,7 @@
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-bom</artifactId>
|
||||
<version>0.0.1</version>
|
||||
<version>1.0.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ import java.util.*;
|
||||
/**
|
||||
* Milvus vector store based on Milvus Java SDK v2.
|
||||
*/
|
||||
public class MilvusVectorStore extends DocumentStore {
|
||||
public class MilvusVectorStore extends DocumentStore implements AutoCloseable {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(MilvusVectorStore.class);
|
||||
private static final long LOAD_TIMEOUT_MS = 30_000L;
|
||||
@@ -546,4 +546,14 @@ public class MilvusVectorStore extends DocumentStore {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
try {
|
||||
client.close(1L);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
LOG.warn("Interrupted while closing Milvus client. uri={}", config.getUri(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2
pom.xml
2
pom.xml
@@ -34,7 +34,7 @@
|
||||
|
||||
<properties>
|
||||
<!-- Easy-Agents Version -->
|
||||
<revision>0.0.1</revision>
|
||||
<revision>1.0.0</revision>
|
||||
<maven.compiler.release>17</maven.compiler.release>
|
||||
<maven-flatten.version>1.3.0</maven-flatten.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
|
||||
Reference in New Issue
Block a user