From be10eabb64b93541de271430c20ec05f2f419a8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Sat, 29 Aug 2026 15:42:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E4=B8=80=E5=BA=93?= =?UTF-8?q?=E4=B8=80=E5=B7=A5=E5=85=B7=E7=9F=A5=E8=AF=86=E6=A3=80=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 编译知识库英文运行名、描述、检索配置和独立 Registration - 统一最终分数阈值并保持模型上下文、检索事件与引用一致 - 完善 AG-UI 知识库检索运行态与完成态展示 --- .../agent/runtime/AgentRunService.java | 177 +++++++++++- .../agent/runtime/AgentRuntimeBundle.java | 18 +- .../agent/runtime/AgentRuntimeCompiler.java | 76 ++++- .../runtime/output/AguiAgentRunOutput.java | 5 +- .../AgentDefinitionCompilerMcpTest.java | 3 + .../AgentRunServiceDraftAndHitlTest.java | 101 ++++++- .../AgentRuntimeCompilerKnowledgeTest.java | 268 ++++++++++++++++++ .../impl/DocumentCollectionServiceImpl.java | 6 +- .../DocumentCollectionServiceImplTest.java | 22 ++ .../views/ai/shared/agent-agui/client.test.ts | 64 +++++ .../src/views/ai/shared/agent-agui/client.ts | 31 +- .../ai/shared/agent-agui/projection.test.ts | 70 +++++ .../views/ai/shared/agent-agui/projection.ts | 10 +- .../src/components/chat-timeline/builder.ts | 25 +- 14 files changed, 832 insertions(+), 44 deletions(-) create mode 100644 easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRuntimeCompilerKnowledgeTest.java diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunService.java index 94954729..d62a815a 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunService.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunService.java @@ -1169,6 +1169,8 @@ public class AgentRunService { StringBuilder answer = new StringBuilder(); ChatAssistantAccumulator assistantAccumulator = new ChatAssistantAccumulator(); LegacyThinkingTagParser legacyThinkingTagParser = new LegacyThinkingTagParser(); + KnowledgeRetrievalStatusTracker knowledgeRetrievalStatusTracker = + new KnowledgeRetrievalStatusTracker(); // 注册 emit 服务 registerEmitterCancellation(requestId, runOutput, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog); @@ -1177,7 +1179,7 @@ public class AgentRunService { if (isAguiCancellationRequested(runOutput)) { handleRuntimeEvent(cancellationEvent("用户已停止生成"), requestId, runOutput, answer, assistantAccumulator, legacyThinkingTagParser, - chatContext, finished, persistChatlog); + knowledgeRetrievalStatusTracker, chatContext, finished, persistChatlog); if (lockHandle != null) { releaseRunLockQuietly(lockHandle, requestId); } @@ -1195,7 +1197,7 @@ public class AgentRunService { request.setAgentDefinition(bundle.getDefinition()); request.setRuntimeContext(runtimeContext); request.setToolInvokers(bundle.getToolInvokers()); - request.setKnowledgeRetrievers(bundle.getKnowledgeRetrievers()); + request.setKnowledgeRegistrations(bundle.getKnowledgeRegistrations()); request.setSessionStore(runtimeSessionStore); request.setMediaResolver(agentMediaService.runtimeResolver(account)); request.getMetadata().put("assistantCode", assistantCode); @@ -1224,6 +1226,7 @@ public class AgentRunService { runRuntimeCallbackSafely( () -> handleRuntimeEvent(event, requestId, runOutput, answer, assistantAccumulator, legacyThinkingTagParser, + knowledgeRetrievalStatusTracker, chatContext, finished, persistChatlog), requestId, runOutput, chatContext, finished, persistChatlog); } @@ -1513,7 +1516,8 @@ public class AgentRunService { AtomicBoolean finished, boolean persistChatlog) { handleRuntimeEvent(event, requestId, runOutput, answer, assistantAccumulator, - new LegacyThinkingTagParser(), chatContext, finished, persistChatlog); + new LegacyThinkingTagParser(), new KnowledgeRetrievalStatusTracker(), + chatContext, finished, persistChatlog); } private void handleRuntimeEvent(AgentRuntimeEvent event, @@ -1525,6 +1529,35 @@ public class AgentRunService { ChatRuntimeContext chatContext, AtomicBoolean finished, boolean persistChatlog) { + handleRuntimeEvent(event, requestId, runOutput, answer, assistantAccumulator, + legacyThinkingTagParser, new KnowledgeRetrievalStatusTracker(), + chatContext, finished, persistChatlog); + } + + /** + * 将单个 Runtime 事件投影到聊天协议,并复用本轮知识库工具状态追踪器。 + * + * @param event Runtime 事件 + * @param requestId 请求 ID + * @param runOutput 运行输出 + * @param answer 回答累积器 + * @param assistantAccumulator Assistant 结构化累积器 + * @param legacyThinkingTagParser 旧思考标签解析器 + * @param knowledgeRetrievalStatusTracker 知识库工具状态追踪器 + * @param chatContext 聊天上下文 + * @param finished 终态仲裁标记 + * @param persistChatlog 是否持久化聊天日志 + */ + private void handleRuntimeEvent(AgentRuntimeEvent event, + String requestId, + AgentRunOutput runOutput, + StringBuilder answer, + ChatAssistantAccumulator assistantAccumulator, + LegacyThinkingTagParser legacyThinkingTagParser, + KnowledgeRetrievalStatusTracker knowledgeRetrievalStatusTracker, + ChatRuntimeContext chatContext, + AtomicBoolean finished, + boolean persistChatlog) { if (event == null || event.getEventType() == null) { return; } @@ -1642,6 +1675,17 @@ public class AgentRunService { return; } Map toolPayload = toolStatus; + if (isKnowledgeToolEvent(event)) { + Map statusPayload = buildKnowledgeRetrievalStatusPayload( + knowledgeRetrievalStatusTracker.update(event)); + LOG.info("Agent runtime knowledge tool call, requestId={}, toolCallId={}, toolName={}", + requestId, event.getToolCallId(), stringValue(toolPayload, "toolName")); + if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, statusPayload)) { + cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, + legacyThinkingTagParser, finished, persistChatlog); + } + return; + } if (!runOutput.emitRuntimeEvent(publicRuntimeEvent(event, toolPayload))) { cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog); @@ -1664,6 +1708,20 @@ public class AgentRunService { } if (event.getEventType() == AgentRuntimeEventType.TOOL_RESULT) { Map toolPayload = toolStatus; + if (isKnowledgeToolEvent(event)) { + Map statusPayload = buildKnowledgeRetrievalStatusPayload( + knowledgeRetrievalStatusTracker.update(event)); + LOG.info("Agent runtime knowledge tool result, requestId={}, toolCallId={}, toolName={}, status={}", + requestId, event.getToolCallId(), stringValue(toolPayload, "toolName"), + stringValue(statusPayload, "status")); + if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, statusPayload)) { + cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, + legacyThinkingTagParser, finished, persistChatlog); + return; + } + legacyThinkingTagParser.reset(); + return; + } LOG.info("Agent runtime tool result, requestId={}, toolCallId={}, toolName={}, status={}", requestId, event.getToolCallId(), stringValue(toolPayload, "toolName"), stringValue(toolPayload, "status")); @@ -1689,10 +1747,7 @@ public class AgentRunService { if (event.getEventType() == AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL) { LOG.info("Agent runtime knowledge retrieval, requestId={}, payload={}, metadata={}", requestId, event.getPayload(), event.getMetadata()); - if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, buildKnowledgeRetrievalStatusPayload(event))) { - cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, - legacyThinkingTagParser, finished, persistChatlog); - } + // 文档摘要事件用于引用与监察;UI 完成态统一以 TOOL_RESULT 为准。 return; } if (event.getEventType() == AgentRuntimeEventType.MEMORY_COMPRESSION_STARTED @@ -1750,6 +1805,10 @@ public class AgentRunService { if (event.getEventType() == AgentRuntimeEventType.FAILED) { emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog); + if (knowledgeRetrievalStatusTracker.failActiveCalls()) { + sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, + buildKnowledgeRetrievalStatusPayload("error")); + } runOutput.emitRuntimeEvent(event); assistantAccumulator.finalizePendingSkillInvocations("FAILED", "技能调用失败"); if (persistChatlog) { @@ -2813,7 +2872,8 @@ public class AgentRunService { Map rawPayload = event.getPayload() == null ? Map.of() : event.getPayload(); Map payload = selectPayload(rawPayload, "name", "status", "success", "toolDisplayName", "toolName", - "skillDisplayName", "skillId"); + "skillDisplayName", "skillId", "toolCategory", + "knowledgeId", "knowledgeName", "knowledgeRuntimeName"); String toolCallId = firstText(event.getToolCallId(), stringValue(rawPayload, "toolCallId")); if (toolCallId != null && !toolCallId.isBlank()) { payload.put("toolCallId", toolCallId); @@ -2949,17 +3009,110 @@ public class AgentRunService { /** * 构建知识库检索状态载荷,确保前端可按稳定 key 合并同一轮状态行。 * - * @param event 知识库检索运行时事件 + * @param status running、done 或 error * @return 知识库检索状态载荷 */ - private Map buildKnowledgeRetrievalStatusPayload(AgentRuntimeEvent event) { + private Map buildKnowledgeRetrievalStatusPayload(String status) { + String normalizedStatus = "running".equals(status) || "error".equals(status) + ? status : "done"; Map payload = new LinkedHashMap<>(); payload.put("statusKey", "knowledge-retrieval"); - payload.put("status", "done"); - payload.put("label", "已检索知识库"); + payload.put("status", normalizedStatus); + payload.put("label", switch (normalizedStatus) { + case "running" -> "正在检索知识库"; + case "error" -> "知识库检索失败"; + default -> "已检索知识库"; + }); return payload; } + /** + * 判断标准工具生命周期事件是否属于知识库工具。 + * + * @param event 运行时工具事件 + * @return 知识库工具事件时为 true + */ + private boolean isKnowledgeToolEvent(AgentRuntimeEvent event) { + String category = stringPayload(event, "toolCategory"); + if ("KNOWLEDGE".equalsIgnoreCase(category)) { + return true; + } + String toolName = firstText(stringPayload(event, "toolName"), stringPayload(event, "name")); + if (toolName == null) { + return false; + } + String normalizedName = toolName.trim().toLowerCase(Locale.ROOT); + return "retrieve_knowledge".equals(normalizedName) + || normalizedName.startsWith("retrieve_knowledge_"); + } + + /** + * 聚合同一批知识库工具调用,避免并行检索中首个结果提前结束 UI 状态。 + */ + static final class KnowledgeRetrievalStatusTracker { + + private final Set activeToolCallIds = new LinkedHashSet<>(); + private boolean failed; + + /** + * 应用一次知识库工具生命周期事件。 + * + * @param event TOOL_CALL 或 TOOL_RESULT 事件 + * @return 聚合后的 running、done 或 error 状态 + */ + String update(AgentRuntimeEvent event) { + String toolCallId = toolCallIdentity(event); + if (event.getEventType() == AgentRuntimeEventType.TOOL_CALL) { + if (activeToolCallIds.isEmpty()) { + failed = false; + } + activeToolCallIds.add(toolCallId); + return "running"; + } + if (event.getEventType() == AgentRuntimeEventType.TOOL_RESULT) { + activeToolCallIds.remove(toolCallId); + failed = failed || !toolSucceeded(event); + if (!activeToolCallIds.isEmpty()) { + return "running"; + } + return failed ? "error" : "done"; + } + throw new IllegalArgumentException("Knowledge status only accepts TOOL_CALL or TOOL_RESULT events."); + } + + /** + * 将运行失败时仍未结束的知识库调用收口为失败。 + * + * @return 存在未结束调用时为 true + */ + boolean failActiveCalls() { + if (activeToolCallIds.isEmpty()) { + return false; + } + activeToolCallIds.clear(); + failed = true; + return true; + } + + private String toolCallIdentity(AgentRuntimeEvent event) { + String toolCallId = event.getToolCallId(); + if (toolCallId == null || toolCallId.isBlank()) { + Object payloadId = event.getPayload() == null ? null : event.getPayload().get("toolCallId"); + toolCallId = payloadId == null ? event.getEventId() : String.valueOf(payloadId); + } + return toolCallId; + } + + private boolean toolSucceeded(AgentRuntimeEvent event) { + Map payload = event.getPayload() == null ? Map.of() : event.getPayload(); + if (Boolean.FALSE.equals(payload.get("success"))) { + return false; + } + Object status = payload.get("status"); + return status == null || !"FAILED".equalsIgnoreCase(String.valueOf(status)); + } + } + /** * 构建不含 Runtime 原始上下文的内存压缩公开状态载荷。 * diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRuntimeBundle.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRuntimeBundle.java index 12ed0fb1..8c9cc2d2 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRuntimeBundle.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRuntimeBundle.java @@ -1,10 +1,12 @@ package tech.easyflow.agent.runtime; import com.easyagents.agent.runtime.AgentDefinition; -import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever; +import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration; import com.easyagents.agent.runtime.tool.AgentToolInvoker; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -14,7 +16,7 @@ public class AgentRuntimeBundle { private AgentDefinition definition; private Map toolInvokers = new LinkedHashMap<>(); - private Map knowledgeRetrievers = new LinkedHashMap<>(); + private List knowledgeRegistrations = new ArrayList<>(); /** * 获取 Agent 定义。 @@ -57,16 +59,18 @@ public class AgentRuntimeBundle { * * @return 知识库检索器 */ - public Map getKnowledgeRetrievers() { - return knowledgeRetrievers; + public List getKnowledgeRegistrations() { + return knowledgeRegistrations; } /** * 设置知识库检索器。 * - * @param knowledgeRetrievers 知识库检索器 + * @param knowledgeRegistrations 知识库运行时绑定 */ - public void setKnowledgeRetrievers(Map knowledgeRetrievers) { - this.knowledgeRetrievers = knowledgeRetrievers == null ? new LinkedHashMap<>() : knowledgeRetrievers; + public void setKnowledgeRegistrations(List knowledgeRegistrations) { + this.knowledgeRegistrations = knowledgeRegistrations == null + ? new ArrayList<>() + : new ArrayList<>(knowledgeRegistrations); } } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRuntimeCompiler.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRuntimeCompiler.java index fe672ad6..f0bddd95 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRuntimeCompiler.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRuntimeCompiler.java @@ -6,9 +6,10 @@ import com.easyagents.agent.runtime.AgentRuntimeContext; import com.easyagents.agent.runtime.event.AgentRuntimeEvent; import com.easyagents.agent.runtime.event.AgentRuntimeEventType; import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument; -import com.easyagents.agent.runtime.knowledge.AgentKnowledgePolicy; +import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration; import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult; import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec; +import com.easyagents.agent.runtime.knowledge.AgentKnowledgeToolNames; import com.easyagents.agent.runtime.memory.AgentMemoryCompressionParameter; import com.easyagents.agent.runtime.memory.AgentMemoryPolicy; import com.easyagents.agent.runtime.memory.AgentMemoryType; @@ -118,11 +119,11 @@ public class AgentRuntimeCompiler { bundle.setDefinition(definition); compileTools(agent, definition, bundle); + compileKnowledge(agent, definition, bundle); if (agentBuiltinToolsConfigResolver != null) { validateBuiltinTools(definition, agentBuiltinToolsConfigResolver.resolvePublishedRuntime(agent.getExecutionConfigJson())); } - compileKnowledge(agent, definition, bundle); return bundle; } @@ -294,7 +295,7 @@ public class AgentRuntimeCompiler { if (config.artifactPublish().enabled()) { specs.add(buildArtifactPublishSpec(config.artifactPublish())); } - assertToolBudget(specs, definition.getMcpSpecs()); + assertToolBudget(specs, definition.getMcpSpecs(), definition.getKnowledgeSpecs().size()); } private void attachBuiltinTools(Agent agent, @@ -510,11 +511,21 @@ public class AgentRuntimeCompiler { return names; } + /** + * 校验内置工具与普通工具、知识库工具及 MCP 工具不存在运行名冲突。 + * + * @param definition 已编译 Agent 定义 + * @param builtinNames 待启用内置工具名称 + * @throws BusinessException 工具名称冲突时抛出 + */ private void assertNoBuiltinNameConflict(AgentDefinition definition, Set builtinNames) { Set existing = new LinkedHashSet<>(); for (AgentToolSpec spec : definition.getToolSpecs()) { existing.add(spec.getName()); } + for (AgentKnowledgeSpec spec : definition.getKnowledgeSpecs()) { + existing.add(AgentKnowledgeToolNames.build(spec.getRuntimeName())); + } for (McpSpec mcp : definition.getMcpSpecs()) { if (mcp.getFrozenToolManifest() != null) { mcp.getFrozenToolManifest().forEach(entry -> existing.add(entry.getName())); @@ -540,6 +551,14 @@ public class AgentRuntimeCompiler { assertToolBudget(toolSpecs, mcpSpecs, 0); } + /** + * 校验最终工具数量和 Schema 大小预算。 + * + * @param toolSpecs 静态 Tool 声明 + * @param mcpSpecs MCP 声明 + * @param additionalToolCount 知识库等额外工具数量 + * @throws BusinessException 超出预算时抛出 + */ private void assertToolBudget(List toolSpecs, List mcpSpecs, int additionalToolCount) { @@ -570,7 +589,7 @@ public class AgentRuntimeCompiler { } } if (toolCount > MAX_RUNTIME_TOOL_COUNT) { - throw new BusinessException("Agent Runtime Tool 数量超过 128 个,请减少直接工具或 Skill 绑定"); + throw new BusinessException("Agent Runtime Tool 数量超过 128 个,请减少工具、知识库或 Skill 绑定"); } if (schemaBytes > MAX_RUNTIME_SCHEMA_BYTES) { throw new BusinessException("Agent Runtime Tool Schema 超过 2 MiB,请减少工具或精简 Schema"); @@ -591,12 +610,27 @@ public class AgentRuntimeCompiler { } } + /** + * 将 EasyFlow 知识库绑定编译为一库一工具所需的声明和 Retriever 绑定。 + * + * @param agent Agent 发布视图 + * @param definition 中立 Agent 定义 + * @param bundle 运行时编译结果 + * @throws BusinessException 知识库不存在、英文运行名非法或工具名冲突时抛出 + */ private void compileKnowledge(Agent agent, AgentDefinition definition, AgentRuntimeBundle bundle) { if (agent.getKnowledgeBindings() == null) { return; } List specs = new ArrayList<>(); - Map retrievers = new LinkedHashMap<>(); + List registrations = new ArrayList<>(); + Set knowledgeToolNames = new LinkedHashSet<>(); + Set existingToolNames = new LinkedHashSet<>(); + definition.getToolSpecs().stream() + .filter(Objects::nonNull) + .map(AgentToolSpec::getName) + .filter(Objects::nonNull) + .forEach(existingToolNames::add); for (AgentKnowledgeBinding binding : agent.getKnowledgeBindings()) { if (!Boolean.TRUE.equals(binding.getEnabled())) { continue; @@ -607,9 +641,9 @@ public class AgentRuntimeCompiler { } AgentKnowledgeSpec spec = new AgentKnowledgeSpec(); spec.setKnowledgeId(binding.getKnowledgeId().toString()); + spec.setRuntimeName(requireKnowledgeRuntimeName(knowledge)); spec.setName(knowledge.getTitle()); spec.setDescription(knowledge.getDescription()); - spec.setRetrievalMode(AgentKnowledgePolicy.AGENTIC); spec.getMetadata().put("knowledgeType", knowledge.getCollectionType()); spec.getMetadata().put("faqCollection", knowledge.isFaqCollection()); Integer limit = intValue(binding.getOptionsJson(), "limit"); @@ -618,11 +652,37 @@ public class AgentRuntimeCompiler { if (threshold != null) { spec.setScoreThreshold(threshold); } + String toolName = AgentKnowledgeToolNames.build(spec.getRuntimeName()); + if (!knowledgeToolNames.add(toolName) || existingToolNames.contains(toolName)) { + throw new BusinessException("Agent 知识库工具运行名冲突:" + toolName); + } specs.add(spec); - retrievers.put(spec.getKnowledgeId(), request -> retrieveKnowledge(binding, request.getQuery(), request.getLimit(), request.getScoreThreshold())); + registrations.add(new AgentKnowledgeRegistration(spec, + request -> retrieveKnowledge(binding, request.getQuery(), request.getLimit(), request.getScoreThreshold()))); } definition.setKnowledgeSpecs(specs); - bundle.setKnowledgeRetrievers(retrievers); + bundle.setKnowledgeRegistrations(registrations); + } + + /** + * 获取并校验知识库英文运行名。 + * + * @param knowledge 知识库发布视图 + * @return 合法英文运行名 + * @throws BusinessException 英文运行名缺失或非法时抛出 + */ + private String requireKnowledgeRuntimeName(DocumentCollection knowledge) { + String runtimeName = knowledge == null ? null : knowledge.getEnglishName(); + try { + AgentKnowledgeToolNames.build(runtimeName); + return runtimeName.trim(); + } catch (RuntimeException exception) { + String knowledgeName = knowledge == null || knowledge.getTitle() == null + ? "未知知识库" + : knowledge.getTitle(); + throw new BusinessException(400, 400, "知识库“" + knowledgeName + + "”的英文名称不能为空,且只能包含字母、数字、下划线和连字符", exception); + } } private AgentKnowledgeRetrievalResult retrieveKnowledge(AgentKnowledgeBinding binding, String query, int limit, double scoreThreshold) { diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/output/AguiAgentRunOutput.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/output/AguiAgentRunOutput.java index d22b574e..68c13552 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/output/AguiAgentRunOutput.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/output/AguiAgentRunOutput.java @@ -18,6 +18,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.UUID; @@ -563,7 +564,9 @@ public final class AguiAgentRunOutput implements AgentRunOutput { } private static boolean isHiddenToolName(String toolName) { - return "retrieve_knowledge".equalsIgnoreCase(toolName) + String normalizedName = toolName == null ? "" : toolName.trim().toLowerCase(Locale.ROOT); + return "retrieve_knowledge".equals(normalizedName) + || normalizedName.startsWith("retrieve_knowledge_") || "context_reload".equalsIgnoreCase(toolName) || "__fragment__".equalsIgnoreCase(toolName); } diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentDefinitionCompilerMcpTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentDefinitionCompilerMcpTest.java index 4c37ee3c..6d989d21 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentDefinitionCompilerMcpTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentDefinitionCompilerMcpTest.java @@ -8,6 +8,7 @@ import org.junit.Test; import tech.easyflow.agent.entity.Agent; import tech.easyflow.agent.entity.AgentToolBinding; import tech.easyflow.agent.enums.AgentToolType; +import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeCompiler; import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler; import tech.easyflow.ai.entity.Mcp; import tech.easyflow.ai.entity.Model; @@ -44,6 +45,8 @@ public class AgentDefinitionCompilerMcpTest { setField(toolCompiler, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper()); setField(toolCompiler, "mcpService", mcpService(mcp)); setField(compiler, "agentToolRuntimeCompiler", toolCompiler); + setField(compiler, "agentSkillRuntimeCompiler", new AgentSkillRuntimeCompiler( + null, toolCompiler, new com.fasterxml.jackson.databind.ObjectMapper())); Agent agent = agent(modelId, mcpId); diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServiceDraftAndHitlTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServiceDraftAndHitlTest.java index c396f42d..adc2367c 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServiceDraftAndHitlTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServiceDraftAndHitlTest.java @@ -476,18 +476,48 @@ public class AgentRunServiceDraftAndHitlTest { } /** - * 验证知识检索状态不会携带命中文档和内部 metadata。 + * 验证知识库工具开始事件会投影为脱敏的检索中状态。 * * @throws Exception 反射调用失败时抛出 */ @Test - public void handleRuntimeEventShouldWhitelistKnowledgeStatusPayload() throws Exception { + public void handleRuntimeEventShouldProjectKnowledgeToolCallAsRunningStatus() throws Exception { AgentRunService service = new AgentRunService(); RecordingChatSseEmitter emitter = new RecordingChatSseEmitter(); - AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL); + AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_CALL); + event.setToolCallId("knowledge-call-1"); + event.getPayload().put("toolCallId", "knowledge-call-1"); + event.getPayload().put("toolName", "retrieve_knowledge_homeinn_faq"); + event.getPayload().put("toolCategory", "KNOWLEDGE"); event.getPayload().put("documents", List.of(Map.of("chunkContent", "private chunk"))); event.getPayload().put("metadata", Map.of("sourceUri", "private://document")); + invoke(service, "handleRuntimeEvent", + runtimeEventParameterTypes(), + event, "request-knowledge", legacyOutput(emitter), new StringBuilder(), + new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false); + + Assert.assertEquals(1, emitter.envelopes.size()); + @SuppressWarnings("unchecked") + Map payload = (Map) emitter.envelopes.get(0).getPayload(); + Assert.assertEquals(Map.of( + "label", "正在检索知识库", + "status", "running", + "statusKey", "knowledge-retrieval"), payload); + } + + /** + * 验证知识库工具结果事件会投影为完成状态。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + public void handleRuntimeEventShouldProjectKnowledgeToolResultAsDoneStatus() throws Exception { + AgentRunService service = new AgentRunService(); + RecordingChatSseEmitter emitter = new RecordingChatSseEmitter(); + AgentRuntimeEvent event = knowledgeToolEvent( + AgentRuntimeEventType.TOOL_RESULT, "knowledge-call-1", true); + invoke(service, "handleRuntimeEvent", runtimeEventParameterTypes(), event, "request-knowledge", legacyOutput(emitter), new StringBuilder(), @@ -502,6 +532,48 @@ public class AgentRunServiceDraftAndHitlTest { "statusKey", "knowledge-retrieval"), payload); } + /** + * 验证文档摘要事件不会抢先把知识库工具状态标记为完成。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + public void handleRuntimeEventShouldNotCompleteKnowledgeStatusFromDocumentEvent() throws Exception { + AgentRunService service = new AgentRunService(); + RecordingChatSseEmitter emitter = new RecordingChatSseEmitter(); + AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL); + event.getPayload().put("documents", List.of(Map.of("chunkContent", "private chunk"))); + + invoke(service, "handleRuntimeEvent", + runtimeEventParameterTypes(), + event, "request-knowledge", legacyOutput(emitter), new StringBuilder(), + new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false); + + Assert.assertTrue(emitter.envelopes.isEmpty()); + } + + /** + * 验证并行知识库调用全部结束后才进入终态,并保留任一调用失败结果。 + */ + @Test + public void knowledgeStatusTrackerShouldAggregateParallelToolCalls() { + AgentRunService.KnowledgeRetrievalStatusTracker tracker = + new AgentRunService.KnowledgeRetrievalStatusTracker(); + AgentRuntimeEvent firstCall = knowledgeToolEvent( + AgentRuntimeEventType.TOOL_CALL, "knowledge-call-1", true); + AgentRuntimeEvent secondCall = knowledgeToolEvent( + AgentRuntimeEventType.TOOL_CALL, "knowledge-call-2", true); + AgentRuntimeEvent firstResult = knowledgeToolEvent( + AgentRuntimeEventType.TOOL_RESULT, "knowledge-call-1", false); + AgentRuntimeEvent secondResult = knowledgeToolEvent( + AgentRuntimeEventType.TOOL_RESULT, "knowledge-call-2", true); + + Assert.assertEquals("running", tracker.update(firstCall)); + Assert.assertEquals("running", tracker.update(secondCall)); + Assert.assertEquals("running", tracker.update(firstResult)); + Assert.assertEquals("error", tracker.update(secondResult)); + } + /** * 验证完成事件不会再次发送正文消息,只用于最终收口。 * @@ -1579,6 +1651,29 @@ public class AgentRunServiceDraftAndHitlTest { } } + /** + * 创建知识库工具生命周期测试事件。 + * + * @param eventType 工具开始或结果事件类型 + * @param toolCallId 工具调用 ID + * @param success 工具结果是否成功 + * @return 知识库工具事件 + */ + private AgentRuntimeEvent knowledgeToolEvent(AgentRuntimeEventType eventType, + String toolCallId, + boolean success) { + AgentRuntimeEvent event = AgentRuntimeEvent.of(eventType); + event.setToolCallId(toolCallId); + event.getPayload().put("toolCallId", toolCallId); + event.getPayload().put("toolName", "retrieve_knowledge_homeinn_faq"); + event.getPayload().put("toolCategory", "KNOWLEDGE"); + if (eventType == AgentRuntimeEventType.TOOL_RESULT) { + event.getPayload().put("success", success); + event.getPayload().put("status", success ? "SUCCESS" : "FAILED"); + } + return event; + } + private Class[] runtimeEventParameterTypes() { return new Class[]{AgentRuntimeEvent.class, String.class, AgentRunOutput.class, StringBuilder.class, ChatAssistantAccumulator.class, diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRuntimeCompilerKnowledgeTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRuntimeCompilerKnowledgeTest.java new file mode 100644 index 00000000..1b38746b --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRuntimeCompilerKnowledgeTest.java @@ -0,0 +1,268 @@ +package tech.easyflow.agent.runtime; + +import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument; +import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration; +import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalRequest; +import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult; +import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec; +import com.easyagents.core.document.Document; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.entity.AgentKnowledgeBinding; +import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeCompiler; +import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler; +import tech.easyflow.ai.entity.Model; +import tech.easyflow.ai.entity.ModelProvider; +import tech.easyflow.ai.rag.KnowledgeRetrievalRequest; +import tech.easyflow.ai.service.DocumentCollectionService; +import tech.easyflow.ai.service.ModelService; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.math.BigInteger; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Agent 知识库一库一工具运行时编译测试。 + */ +public class AgentRuntimeCompilerKnowledgeTest { + + /** + * 验证知识库英文名称、描述和检索配置会编译到中立声明及独立 Retriever。 + * + * @throws Exception 反射注入依赖失败时抛出 + */ + @Test + public void compileShouldBuildOneKnowledgeRegistrationWithEnglishRuntimeName() throws Exception { + AtomicReference capturedRequest = new AtomicReference<>(); + Document document = new Document("如家酒店通常在入住日 14:00 后办理入住。"); + document.setId("chunk-1"); + document.setTitle("如家 FAQ"); + document.setScore(0.92D); + document.addMetadata("documentId", "faq-document-1"); + document.addMetadata("chunkId", "faq-chunk-1"); + AgentRuntimeCompiler compiler = compiler(capturedRequest, List.of(document)); + Agent agent = agent(knowledgeBinding("homeinn_faq", BigInteger.valueOf(20L))); + + AgentRuntimeBundle bundle = compiler.compile(agent); + + Assert.assertEquals(1, bundle.getDefinition().getKnowledgeSpecs().size()); + AgentKnowledgeSpec spec = bundle.getDefinition().getKnowledgeSpecs().get(0); + Assert.assertEquals("homeinn_faq", spec.getRuntimeName()); + Assert.assertEquals("如家 FAQ", spec.getName()); + Assert.assertTrue(spec.getDescription().contains("入住")); + Assert.assertEquals(7, spec.getLimit()); + Assert.assertEquals(0.55D, spec.getScoreThreshold(), 0.0001D); + Assert.assertEquals(1, bundle.getKnowledgeRegistrations().size()); + + AgentKnowledgeRegistration registration = bundle.getKnowledgeRegistrations().get(0); + AgentKnowledgeRetrievalRequest retrievalRequest = new AgentKnowledgeRetrievalRequest(); + retrievalRequest.setQuery("如家几点入住"); + retrievalRequest.setLimit(spec.getLimit()); + retrievalRequest.setScoreThreshold(spec.getScoreThreshold()); + AgentKnowledgeRetrievalResult result = registration.getRetriever().retrieve(retrievalRequest); + + Assert.assertEquals("如家几点入住", capturedRequest.get().getQuery()); + Assert.assertEquals(Integer.valueOf(7), capturedRequest.get().getLimit()); + Assert.assertEquals(Double.valueOf(0.55D), capturedRequest.get().getMinSimilarity()); + Assert.assertEquals("AGENT_KNOWLEDGE", capturedRequest.get().getCallerType()); + Assert.assertEquals(1, result.getDocuments().size()); + AgentKnowledgeDocument mapped = result.getDocuments().get(0); + Assert.assertEquals("faq-document-1", mapped.getDocumentId()); + Assert.assertEquals("faq-chunk-1", mapped.getChunkId()); + Assert.assertEquals(0.92D, mapped.getScore(), 0.0001D); + } + + /** + * 验证缺失知识库英文名称时在发布编译阶段明确失败。 + * + * @throws Exception 反射注入依赖失败时抛出 + */ + @Test + public void compileShouldRejectMissingKnowledgeEnglishName() throws Exception { + AgentRuntimeCompiler compiler = compiler(new AtomicReference<>(), List.of()); + Agent agent = agent(knowledgeBinding(null, BigInteger.valueOf(20L))); + + try { + compiler.compile(agent); + Assert.fail("缺失英文名称时应拒绝编译"); + } catch (BusinessException expected) { + Assert.assertTrue(expected.getMessage().contains("英文名称不能为空")); + } + } + + /** + * 验证多个知识库生成相同工具名时在编译阶段拒绝发布。 + * + * @throws Exception 反射注入依赖失败时抛出 + */ + @Test + public void compileShouldRejectDuplicateKnowledgeToolNames() throws Exception { + AgentRuntimeCompiler compiler = compiler(new AtomicReference<>(), List.of()); + AgentKnowledgeBinding first = knowledgeBinding("homeinn_faq", BigInteger.valueOf(20L)); + AgentKnowledgeBinding second = knowledgeBinding("homeinn_faq", BigInteger.valueOf(21L)); + Agent agent = agent(first, second); + + try { + compiler.compile(agent); + Assert.fail("重复知识库工具名时应拒绝编译"); + } catch (BusinessException expected) { + Assert.assertTrue(expected.getMessage().contains("retrieve_knowledge_homeinn_faq")); + } + } + + /** + * 创建仅含测试模型与知识库服务的运行时编译器。 + * + * @param capturedRequest 检索请求捕获器 + * @param documents 检索服务返回文档 + * @return 已注入依赖的编译器 + * @throws Exception 反射注入失败时抛出 + */ + private AgentRuntimeCompiler compiler(AtomicReference capturedRequest, + List documents) throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + AgentToolRuntimeCompiler toolCompiler = new AgentToolRuntimeCompiler(); + AgentRuntimeCompiler compiler = new AgentRuntimeCompiler(); + setField(compiler, "objectMapper", objectMapper); + setField(compiler, "modelService", modelService(model())); + setField(compiler, "documentCollectionService", documentCollectionService(capturedRequest, documents)); + setField(compiler, "agentToolRuntimeCompiler", toolCompiler); + setField(compiler, "agentSkillRuntimeCompiler", + new AgentSkillRuntimeCompiler(null, toolCompiler, objectMapper)); + return compiler; + } + + /** + * 创建带知识库绑定的 Agent。 + * + * @param bindings 知识库绑定 + * @return Agent 测试对象 + */ + private Agent agent(AgentKnowledgeBinding... bindings) { + Agent agent = new Agent(); + agent.setId(BigInteger.ONE); + agent.setName("如家助手"); + agent.setModelId(BigInteger.TEN); + agent.setKnowledgeBindings(List.of(bindings)); + return agent; + } + + /** + * 创建冻结知识库绑定。 + * + * @param englishName 知识库英文名称 + * @param knowledgeId 知识库 ID + * @return 知识库绑定 + */ + private AgentKnowledgeBinding knowledgeBinding(String englishName, BigInteger knowledgeId) { + AgentKnowledgeBinding binding = new AgentKnowledgeBinding(); + binding.setAgentId(BigInteger.ONE); + binding.setKnowledgeId(knowledgeId); + binding.setRetrievalMode("HYBRID"); + binding.setEnabled(true); + binding.setOptionsJson(Map.of("limit", 7, "scoreThreshold", 0.55D)); + binding.setResourceSnapshot(Map.of( + "id", knowledgeId, + "title", "如家 FAQ", + "description", "如家酒店入住、退房和会员服务常见问题", + "collectionType", "FAQ", + "englishName", englishName == null ? "" : englishName)); + return binding; + } + + /** + * 创建模型服务代理。 + * + * @param model 测试模型 + * @return 模型服务代理 + */ + private ModelService modelService(Model model) { + return (ModelService) Proxy.newProxyInstance( + ModelService.class.getClassLoader(), + new Class[]{ModelService.class}, + (proxy, method, args) -> "getModelInstance".equals(method.getName()) + ? model + : defaultValue(method.getReturnType())); + } + + /** + * 创建知识库服务代理。 + * + * @param capturedRequest 检索请求捕获器 + * @param documents 返回文档 + * @return 知识库服务代理 + */ + private DocumentCollectionService documentCollectionService( + AtomicReference capturedRequest, + List documents) { + return (DocumentCollectionService) Proxy.newProxyInstance( + DocumentCollectionService.class.getClassLoader(), + new Class[]{DocumentCollectionService.class}, + (proxy, method, args) -> { + if ("search".equals(method.getName()) && args != null && args.length == 1 + && args[0] instanceof KnowledgeRetrievalRequest request) { + capturedRequest.set(request); + return documents; + } + return defaultValue(method.getReturnType()); + }); + } + + /** + * 创建可映射为 AgentScope 模型配置的测试模型。 + * + * @return 测试模型 + */ + private Model model() { + ModelProvider provider = new ModelProvider(); + provider.setProviderType("openai"); + provider.setProviderName("OpenAI"); + Model model = new Model(); + model.setId(BigInteger.TEN); + model.setModelProvider(provider); + model.setModelName("gpt-test"); + model.setEndpoint("https://example.com"); + model.setRequestPath("/v1/chat/completions"); + model.setApiKey("test-key"); + return model; + } + + /** + * 返回代理方法所需的默认值。 + * + * @param type 返回类型 + * @return 对应默认值 + */ + private Object defaultValue(Class type) { + if (type == boolean.class) { + return false; + } + if (type == int.class || type == long.class || type == short.class || type == byte.class) { + return 0; + } + if (type == double.class || type == float.class) { + return 0D; + } + return null; + } + + /** + * 反射注入测试依赖。 + * + * @param target 目标对象 + * @param fieldName 字段名称 + * @param value 字段值 + * @throws Exception 字段不存在或不可写时抛出 + */ + private void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImpl.java index a1f91908..74975372 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/DocumentCollectionServiceImpl.java @@ -187,7 +187,7 @@ public class DocumentCollectionServiceImpl extends ServiceImpl formattedDocuments = formatDocuments( searchDocuments, - shouldApplyMinSimilarityFilter(retrievalMode, reranked), + true, minSimilarity, docRecallMaxNum ); @@ -396,10 +396,6 @@ public class DocumentCollectionServiceImpl extends ServiceImpl result = service.formatDocuments( + List.of(lowScore, thresholdScore, highScore), + true, + 0.5F, + 5 + ); + + Assert.assertEquals(2, result.size()); + Assert.assertEquals(highScore.getId(), result.get(0).getId()); + Assert.assertEquals(thresholdScore.getId(), result.get(1).getId()); + } + /** * 验证检索结果会在重排前过滤掉未完成文档,避免高分进行中文档挤占最终名额。 * diff --git a/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.test.ts b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.test.ts index a5675520..7ab6b446 100644 --- a/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.test.ts @@ -2,6 +2,7 @@ import { EventType } from '@ag-ui/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { EasyFlowAguiClient, EasyFlowAguiProjectionError } from './client'; +import { easyFlowAguiCustomEvent } from './custom-events'; import { isRetryableAguiTransportError } from './reconnect'; vi.mock('#/api/request', () => ({ @@ -276,6 +277,69 @@ describe('easyFlowAguiClient', () => { expect(received.at(-1)).toBe(EventType.RUN_FINISHED); }); + it('yields a paint opportunity after knowledge retrieval starts', async () => { + vi.useFakeTimers(); + let paintCallback: FrameRequestCallback | undefined; + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + paintCallback = callback; + return 1; + }), + ); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + sse([ + { runId: 'run-1', threadId: '101', type: EventType.RUN_STARTED }, + { + name: easyFlowAguiCustomEvent.knowledgeRetrievalStatus, + type: EventType.CUSTOM, + value: { + status: 'running', + statusKey: 'knowledge-retrieval', + }, + }, + { + name: easyFlowAguiCustomEvent.knowledgeRetrievalStatus, + type: EventType.CUSTOM, + value: { + status: 'done', + statusKey: 'knowledge-retrieval', + }, + }, + { + runId: 'run-1', + threadId: '101', + type: EventType.RUN_FINISHED, + }, + ]), + ), + ); + const receivedStatuses: string[] = []; + + const runPromise = new EasyFlowAguiClient().run({ + onEvent: (event) => { + if (event.type === EventType.CUSTOM) { + const value = event.value as Record; + receivedStatuses.push(String(value.status)); + } + }, + threadId: '101', + url: '/api/v1/agent/1/agui/run', + userMessage: { content: '几点退房', id: 'user-1', role: 'user' }, + }); + await vi.advanceTimersByTimeAsync(0); + + expect(receivedStatuses).toEqual(['running']); + + paintCallback?.(0); + await vi.advanceTimersByTimeAsync(0); + await runPromise; + + expect(receivedStatuses).toEqual(['running', 'done']); + }); + it('replays a completed run from the server journal after refresh', async () => { vi.stubGlobal( 'fetch', diff --git a/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.ts b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.ts index f77ed95e..06ae6c08 100644 --- a/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.ts +++ b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.ts @@ -5,6 +5,8 @@ import { events } from 'fetch-event-stream'; import { createEventStreamHeaders, resolveApiUrl } from '#/api/request'; +import { easyFlowAguiCustomEvent } from './custom-events'; + export interface EasyFlowAguiRunOptions { forwardedProps?: Record; onCursor?: (cursor: number) => void; @@ -101,6 +103,31 @@ function waitForToolStartPaint(): Promise { }); } +/** + * 判断事件是否开启了需要即时呈现的工具执行状态。 + * + * @param event AG-UI 事件 + * @returns 标准工具开始或知识库检索开始时为 true + */ +function startsVisibleToolExecution(event: AguiEvent) { + if (event.type === EventType.TOOL_CALL_START) { + return true; + } + if ( + event.type !== EventType.CUSTOM || + event.name !== easyFlowAguiCustomEvent.knowledgeRetrievalStatus + ) { + return false; + } + const value = + event.value && + typeof event.value === 'object' && + !Array.isArray(event.value) + ? (event.value as Record) + : {}; + return String(value.status || '').toLowerCase() === 'running'; +} + /** * EasyFlow 的无头 AG-UI 运行客户端。 * @@ -174,7 +201,7 @@ export class EasyFlowAguiClient { ) { terminalReceived = true; } - if (event.type === EventType.TOOL_CALL_START) { + if (startsVisibleToolExecution(event as AguiEvent)) { await waitForToolStartPaint(); } } @@ -233,7 +260,7 @@ export class EasyFlowAguiClient { if (Number.isSafeInteger(cursor) && cursor > 0) { options.onCursor?.(cursor); } - if (event.type === EventType.TOOL_CALL_START) { + if (startsVisibleToolExecution(event as AguiEvent)) { await waitForToolStartPaint(); } } diff --git a/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/projection.test.ts b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/projection.test.ts index 97857135..a20378ff 100644 --- a/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/projection.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/projection.test.ts @@ -336,6 +336,76 @@ describe('aG-UI wire contract and timeline projection', () => { ).toBe(true); }); + it('merges knowledge retrieval tool and status events within one turn', () => { + const items: ChatTimelineItem[] = []; + const state = createAguiTimelineProjectionState(); + const events = [ + { + toolCallId: 'tool-faq-1', + toolCallName: 'retrieve_knowledge_homeinn_faq', + type: EventType.TOOL_CALL_START, + }, + { + name: easyFlowAguiCustomEvent.knowledgeRetrievalStatus, + type: EventType.CUSTOM, + value: { + label: '已检索知识库', + status: 'done', + statusKey: 'knowledge-retrieval', + }, + }, + { + content: 'Retrieved 1 relevant document(s)', + messageId: 'tool-result-faq-1', + role: 'tool', + toolCallId: 'tool-faq-1', + type: EventType.TOOL_CALL_RESULT, + }, + ].map((event) => EventSchemas.parse(event)); + + for (const event of events) { + applyAguiEventToTimeline(items, event, { roundId: 'round-faq' }, state); + } + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + label: '已检索知识库', + roundId: 'round-faq', + status: 'done', + statusKey: 'knowledge-retrieval:round-faq', + type: 'status', + }); + }); + + it('projects failed knowledge retrieval without exposing tool details', () => { + const items: ChatTimelineItem[] = []; + applyAguiEventToTimeline( + items, + EventSchemas.parse({ + name: easyFlowAguiCustomEvent.knowledgeRetrievalStatus, + type: EventType.CUSTOM, + value: { + internalError: 'private stack', + status: 'error', + statusKey: 'knowledge-retrieval', + }, + }), + { roundId: 'round-failed-knowledge' }, + createAguiTimelineProjectionState(), + ); + + expect(items).toEqual([ + expect.objectContaining({ + label: '知识库检索失败', + status: 'error', + statusKey: 'knowledge-retrieval:round-failed-knowledge', + tone: 'danger', + type: 'status', + }), + ]); + expect(JSON.stringify(items)).not.toContain('private stack'); + }); + it('projects Skill invocation status in place through the strict public fields', () => { const items: ChatTimelineItem[] = []; const state = createAguiTimelineProjectionState(); diff --git a/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/projection.ts b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/projection.ts index 6f6ff645..52874084 100644 --- a/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/projection.ts +++ b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/projection.ts @@ -3,6 +3,7 @@ import type { ChatTimelineKnowledgeHit, ChatTimelineMessageItem, ChatTimelineSkillInvocationStatus, + ChatTimelineStatusStatus, ChatTimelineToolStatus, } from '@easyflow/common-ui'; @@ -131,6 +132,13 @@ function asyncToolStatus( return 'running'; } +function knowledgeRetrievalStatus(value: unknown): ChatTimelineStatusStatus { + const status = asText(value).trim().toLowerCase(); + if (status === 'running') return 'running'; + if (status === 'error' || status === 'failed') return 'error'; + return 'done'; +} + function statusKey( payload: Record, options: AguiTimelineProjectionOptions, @@ -275,7 +283,7 @@ function applyCustomEvent( if (event.name === easyFlowAguiCustomEvent.knowledgeRetrievalStatus) { ChatTimelineBuilder.upsertKnowledgeRetrievalStatus( items, - asText(payload.status).toLowerCase() === 'running' ? 'running' : 'done', + knowledgeRetrievalStatus(payload.status), statusKey(payload, options, 'knowledge-retrieval'), turnMetadata, ); diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/builder.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/builder.ts index adbfe002..54a7aa7c 100644 --- a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/builder.ts +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/builder.ts @@ -54,20 +54,29 @@ function isHiddenToolName(toolName?: string) { const normalizedName = normalizeToolName(toolName); return ( normalizedName === 'retrieve_knowledge' || + normalizedName.startsWith('retrieve_knowledge_') || normalizedName === 'context_reload' || normalizedName === '__fragment__' ); } function isKnowledgeRetrievalToolName(toolName?: string) { - return normalizeToolName(toolName) === 'retrieve_knowledge'; + const normalizedName = normalizeToolName(toolName); + return ( + normalizedName === 'retrieve_knowledge' || + normalizedName.startsWith('retrieve_knowledge_') + ); } function isBlankToolName(toolName?: string) { return !normalizeToolName(toolName); } -function knowledgeRetrievalStatusKey(statusKey?: string) { +function knowledgeRetrievalStatusKey(statusKey?: string, roundId?: string) { + const normalizedRoundId = normalizeText(roundId).trim(); + if (normalizedRoundId) { + return `knowledge-retrieval:${normalizedRoundId}`; + } return normalizeText(statusKey).trim() || 'knowledge-retrieval'; } @@ -659,12 +668,18 @@ export const ChatTimelineBuilder = { metadata?: ChatTimelineTurnMetadata, ) { finishAssistantMessage(items, false, metadata?.roundId); + let label = '已检索知识库'; + if (status === 'running') { + label = '正在检索知识库'; + } else if (status === 'error') { + label = '知识库检索失败'; + } upsertStatus(items, { ...metadata, - label: status === 'running' ? '正在检索知识库' : '已检索知识库', + label, status, - statusKey: knowledgeRetrievalStatusKey(statusKey), - tone: 'muted', + statusKey: knowledgeRetrievalStatusKey(statusKey, metadata?.roundId), + tone: status === 'error' ? 'danger' : 'muted', }); },