refactor: 重构升级为有状态 Agent

- 完善 hook 对接机制
- 提供更加明确的调用方式
This commit is contained in:
2026-05-23 21:17:50 +08:00
parent 8356560c26
commit 2b5e701ade
53 changed files with 5108 additions and 2523 deletions

View File

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

View File

@@ -1,16 +1,33 @@
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<>();
/**
@@ -32,7 +49,7 @@ public class AgentToolApprovalResponse {
}
/**
* 返回是否批准执行工具
* 返回是否批准继续执行
*
* @return 批准时为 true
*/
@@ -41,7 +58,7 @@ public class AgentToolApprovalResponse {
}
/**
* 设置是否批准执行工具
* 设置是否批准继续执行
*
* @param approved 批准标记
*/
@@ -68,18 +85,18 @@ 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;

View File

@@ -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);
}

View File

@@ -1,26 +1,34 @@
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);
}

View File

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

View File

@@ -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');
}
}

View File

@@ -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);
}
}

View File

@@ -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());

View File

@@ -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();

View File

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

View File

@@ -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);
}
}

View File

@@ -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())
@@ -156,14 +141,7 @@ public class AgentScopeSessionAdapter implements Session {
/**
* 运行时支撑的 AgentScope 会话键。
*/
private static class RuntimeSessionKey implements SessionKey {
private final String value;
private RuntimeSessionKey(String value) {
this.value = value;
}
private record RuntimeSessionKey(String value) implements SessionKey {
/**
* 将键转换为稳定标识符。
*

View File

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

View File

@@ -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,12 +354,13 @@ 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());
}
@@ -273,35 +404,39 @@ public class AgentScopeToolAdapter {
AgentToolContext context = buildContext(param);
AgentToolResult result = invoker.invoke(input, context);
ToolResultBlock block = toToolResultBlock(param, result);
// 有状态 runtime 中,普通工具结果由 AgentScope 原生 PostActingEvent
// 旁路观察器统一发出;旧 sink 辅助路径没有统一 hook因此仍允许 adapter 兼容发射。
if (emitNormalToolResult || (emitSkillStep && activeSkillBinding() != null)) {
emit(toolResultEvent(block));
}
return block;
}
/**
* 存在 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 +494,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 +503,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 +540,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 +588,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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -14,6 +14,16 @@ public enum AgentRuntimeEventType {
*/
REASONING_DELTA,
/**
* 智能体开始一次模型推理。
*/
REASONING_STARTED,
/**
* 智能体完成一次模型推理。
*/
REASONING_COMPLETED,
/**
* 流式输出内容,用于流式展示聊天内容。
*/
@@ -34,6 +44,16 @@ public enum AgentRuntimeEventType {
*/
KNOWLEDGE_RETRIEVAL,
/**
* 自动上下文压缩已开始。
*/
MEMORY_COMPRESSION_STARTED,
/**
* 自动上下文压缩已完成。
*/
MEMORY_COMPRESSION_COMPLETED,
/**
* 工具执行前需要人工审批。
*/
@@ -64,6 +84,11 @@ public enum AgentRuntimeEventType {
*/
COMPLETED,
/**
* 智能体运行已暂停,等待外部输入后继续。
*/
SUSPENDED,
/**
* 智能体运行失败。
*/

View File

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

View File

@@ -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);
}
}

View File

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

View File

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

View File

@@ -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);
}
}

View File

@@ -0,0 +1,357 @@
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()) {
boolean compressed = autoContextMemory.compressIfNeeded();
List<CompressionEvent> newEvents = newCompressionEvents(autoContextMemory, beforeEventCount);
if (compressed && !newEvents.isEmpty()) {
emitCompressionStarted(compressionCheck);
emitCompressionCompleted(compressionCheck, newEvents);
}
}
// 压缩完毕后自动进行当前的会话
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) {
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", "已整理上下文");
event.getPayload().put("compressed", true);
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;
}
}
}

View File

@@ -0,0 +1,198 @@
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());
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());
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 String approvalPrompt(AgentToolApprovalRequest approvalRequest) {
if (approvalRequest != null
&& approvalRequest.getApprovalPrompt() != null
&& !approvalRequest.getApprovalPrompt().isBlank()) {
return approvalRequest.getApprovalPrompt();
}
return "是否批准执行该工具?";
}
}

View File

@@ -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());
}
}

View File

@@ -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();
}
}

View File

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

View File

@@ -0,0 +1,154 @@
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 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.Map;
/**
* 监听 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;
/**
* 创建工具执行观察器。
*
* @param eventBridge 旁路事件桥
*/
public ToolExecutionObserver(AgentRuntimeEventBridge eventBridge) {
this(eventBridge, null);
}
/**
* 创建工具执行观察器。
*
* @param eventBridge 旁路事件桥
* @param skillContext Skill 上下文,用于跳过由 SkillExecutionObserver 处理的工具
*/
public ToolExecutionObserver(AgentRuntimeEventBridge eventBridge,
AgentSkillRuntimeContext skillContext) {
this.eventBridge = eventBridge;
this.skillContext = skillContext;
}
/**
* 观察工具执行前后事件。
*
* @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()));
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()));
}
eventBridge.emit(runtimeEvent);
}
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;
}
}

View File

@@ -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");
}

View File

@@ -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);
}

View File

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

View File

@@ -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;
}
/**

View File

@@ -31,7 +31,7 @@ public class AgentMemorySnapshot {
}
/**
* 添加one message
* 添加消息
*
* @param message 消息
*/

View File

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

View File

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

View File

@@ -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();
}

View File

@@ -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);
}

View File

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

View File

@@ -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);
}
}
}

View File

@@ -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();
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}

View File

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

View File

@@ -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());
}
}

View File

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

View File

@@ -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();
}

View File

@@ -1,4 +1,4 @@
package com.easyagents.agent.runtime.persistence;
package com.easyagents.agent.runtime.persistence.session;
import com.easyagents.agent.runtime.AgentRuntimeException;

View File

@@ -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());
}
}

View File

@@ -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();
}

View File

@@ -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 是否已激活。
*

View File

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

View File

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