From 4e8640dcaf87e23cbb3b46b468b240f1925b0ee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Wed, 19 Aug 2026 22:13:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20Agent=20=E6=A0=87?= =?UTF-8?q?=E5=87=86=E4=BA=A4=E4=BA=92=E4=B8=8E=E5=AE=89=E5=85=A8=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 接入 AG-UI 运行投影、Turn 时间线和审批隔离 - 增加 Agent Skill 冻结绑定与运行时消费闭环 - 增加受控工作区、内置工具和私有 Artifact 生命周期 --- .gitignore | 1 + Dockerfile | 96 +- .../agent/AgentArtifactController.java | 109 ++ .../controller/agent/AgentController.java | 109 +- .../controller/agent/AgentDetailView.java | 114 ++ .../agent/AgentDraftSaveRequest.java | 109 ++ .../agent/AgentSkillBindingUpdateRequest.java | 84 ++ .../controller/ai/WorkflowController.java | 7 +- .../controller/skill/SkillController.java | 62 + .../vo/SkillToolBindingUpdateRequest.java | 112 ++ .../admin/controller/skill/vo/SkillView.java | 62 +- .../service/agent/AgentSessionService.java | 3 + .../service/ai/ChatWorkspaceService.java | 8 +- .../agent/AgentSkillBindingContractTest.java | 77 ++ .../skill/SkillControllerContractTest.java | 39 + .../agent/AgentSessionServiceTest.java | 7 + .../controller/ai/UcWorkflowController.java | 8 +- .../chat/protocol/sse/ChatSseEmitter.java | 33 + .../runtime/ChatAssistantAccumulator.java | 78 +- .../ChatAssistantAccumulatorArtifactTest.java | 39 + .../impl/XFIleStorageServiceImpl.java | 28 + .../impl/XFIleStorageServiceImplTest.java | 20 + .../easyflow-module-agent/pom.xml | 8 + .../agent/config/AgentBuiltinToolsConfig.java | 112 ++ .../AgentBuiltinToolsConfigResolver.java | 209 ++++ .../agent/config/AgentModuleConfig.java | 4 +- ...AgentShellCommandAvailabilityReporter.java | 68 ++ .../agent/config/AgentShellProperties.java | 62 + .../config/AgentWorkspaceProperties.java | 84 ++ .../agent/distributed/AgentApprovalRoute.java | 46 + .../AgentRuntimeCommandConsumer.java | 23 +- .../AgentRuntimeCommandMessage.java | 19 + .../AgentRuntimeCommandProducer.java | 57 +- .../AgentRuntimeRouteRegistry.java | 62 + .../tech/easyflow/agent/entity/Agent.java | 6 + .../easyflow/agent/entity/AgentArtifact.java | 168 +++ .../agent/entity/AgentSkillBinding.java | 88 ++ .../agent/mapper/AgentArtifactMapper.java | 41 + .../agent/mapper/AgentSkillBindingMapper.java | 10 + .../publish/AgentApprovalSubjectHandler.java | 8 + .../agent/runtime/AgentDraftChatRequest.java | 20 + .../agent/runtime/AgentRunRegistry.java | 172 ++- .../agent/runtime/AgentRunService.java | 813 +++++++++++-- .../agent/runtime/AgentRuntimeCompiler.java | 777 ++++++------ .../agent/runtime/AgentToolHitlPayload.java | 18 +- .../agui/AgentAguiHitlResolveRequest.java | 41 + .../runtime/agui/AgentAguiRunInputMapper.java | 400 +++++++ .../runtime/agui/AgentAguiWireContext.java | 16 + .../AgentArtifactChatSessionExtension.java | 69 ++ .../AgentArtifactCleanupScheduler.java | 136 +++ .../artifact/AgentArtifactObjectStorage.java | 173 +++ .../AgentArtifactOperationException.java | 42 + .../artifact/AgentArtifactService.java | 996 +++++++++++++++ .../runtime/artifact/AgentArtifactStatus.java | 19 + .../runtime/artifact/AgentArtifactView.java | 44 + .../asynctool/PluginAsyncSubTools.java | 7 +- .../event/MySqlAgentRunEventRecorder.java | 1 + .../hitl/AgentHitlPendingServiceImpl.java | 15 +- .../hitl/ToolApprovalInputProjection.java | 99 ++ .../agent/runtime/lock/AgentRunLock.java | 11 + .../agent/runtime/lock/RedisAgentRunLock.java | 8 + .../agent/runtime/output/AgentRunOutput.java | 75 ++ .../runtime/output/AguiAgentRunOutput.java | 583 +++++++++ .../runtime/output/LegacyAgentRunOutput.java | 80 ++ .../skill/AgentSkillRuntimeCompilation.java | 45 + .../skill/AgentSkillRuntimeCompiler.java | 338 ++++++ .../skill/AgentSkillRuntimeProjector.java | 404 +++++++ .../tool/AgentToolRuntimeCompiler.java | 89 +- .../runtime/tool/PluginToolExecutor.java | 27 + .../runtime/tool/WorkflowToolExecutor.java | 24 +- .../AgentWorkspaceCleanupService.java | 151 +++ .../workspace/AgentWorkspaceResolver.java | 227 ++++ .../service/AgentDependencyAccessService.java | 54 +- .../service/AgentOptionQueryService.java | 114 +- .../easyflow/agent/service/AgentService.java | 24 + .../service/AgentSkillBindingService.java | 38 + .../impl/AgentBindingSemanticComparator.java | 142 +++ .../AgentKnowledgeBindingServiceImpl.java | 30 +- .../AgentResourceBindingProviderImpl.java | 24 +- .../agent/service/impl/AgentServiceImpl.java | 135 ++- .../impl/AgentSkillBindingServiceImpl.java | 233 ++++ .../impl/AgentSkillReferenceProvider.java | 69 ++ .../impl/AgentToolBindingServiceImpl.java | 30 +- .../agent/vo/AgentResourceOptionsView.java | 40 +- .../AgentBuiltinToolsConfigResolverTest.java | 134 +++ .../AgentRuntimeCommandConsumerTest.java | 36 + .../AgentApprovalSubjectHandlerTest.java | 2 + .../AgentRunServiceDraftAndHitlTest.java | 346 +++++- .../AgentRuntimeCompilerModelConfigTest.java | 102 ++ .../agui/AgentAguiRunInputMapperTest.java | 223 ++++ ...AgentArtifactChatSessionExtensionTest.java | 76 ++ .../AgentArtifactCleanupSchedulerTest.java | 91 ++ .../artifact/AgentArtifactServiceTest.java | 598 +++++++++ .../WorkflowPluginAsyncSubToolsTest.java | 6 +- .../hitl/AgentHitlPendingServiceImplTest.java | 35 + .../output/AguiAgentRunOutputTest.java | 221 ++++ .../skill/AgentSkillRuntimeCompilerTest.java | 138 +++ .../skill/AgentSkillRuntimeProjectorTest.java | 202 ++++ .../tool/AgentToolRuntimeCompilerTest.java | 51 +- .../AgentWorkspaceCleanupServiceTest.java | 84 ++ .../workspace/AgentWorkspaceResolverTest.java | 86 ++ .../service/AgentOptionQueryServiceTest.java | 60 + .../AgentBindingSemanticComparatorTest.java | 90 ++ .../impl/AgentBindingValidationLockTest.java | 70 +- .../AgentResourceBindingProviderImplTest.java | 35 + .../impl/AgentSkillReferenceProviderTest.java | 66 + .../ai/easyagents/tool/PluginTool.java | 34 +- .../listener/ChainEventListenerForSave.java | 6 + .../AgentWorkflowSnapshotFactory.java | 98 ++ .../ChainDefinitionRepositoryImpl.java | 9 + .../FrozenWorkflowDefinitionRegistry.java | 114 ++ .../ai/mcp/McpConnectionSnapshotFactory.java | 192 +++ .../ai/mcp/McpRuntimeSpecFactory.java | 278 +++++ .../PluginConnectionSnapshotFactory.java | 111 ++ .../AbstractAiResourceLifecycleHandler.java | 9 + .../WorkflowApprovalSubjectHandler.java | 14 +- .../AgentResourceReferenceService.java | 15 + .../service/SkillToolReferenceProvider.java | 21 + .../AgentResourceReferenceServiceImpl.java | 55 +- .../ResourceOfflineImpactServiceImpl.java | 26 +- .../easyflow/ai/vo/OfflineImpactCheckVo.java | 40 + .../AgentWorkflowSnapshotFactoryTest.java | 94 ++ .../WorkflowApprovalSubjectHandlerTest.java | 3 + .../ConnectionSnapshotFactoryTest.java | 122 ++ .../ResourceOfflineImpactServiceImplTest.java | 27 + .../runtime/ChatAssistantAccumulatorTest.java | 69 ++ .../impl/ApprovalQueryServiceImpl.java | 4 +- .../support/ApprovalSnapshotProjection.java | 276 +++++ .../ApprovalQueryServiceImplAccessTest.java | 6 +- .../ApprovalSnapshotProjectionTest.java | 146 +++ .../easyflow-module-chatlog/pom.xml | 6 + .../service/ChatPersistDispatcher.java | 6 +- .../service/ChatRoundOperateService.java | 20 + .../chatlog/service/ChatSessionExtension.java | 50 + .../ChatSessionExtensionDispatcher.java | 70 ++ .../impl/ChatHistoryQueryServiceImpl.java | 22 +- .../impl/ChatRoundOperateServiceImpl.java | 39 + .../impl/ChatSessionCommandServiceImpl.java | 20 +- .../impl/ChatSessionQueryServiceImpl.java | 27 +- .../service/ChatPersistDispatcherTest.java | 66 + .../impl/ChatHistoryQueryServiceImplTest.java | 42 + .../impl/ChatRoundOperateServiceImplTest.java | 29 +- .../ChatSessionCommandServiceImplTest.java | 66 + .../impl/ChatSessionQueryServiceImplTest.java | 23 + .../reporter/ActionLogReporterProperties.java | 1 + .../log/reporter/ResponseCachingFilter.java | 17 +- .../reporter/ResponseCachingFilterTest.java | 62 + .../easyflow-module-skill/pom.xml | 4 + .../tech/easyflow/skill/entity/Skill.java | 12 + .../skill/entity/SkillToolBinding.java | 102 ++ .../easyflow/skill/enums/SkillToolType.java | 34 + .../easyflow/skill/mapper/SkillMapper.java | 4 + .../skill/mapper/SkillToolBindingMapper.java | 10 + .../publish/SkillApprovalSubjectHandler.java | 29 +- .../skill/service/SkillReferenceProvider.java | 18 + .../easyflow/skill/service/SkillService.java | 38 + .../service/SkillToolBindingService.java | 69 ++ .../service/SkillToolOptionQueryService.java | 197 +++ .../service/SkillToolResourceService.java | 90 ++ .../skill/service/impl/SkillServiceImpl.java | 115 +- .../impl/SkillToolBindingServiceImpl.java | 492 ++++++++ .../impl/SkillToolReferenceProviderImpl.java | 91 ++ .../impl/SkillToolResourceServiceImpl.java | 281 +++++ .../skill/vo/SkillMcpToolManifestView.java | 23 + .../skill/vo/SkillToolOptionPage.java | 29 + ...valSubjectHandlerContentReferenceTest.java | 13 +- .../SkillToolOptionQueryServiceTest.java | 128 ++ .../SkillServiceImplSnapshotHashTest.java | 108 ++ .../impl/SkillToolBindingServiceImplTest.java | 200 ++++ .../SkillToolReferenceProviderImplTest.java | 92 ++ .../src/main/resources/application.yml | 21 + .../mysql/V57__mysql_agent_artifact.sql | 37 + easyflow-ui-admin/app/package.json | 1 + easyflow-ui-admin/app/src/api/request.ts | 42 +- .../app/src/components/ai-chat/AiMessage.vue | 3 +- .../components/ai-chat/AiToolApprovalCard.vue | 9 +- .../src/components/ai-chat/mediaApi.test.ts | 80 +- .../app/src/components/ai-chat/mediaApi.ts | 62 + .../app/src/components/ai-chat/types.ts | 3 +- .../src/locales/langs/en-US/aiWorkflow.json | 8 +- .../src/locales/langs/zh-CN/aiWorkflow.json | 8 +- .../adapters/agentTimelineAdapter.test.ts | 538 ++++----- .../adapters/agentTimelineAdapter.ts | 365 +++--- .../agentChatRuntimeManager.test.ts | 327 +++-- .../ai/agent-chat/agentChatRuntimeManager.ts | 183 ++- .../app/src/views/ai/agent-chat/api.ts | 48 +- .../app/src/views/ai/agent-chat/index.vue | 19 +- .../app/src/views/ai/agents/AgentDesigner.vue | 156 ++- .../ai/agents/agentResponsiveLayout.test.ts | 24 + .../app/src/views/ai/agents/api.ts | 57 +- .../src/views/ai/agents/builtin-tools.test.ts | 56 + .../app/src/views/ai/agents/builtin-tools.ts | 106 ++ .../ai/agents/components/AgentBaseForm.vue | 261 +++- .../ai/agents/components/AgentCommandBar.vue | 15 +- .../agents/components/AgentInspectorPanel.vue | 59 +- .../agents/components/AgentSkillInspector.vue | 287 +++++ .../AgentSkillSelectorDialog.test.ts | 105 ++ .../components/AgentSkillSelectorDialog.vue | 397 ++++++ .../ai/agents/components/AgentTryoutPanel.vue | 25 +- .../agent-studio/AgentStudioNode.vue | 43 +- .../agents/components/agent-studio/types.ts | 4 +- .../agent-studio/useAgentStudioModel.test.ts | 44 +- .../agent-studio/useAgentStudioModel.ts | 95 +- .../composables/useAgentDesignerState.test.ts | 111 ++ .../composables/useAgentDesignerState.ts | 236 +++- .../useAgentTryoutRawRounds.test.ts | 608 ++++------ .../composables/useAgentTryoutRawRounds.ts | 685 +++-------- .../composables/useAgentTryoutStream.test.ts | 182 +++ .../composables/useAgentTryoutStream.ts | 313 ++--- .../app/src/views/ai/agents/types.ts | 55 +- .../shared/agent-agui/artifact-projection.ts | 84 ++ .../views/ai/shared/agent-agui/client.test.ts | 135 +++ .../src/views/ai/shared/agent-agui/client.ts | 127 ++ .../ai/shared/agent-agui/custom-events.ts | 12 + .../ai/shared/agent-agui/projection.test.ts | 485 ++++++++ .../views/ai/shared/agent-agui/projection.ts | 438 +++++++ .../app/src/views/ai/shared/offline-impact.ts | 7 +- .../src/views/ai/skill/SkillDetail.test.ts | 3 + .../app/src/views/ai/skill/SkillDetail.vue | 39 +- .../ai/skill/SkillToolBindingDialog.test.ts | 201 ++++ .../views/ai/skill/SkillToolBindingDialog.vue | 1066 +++++++++++++++++ .../app/src/views/ai/skill/api.ts | 43 + .../src/views/ai/skill/skill-tool-api.test.ts | 81 ++ .../app/src/views/ai/skill/types.ts | 51 + .../src/views/ai/workflow/WorkflowList.vue | 58 +- .../@core/base/icons/src/local-icons.ts | 5 + .../packages/@core/base/icons/src/lucide.ts | 6 + .../chat-timeline/ChatArtifactAttachment.vue | 265 ++++ .../components/chat-timeline/ChatTimeline.vue | 175 ++- .../chat-timeline/ChatTimelineItem.vue | 8 + .../chat-timeline/ChatTimelineStatusRow.vue | 68 +- .../chat-timeline/ChatTimelineTurn.vue | 523 ++++++++ .../__tests__/ChatArtifactAttachment.test.ts | 65 + .../__tests__/ChatTimelineStatusRow.test.ts | 76 +- .../__tests__/ChatTimelineTurn.test.ts | 464 +++++++ .../chat-timeline/__tests__/builder.test.ts | 93 +- .../src/components/chat-timeline/builder.ts | 358 +++++- .../src/components/chat-timeline/index.ts | 6 + .../src/components/chat-timeline/types.ts | 53 +- easyflow-ui-admin/pnpm-lock.yaml | 82 ++ pom.xml | 5 + 241 files changed, 24382 insertions(+), 2777 deletions(-) create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentArtifactController.java create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentDetailView.java create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentDraftSaveRequest.java create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentSkillBindingUpdateRequest.java create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillToolBindingUpdateRequest.java create mode 100644 easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/agent/AgentSkillBindingContractTest.java create mode 100644 easyflow-commons/easyflow-common-chat-protocol/src/test/java/tech/easyflow/core/runtime/ChatAssistantAccumulatorArtifactTest.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentBuiltinToolsConfig.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentBuiltinToolsConfigResolver.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentShellCommandAvailabilityReporter.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentShellProperties.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentWorkspaceProperties.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentApprovalRoute.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentArtifact.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentSkillBinding.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentArtifactMapper.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentSkillBindingMapper.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/agui/AgentAguiHitlResolveRequest.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/agui/AgentAguiRunInputMapper.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/agui/AgentAguiWireContext.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactChatSessionExtension.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactCleanupScheduler.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactObjectStorage.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactOperationException.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactService.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactStatus.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactView.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/ToolApprovalInputProjection.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/output/AgentRunOutput.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/output/AguiAgentRunOutput.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/output/LegacyAgentRunOutput.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/skill/AgentSkillRuntimeCompilation.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/skill/AgentSkillRuntimeCompiler.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/skill/AgentSkillRuntimeProjector.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/workspace/AgentWorkspaceCleanupService.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/workspace/AgentWorkspaceResolver.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentSkillBindingService.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentBindingSemanticComparator.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentSkillBindingServiceImpl.java create mode 100644 easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentSkillReferenceProvider.java create mode 100644 easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/config/AgentBuiltinToolsConfigResolverTest.java create mode 100644 easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/agui/AgentAguiRunInputMapperTest.java create mode 100644 easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/artifact/AgentArtifactChatSessionExtensionTest.java create mode 100644 easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/artifact/AgentArtifactCleanupSchedulerTest.java create mode 100644 easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/artifact/AgentArtifactServiceTest.java create mode 100644 easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/output/AguiAgentRunOutputTest.java create mode 100644 easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/skill/AgentSkillRuntimeCompilerTest.java create mode 100644 easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/skill/AgentSkillRuntimeProjectorTest.java create mode 100644 easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/workspace/AgentWorkspaceCleanupServiceTest.java create mode 100644 easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/workspace/AgentWorkspaceResolverTest.java create mode 100644 easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/AgentOptionQueryServiceTest.java create mode 100644 easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentBindingSemanticComparatorTest.java create mode 100644 easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentSkillReferenceProviderTest.java create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/repository/AgentWorkflowSnapshotFactory.java create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/repository/FrozenWorkflowDefinitionRegistry.java create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mcp/McpConnectionSnapshotFactory.java create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mcp/McpRuntimeSpecFactory.java create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/PluginConnectionSnapshotFactory.java create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/SkillToolReferenceProvider.java create mode 100644 easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/repository/AgentWorkflowSnapshotFactoryTest.java create mode 100644 easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/security/ConnectionSnapshotFactoryTest.java create mode 100644 easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/support/ApprovalSnapshotProjection.java create mode 100644 easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/support/ApprovalSnapshotProjectionTest.java create mode 100644 easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatSessionExtension.java create mode 100644 easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatSessionExtensionDispatcher.java create mode 100644 easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/ChatPersistDispatcherTest.java create mode 100644 easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatHistoryQueryServiceImplTest.java create mode 100644 easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatSessionCommandServiceImplTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillToolBinding.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillToolType.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillToolBindingMapper.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillReferenceProvider.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillToolBindingService.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillToolOptionQueryService.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillToolResourceService.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillToolBindingServiceImpl.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillToolReferenceProviderImpl.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillToolResourceServiceImpl.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/vo/SkillMcpToolManifestView.java create mode 100644 easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/vo/SkillToolOptionPage.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/SkillToolOptionQueryServiceTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplSnapshotHashTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillToolBindingServiceImplTest.java create mode 100644 easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillToolReferenceProviderImplTest.java create mode 100644 easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V57__mysql_agent_artifact.sql create mode 100644 easyflow-ui-admin/app/src/views/ai/agents/agentResponsiveLayout.test.ts create mode 100644 easyflow-ui-admin/app/src/views/ai/agents/builtin-tools.test.ts create mode 100644 easyflow-ui-admin/app/src/views/ai/agents/builtin-tools.ts create mode 100644 easyflow-ui-admin/app/src/views/ai/agents/components/AgentSkillInspector.vue create mode 100644 easyflow-ui-admin/app/src/views/ai/agents/components/AgentSkillSelectorDialog.test.ts create mode 100644 easyflow-ui-admin/app/src/views/ai/agents/components/AgentSkillSelectorDialog.vue create mode 100644 easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutStream.test.ts create mode 100644 easyflow-ui-admin/app/src/views/ai/shared/agent-agui/artifact-projection.ts create mode 100644 easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.test.ts create mode 100644 easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.ts create mode 100644 easyflow-ui-admin/app/src/views/ai/shared/agent-agui/custom-events.ts create mode 100644 easyflow-ui-admin/app/src/views/ai/shared/agent-agui/projection.test.ts create mode 100644 easyflow-ui-admin/app/src/views/ai/shared/agent-agui/projection.ts create mode 100644 easyflow-ui-admin/app/src/views/ai/skill/SkillToolBindingDialog.test.ts create mode 100644 easyflow-ui-admin/app/src/views/ai/skill/SkillToolBindingDialog.vue create mode 100644 easyflow-ui-admin/app/src/views/ai/skill/skill-tool-api.test.ts create mode 100644 easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatArtifactAttachment.vue create mode 100644 easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineTurn.vue create mode 100644 easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatArtifactAttachment.test.ts create mode 100644 easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineTurn.test.ts diff --git a/.gitignore b/.gitignore index 833c8b5e..94979b3a 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ build/ .DS_Store /.logs/ /logs/ +/agent-workspaces/ /.idea/ .logs .idea diff --git a/Dockerfile b/Dockerfile index e49e93e6..2e1e946b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # 后端构建脚本 -FROM --platform=linux/amd64 swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/eclipse-temurin:17-jre +FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/eclipse-temurin:17-jre ENV LANG=C.UTF-8 ENV LC_ALL=C.UTF-8 @@ -9,12 +9,15 @@ ENV EASYFLOW_JAR_PATH=/app/artifacts/easyflow.jar ENV EASYFLOW_CONFIG_PATH=file:/app/application.yml ENV EASYFLOW_LOG_FILE=/app/logs/app.log ENV EASYFLOW_JAR_RESTART_GRACE_SECONDS=30 -ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com -ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple -ENV PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn +ENV NPM_CONFIG_REGISTRY=https://registry.npmjs.org +ENV PIP_INDEX_URL=https://pypi.org/simple +ENV PYTHONPATH=/opt/easyflow/python-packages +ENV NODE_PATH=/app/node_modules WORKDIR /app +ARG DEBIAN_FRONTEND=noninteractive + RUN useradd --system --create-home easyflow && \ apt-get update && \ apt-get install -y --no-install-recommends \ @@ -29,21 +32,100 @@ RUN useradd --system --create-home easyflow && \ rm -f /tmp/nodesource.gpg.key && \ apt-get update && \ apt-get install -y --no-install-recommends \ + coreutils \ + diffutils \ + file \ + findutils \ + fontconfig \ + fonts-liberation2 \ + fonts-noto-cjk \ + gawk \ + grep \ + gzip \ inotify-tools \ + jq \ + libdigest-sha-perl \ + libreoffice-calc \ + libreoffice-impress \ + libreoffice-writer \ nodejs \ + pandoc \ + poppler-utils \ + procps \ python3 \ python3-pip \ python3-venv \ + qpdf \ + ripgrep \ + sed \ + tar \ + tree \ + unzip \ + util-linux \ + zip \ tini && \ + rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /etc/pip "${PYTHONPATH}" /opt/easyflow/node-runtime && \ ln -sf /usr/bin/python3 /usr/local/bin/python && \ ln -sf /usr/bin/pip3 /usr/local/bin/pip && \ npm config set registry "${NPM_CONFIG_REGISTRY}" && \ printf "registry=%s\n" "${NPM_CONFIG_REGISTRY}" > /etc/npmrc && \ npm install -g pnpm@10.17.1 && \ pnpm config set registry "${NPM_CONFIG_REGISTRY}" && \ - mkdir -p /etc/pip && \ - printf "[global]\nindex-url = %s\ntrusted-host = %s\n" "${PIP_INDEX_URL}" "${PIP_TRUSTED_HOST}" > /etc/pip.conf && \ - rm -rf /var/lib/apt/lists/* && \ + printf "[global]\nindex-url = %s\n" "${PIP_INDEX_URL}" > /etc/pip.conf + +RUN python3 -m pip install --no-cache-dir --target "${PYTHONPATH}" \ + python-docx==1.2.0 \ + python-pptx==1.0.2 \ + openpyxl==3.1.5 \ + xlsxwriter==3.2.9 \ + lxml==6.1.1 \ + defusedxml==0.7.1 \ + pillow==12.3.0 \ + pypdf==6.16.1 \ + pdfplumber==0.11.10 \ + pdf2image==1.17.0 \ + reportlab==5.0.0 \ + numpy==2.5.2 \ + pandas==3.0.5 \ + matplotlib==3.11.1 \ + seaborn==0.13.2 \ + pyyaml==6.0.3 \ + jsonschema==4.26.0 \ + jinja2==3.1.6 \ + beautifulsoup4==4.15.0 \ + pydantic==2.13.4 \ + python-dateutil==2.9.0.post0 \ + tabulate==0.10.0 \ + markdown==3.10.3 \ + charset-normalizer==3.5.1 \ + tenacity==9.1.4 && \ + PYTHONPATH="${PYTHONPATH}" python3 -c "import bs4, defusedxml, docx, jsonschema, lxml, matplotlib, numpy, openpyxl, pandas, pdfplumber, PIL, pptx, pydantic, pypdf, reportlab, seaborn, yaml" + +RUN npm install --prefix /opt/easyflow/node-runtime --omit=dev --no-audit --no-fund --save-exact \ + docx@9.7.1 \ + pptxgenjs@4.0.1 \ + sharp@0.35.3 \ + pdf-lib@1.17.1 \ + pdfjs-dist@6.2.108 \ + zod@4.4.3 \ + ajv@8.20.0 \ + yaml@2.9.0 \ + csv-parse@7.0.2 \ + csv-stringify@6.8.3 \ + fast-xml-parser@5.10.1 \ + marked@18.0.9 \ + sanitize-html@2.17.7 \ + cheerio@1.2.0 \ + dayjs@1.11.21 \ + handlebars@4.7.9 \ + jszip@3.10.1 && \ + ln -s /opt/easyflow/node-runtime/node_modules /app/node_modules && \ + node -e "for (const name of ['docx','pptxgenjs','sharp','pdf-lib','pdfjs-dist/package.json','zod','ajv','yaml','csv-parse','csv-stringify','fast-xml-parser','marked','sanitize-html','cheerio','dayjs','handlebars','jszip']) require.resolve(name)" && \ + npm cache clean --force + +RUN fc-cache -f && \ mkdir -p /app/logs /app/artifacts /app/data && \ chown -R easyflow:easyflow /app diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentArtifactController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentArtifactController.java new file mode 100644 index 00000000..676f9811 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentArtifactController.java @@ -0,0 +1,109 @@ +package tech.easyflow.admin.controller.agent; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.annotation.SaMode; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import tech.easyflow.agent.entity.AgentArtifact; +import tech.easyflow.agent.runtime.artifact.AgentArtifactService; +import tech.easyflow.agent.runtime.artifact.AgentArtifactView; +import tech.easyflow.common.domain.Result; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.io.InputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; + +/** + * Agent Artifact 安全元数据与鉴权下载控制器。 + */ +@RestController +@RequestMapping("/api/v1/agent/artifacts") +public class AgentArtifactController { + + private final AgentArtifactService artifactService; + + /** + * 创建 Artifact 控制器。 + * + * @param artifactService Artifact 服务 + */ + public AgentArtifactController(AgentArtifactService artifactService) { + this.artifactService = artifactService; + } + + /** + * 查询一个已鉴权 Artifact 的安全元数据。 + * + * @param artifactId 稳定 Artifact ID + * @return 安全元数据 + */ + @GetMapping("/{artifactId}") + @SaCheckPermission(value = {"/api/v1/agent/session/query", "/api/v1/agent/save"}, mode = SaMode.OR) + public Result metadata(@PathVariable String artifactId, + @RequestParam BigInteger agentId, + @RequestParam String mode, + @RequestParam(required = false) BigInteger sessionId, + @RequestParam(required = false) String runtimeSessionId) { + AgentArtifact artifact = artifactService.requireDownload( + artifactId, requireAccount(), agentId, mode, sessionId, runtimeSessionId); + return Result.ok(artifactService.toView(artifact)); + } + + /** + * 通过后端鉴权代理流式下载私有 Artifact。 + * + * @param artifactId 稳定 Artifact ID + * @return 私有流式响应 + */ + @GetMapping("/{artifactId}/content") + @SaCheckPermission(value = {"/api/v1/agent/session/query", "/api/v1/agent/save"}, mode = SaMode.OR) + public ResponseEntity content(@PathVariable String artifactId, + @RequestParam BigInteger agentId, + @RequestParam String mode, + @RequestParam(required = false) BigInteger sessionId, + @RequestParam(required = false) String runtimeSessionId) { + AgentArtifact artifact = artifactService.requireDownload( + artifactId, requireAccount(), agentId, mode, sessionId, runtimeSessionId); + StreamingResponseBody body = output -> { + try (InputStream input = artifactService.openDownload(artifact)) { + input.transferTo(output); + } + }; + String mimeType = artifact.getMimeType() == null + ? MediaType.APPLICATION_OCTET_STREAM_VALUE : artifact.getMimeType(); + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(artifact.getFileName(), StandardCharsets.UTF_8).build().toString()) + .header("X-Content-Type-Options", "nosniff") + .contentType(MediaType.parseMediaType(mimeType)) + .contentLength(artifact.getSizeBytes() == null ? 0L : artifact.getSizeBytes()) + .body(body); + } + + private LoginAccount requireAccount() { + try { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null || account.getTenantId() == null) { + throw new BusinessException("当前登录状态失效,请重新登录后再试"); + } + return account; + } catch (BusinessException error) { + throw error; + } catch (Exception error) { + throw new BusinessException("当前登录状态失效,请重新登录后再试"); + } + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentController.java index 6a35dbc2..a00284a3 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentController.java @@ -4,6 +4,7 @@ import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaMode; import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.query.QueryWrapper; +import io.agentscope.core.agui.model.RunAgentInput; import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; @@ -12,7 +13,9 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.context.request.RequestContextHolder; @@ -27,6 +30,7 @@ import tech.easyflow.agent.publish.AgentPublishAppService; import tech.easyflow.agent.runtime.AgentChatRequest; import tech.easyflow.agent.runtime.AgentDraftChatRequest; import tech.easyflow.agent.runtime.AgentRunService; +import tech.easyflow.agent.runtime.agui.AgentAguiHitlResolveRequest; import tech.easyflow.agent.runtime.composer.AgentComposerDraft; import tech.easyflow.agent.runtime.composer.AgentComposerDraftService; import tech.easyflow.agent.runtime.composer.AgentComposerSession; @@ -41,6 +45,7 @@ import tech.easyflow.agent.service.AgentApprovalStateService; import tech.easyflow.agent.service.AgentKnowledgeBindingService; import tech.easyflow.agent.service.AgentOptionQueryService; import tech.easyflow.agent.service.AgentService; +import tech.easyflow.agent.service.AgentSkillBindingService; import tech.easyflow.agent.service.AgentToolBindingService; import tech.easyflow.agent.vo.AgentOptionView; import tech.easyflow.agent.vo.AgentResourceOptionsView; @@ -74,6 +79,8 @@ public class AgentController extends BaseCurdController { @Resource private AgentKnowledgeBindingService agentKnowledgeBindingService; @Resource + private AgentSkillBindingService agentSkillBindingService; + @Resource private AgentRunService agentRunService; @Resource private AgentPublishAppService agentPublishAppService; @@ -118,10 +125,11 @@ public class AgentController extends BaseCurdController { * @return Agent 详情 */ @GetMapping("/getDetail") - public Result getDetail(BigInteger id) { + public Result getDetail(BigInteger id) { Agent agent = service.getDetail(id); agentApprovalStateService.fillAgentApprovalState(agent); - return Result.ok(agent); + aiResourceCreatorNameSupport.fillAgentCreatorNames(List.of(agent)); + return Result.ok(AgentDetailView.from(agent)); } /** @@ -133,7 +141,8 @@ public class AgentController extends BaseCurdController { @Override @PostMapping("save") public Result save(@JsonBody Agent agent) { - return Result.ok(service.saveDraft(agent)); + Agent saved = service.saveDraft(agent); + return Result.ok(AgentDetailView.from(service.getDetail(saved.getId()))); } /** @@ -145,7 +154,32 @@ public class AgentController extends BaseCurdController { @Override @PostMapping("update") public Result update(@JsonBody Agent agent) { - return Result.ok(service.updateDraft(agent)); + Agent saved = service.updateDraft(agent); + return Result.ok(AgentDetailView.from(service.getDetail(saved.getId()))); + } + + /** + * 原子保存 Agent 草稿及本次发生变化的绑定组。 + * + * @param request 设计器保存请求 + * @return 保存后的 Agent 与本次替换的绑定 + */ + @PostMapping("/draft/save") + @SaCheckPermission("/api/v1/agent/save") + public Result saveDraft(@JsonBody(required = true, skipConvertError = false) + AgentDraftSaveRequest request) { + if (request == null || request.getAgent() == null) { + throw new BusinessException("Agent 草稿不能为空"); + } + Agent saved = service.saveDraftGraph( + request.getAgent(), + request.getToolBindings(), + request.isReplaceToolBindings(), + request.getKnowledgeBindings(), + request.isReplaceKnowledgeBindings(), + request.toSkillBindings(), + request.isReplaceSkillBindings()); + return Result.ok(AgentDetailView.from(saved)); } /** @@ -156,8 +190,9 @@ public class AgentController extends BaseCurdController { */ @PostMapping("visibilityScope/update") @SaCheckPermission("/api/v1/agent/save") - public Result updateVisibilityScope(@JsonBody Agent agent) { - return Result.ok(service.updateVisibilityScope(agent.getId(), agent.getVisibilityScope())); + public Result updateVisibilityScope(@JsonBody Agent agent) { + return Result.ok(AgentDetailView.from( + service.updateVisibilityScope(agent.getId(), agent.getVisibilityScope()))); } /** @@ -258,6 +293,47 @@ public class AgentController extends BaseCurdController { return agentRunService.chatDraft(request); } + /** + * 通过 AG-UI 协议运行正式 Agent 聊天。 + * + * @param agentId URL 中的 Agent ID + * @param input AG-UI 运行输入 + * @return 原生 AG-UI SSE + */ + @PostMapping("/{agentId}/agui/run") + @SaCheckPermission("/api/v1/agent/session/query") + public SseEmitter chatAgui(@PathVariable BigInteger agentId, + @RequestBody RunAgentInput input) { + return agentRunService.chatAgui(agentId, input); + } + + /** + * 通过 AG-UI 协议运行草稿 Agent 试用。 + * + * @param input AG-UI 运行输入 + * @return 原生 AG-UI SSE + */ + @PostMapping("/agui/run/draft") + @SaCheckPermission("/api/v1/agent/save") + public SseEmitter chatDraftAgui(@RequestBody RunAgentInput input) { + return agentRunService.chatDraftAgui(input); + } + + /** + * 处理 AG-UI 自定义 HITL 兼容桥审批。 + * + * @param request 审批请求 + * @return 操作结果 + */ + @PostMapping("/agui/hitl/resolve") + @SaCheckPermission(value = { + "/api/v1/agent/session/query", "/api/v1/agent/save" + }, mode = SaMode.OR) + public Result resolveAguiApproval(@RequestBody AgentAguiHitlResolveRequest request) { + agentRunService.resolveAguiApproval(request); + return Result.ok(); + } + /** * 上传一张 Agent 聊天临时图片。 * @@ -565,6 +641,26 @@ public class AgentController extends BaseCurdController { return Result.ok(agentKnowledgeBindingService.replaceBindings(agentId, bindings)); } + /** + * 原子替换 Agent 的全部 Skill 草稿绑定。 + * + * @param request 白名单 Skill 引用请求 + * @return 服务端生成的安全 Skill 摘要 + */ + @PostMapping("/skillBinding/update") + @SaCheckPermission("/api/v1/agent/save") + public Result> updateSkillBinding( + @JsonBody(required = true, skipConvertError = false) AgentSkillBindingUpdateRequest request) { + if (request == null || request.getAgentId() == null) { + throw new BusinessException("Agent ID 不能为空"); + } + List bindings = request.getBindings() == null + ? List.of() + : request.getBindings().stream().map(AgentSkillBindingUpdateRequest.Binding::toEntity).toList(); + return Result.ok(agentSkillBindingService.replaceBindings(request.getAgentId(), bindings) + .stream().map(AgentDetailView.SkillBindingView::from).toList()); + } + /** * 提交发布审批。 * @@ -650,6 +746,7 @@ public class AgentController extends BaseCurdController { agent.setPublishedSnapshotJson(Collections.emptyMap()); agent.setToolBindings(null); agent.setKnowledgeBindings(null); + agent.setSkillBindings(null); } /** diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentDetailView.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentDetailView.java new file mode 100644 index 00000000..272b148a --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentDetailView.java @@ -0,0 +1,114 @@ +package tech.easyflow.admin.controller.agent; + +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.entity.AgentKnowledgeBinding; +import tech.easyflow.agent.entity.AgentSkillBinding; +import tech.easyflow.agent.entity.AgentToolBinding; + +import java.math.BigInteger; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * 管理端 Agent 草稿安全详情。 + * + *

该视图明确排除发布快照以及各绑定的内部资源快照。

+ */ +public record AgentDetailView( + BigInteger id, + BigInteger deptId, + String name, + String description, + String avatar, + BigInteger categoryId, + BigInteger modelId, + Map modelConfigJson, + Map generationConfigJson, + Map promptConfigJson, + Map memoryConfigJson, + Map executionConfigJson, + Map interactionConfigJson, + Integer status, + String visibilityScope, + String publishStatus, + BigInteger currentApprovalInstanceId, + Date publishedAt, + BigInteger publishedBy, + Date created, + BigInteger createdBy, + Date modified, + BigInteger modifiedBy, + Boolean approvalPending, + String currentApprovalActionType, + String displayPublishStatus, + String createdByName, + List toolBindings, + List knowledgeBindings, + List skillBindings) { + + /** + * 从领域实体构造安全详情。 + * + * @param agent Agent 领域实体 + * @return 安全详情 + */ + public static AgentDetailView from(Agent agent) { + return new AgentDetailView(agent.getId(), agent.getDeptId(), agent.getName(), agent.getDescription(), + agent.getAvatar(), agent.getCategoryId(), agent.getModelId(), agent.getModelConfigJson(), + agent.getGenerationConfigJson(), agent.getPromptConfigJson(), agent.getMemoryConfigJson(), + agent.getExecutionConfigJson(), agent.getInteractionConfigJson(), agent.getStatus(), + agent.getVisibilityScope(), agent.getPublishStatus(), agent.getCurrentApprovalInstanceId(), + agent.getPublishedAt(), agent.getPublishedBy(), agent.getCreated(), agent.getCreatedBy(), + agent.getModified(), agent.getModifiedBy(), agent.getApprovalPending(), + agent.getCurrentApprovalActionType(), agent.getDisplayPublishStatus(), agent.getCreatedByName(), + mapTools(agent.getToolBindings()), mapKnowledges(agent.getKnowledgeBindings()), + mapSkills(agent.getSkillBindings())); + } + + private static List mapTools(List bindings) { + return bindings == null ? List.of() : bindings.stream().map(ToolBindingView::from).toList(); + } + + private static List mapKnowledges(List bindings) { + return bindings == null ? List.of() : bindings.stream().map(KnowledgeBindingView::from).toList(); + } + + private static List mapSkills(List bindings) { + return bindings == null ? List.of() : bindings.stream().map(SkillBindingView::from).toList(); + } + + /** Agent 直接 Tool 草稿绑定。 */ + public record ToolBindingView(BigInteger id, String toolType, BigInteger targetId, String toolName, + Boolean enabled, Boolean hitlEnabled, Map hitlConfigJson, + Map optionsJson, Integer sortNo, + Map resourceSummary) { + /** @param value 实体 @return 安全绑定 */ + static ToolBindingView from(AgentToolBinding value) { + return new ToolBindingView(value.getId(), value.getToolType(), value.getTargetId(), value.getToolName(), + value.getEnabled(), value.getHitlEnabled(), value.getHitlConfigJson(), value.getOptionsJson(), + value.getSortNo(), value.getResourceSummary()); + } + } + + /** Agent 知识库草稿绑定。 */ + public record KnowledgeBindingView(BigInteger id, BigInteger knowledgeId, String retrievalMode, + Boolean enabled, Map optionsJson, Integer sortNo, + Map resourceSummary) { + /** @param value 实体 @return 安全绑定 */ + static KnowledgeBindingView from(AgentKnowledgeBinding value) { + return new KnowledgeBindingView(value.getId(), value.getKnowledgeId(), value.getRetrievalMode(), + value.getEnabled(), value.getOptionsJson(), value.getSortNo(), value.getResourceSummary()); + } + } + + /** Agent Skill 草稿绑定。 */ + public record SkillBindingView(BigInteger id, BigInteger skillId, Integer sortNo, + Map resourceSummary) { + /** @param value 实体 @return 安全绑定 */ + static SkillBindingView from(AgentSkillBinding value) { + return new SkillBindingView(value.getId(), value.getSkillId(), value.getSortNo(), + value.getResourceSummary()); + } + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentDraftSaveRequest.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentDraftSaveRequest.java new file mode 100644 index 00000000..23e2d44c --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentDraftSaveRequest.java @@ -0,0 +1,109 @@ +package tech.easyflow.admin.controller.agent; + +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.entity.AgentKnowledgeBinding; +import tech.easyflow.agent.entity.AgentSkillBinding; +import tech.easyflow.agent.entity.AgentToolBinding; + +import java.util.List; + +/** + * Agent 设计器原子保存请求。 + * + *

绑定变更标记由设计器基于加载后的稳定业务字段计算。服务端仍会执行权限、状态与幂等比较, + * 标记为未变化的绑定不会进入查询、外部资源校验或整组重写流程。

+ */ +public class AgentDraftSaveRequest { + + private Agent agent; + private List toolBindings; + private boolean replaceToolBindings; + private List knowledgeBindings; + private boolean replaceKnowledgeBindings; + private List skillBindings; + private boolean replaceSkillBindings; + + /** 创建空请求。 */ + public AgentDraftSaveRequest() { + } + + /** @return Agent 草稿 */ + public Agent getAgent() { + return agent; + } + + /** @param agent Agent 草稿 */ + public void setAgent(Agent agent) { + this.agent = agent; + } + + /** @return 工具绑定 */ + public List getToolBindings() { + return toolBindings; + } + + /** @param toolBindings 工具绑定 */ + public void setToolBindings(List toolBindings) { + this.toolBindings = toolBindings; + } + + /** @return 是否替换工具绑定 */ + public boolean isReplaceToolBindings() { + return replaceToolBindings; + } + + /** @param replaceToolBindings 是否替换工具绑定 */ + public void setReplaceToolBindings(boolean replaceToolBindings) { + this.replaceToolBindings = replaceToolBindings; + } + + /** @return 知识库绑定 */ + public List getKnowledgeBindings() { + return knowledgeBindings; + } + + /** @param knowledgeBindings 知识库绑定 */ + public void setKnowledgeBindings(List knowledgeBindings) { + this.knowledgeBindings = knowledgeBindings; + } + + /** @return 是否替换知识库绑定 */ + public boolean isReplaceKnowledgeBindings() { + return replaceKnowledgeBindings; + } + + /** @param replaceKnowledgeBindings 是否替换知识库绑定 */ + public void setReplaceKnowledgeBindings(boolean replaceKnowledgeBindings) { + this.replaceKnowledgeBindings = replaceKnowledgeBindings; + } + + /** @return Skill 绑定 */ + public List getSkillBindings() { + return skillBindings; + } + + /** @param skillBindings Skill 绑定 */ + public void setSkillBindings(List skillBindings) { + this.skillBindings = skillBindings; + } + + /** @return 是否替换 Skill 绑定 */ + public boolean isReplaceSkillBindings() { + return replaceSkillBindings; + } + + /** @param replaceSkillBindings 是否替换 Skill 绑定 */ + public void setReplaceSkillBindings(boolean replaceSkillBindings) { + this.replaceSkillBindings = replaceSkillBindings; + } + + /** + * 将 Skill 白名单引用转换为领域绑定。 + * + * @return 最小 Skill 绑定列表 + */ + public List toSkillBindings() { + return skillBindings == null + ? List.of() : skillBindings.stream().map(AgentSkillBindingUpdateRequest.Binding::toEntity).toList(); + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentSkillBindingUpdateRequest.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentSkillBindingUpdateRequest.java new file mode 100644 index 00000000..73f3ce67 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/agent/AgentSkillBindingUpdateRequest.java @@ -0,0 +1,84 @@ +package tech.easyflow.admin.controller.agent; + +import tech.easyflow.agent.entity.AgentSkillBinding; + +import java.math.BigInteger; +import java.util.List; + +/** + * Agent Skill 整组替换请求。 + * + *

使用标准 JavaBean 以兼容 {@code @JsonBody} 的 Fastjson 1 嵌套列表转换。

+ */ +public class AgentSkillBindingUpdateRequest { + + private BigInteger agentId; + private List bindings; + + /** 创建空请求。 */ + public AgentSkillBindingUpdateRequest() { + } + + /** + * 创建 Agent Skill 绑定请求。 + * + * @param agentId Agent ID + * @param bindings Skill 引用 + */ + public AgentSkillBindingUpdateRequest(BigInteger agentId, List bindings) { + this.agentId = agentId; + this.bindings = bindings; + } + + /** @return Agent ID */ + public BigInteger getAgentId() { return agentId; } + /** @param agentId Agent ID */ + public void setAgentId(BigInteger agentId) { this.agentId = agentId; } + /** @return Skill 引用 */ + public List getBindings() { return bindings; } + /** @param bindings Skill 引用 */ + public void setBindings(List bindings) { this.bindings = bindings; } + + /** 客户端允许提交的最小 Skill 引用。 */ + public static class Binding { + + private BigInteger skillId; + private Integer sortNo; + + /** 创建空绑定。 */ + public Binding() { + } + + /** + * 创建最小 Skill 绑定。 + * + * @param skillId Skill ID + * @param sortNo 排序号 + */ + public Binding(BigInteger skillId, Integer sortNo) { + this.skillId = skillId; + this.sortNo = sortNo; + } + + /** @return Skill ID */ + public BigInteger getSkillId() { return skillId; } + /** @param skillId Skill ID */ + public void setSkillId(BigInteger skillId) { this.skillId = skillId; } + /** @return 排序号 */ + public Integer getSortNo() { return sortNo; } + /** @param sortNo 排序号 */ + public void setSortNo(Integer sortNo) { this.sortNo = sortNo; } + + /** + * 转换为不含任何服务端快照的领域引用。 + * + * @return 最小 Skill 绑定 + */ + public AgentSkillBinding toEntity() { + AgentSkillBinding value = new AgentSkillBinding(); + value.setSkillId(skillId); + value.setSortNo(sortNo); + return value; + } + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowController.java index 45b6bfab..b8257f5c 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowController.java @@ -324,7 +324,12 @@ public class WorkflowController extends BaseCurdController resume(@JsonBody(value = "executeId", required = true) String executeId, @JsonBody("confirmParams") Map confirmParams) { - chainExecutor.resumeAsync(executeId, confirmParams); + if (!chainExecutor.resumeAsyncIfSuspended(executeId, confirmParams)) { + throw new BusinessException( + 409, + 40901, + "当前执行状态不可恢复,仅暂停中的工作流允许恢复"); + } return Result.ok(); } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillController.java index 7d4f0c26..4ca3695c 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/SkillController.java @@ -1,6 +1,7 @@ package tech.easyflow.admin.controller.skill; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.annotation.SaMode; import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.query.QueryWrapper; import jakarta.servlet.http.HttpServletResponse; @@ -17,6 +18,7 @@ import tech.easyflow.admin.controller.skill.vo.SkillCopyRequest; import tech.easyflow.admin.controller.skill.vo.SkillDraftRequest; import tech.easyflow.admin.controller.skill.vo.SkillImportBatchResultView; import tech.easyflow.admin.controller.skill.vo.SkillView; +import tech.easyflow.admin.controller.skill.vo.SkillToolBindingUpdateRequest; import tech.easyflow.admin.controller.skill.vo.SkillPublishStatusView; import tech.easyflow.approval.entity.vo.ApprovalActionResult; import tech.easyflow.common.entity.LoginAccount; @@ -41,6 +43,10 @@ import tech.easyflow.skill.publish.SkillPublishAppService; import tech.easyflow.skill.security.SkillVisibilityQueryHelper; import tech.easyflow.skill.service.SkillApprovalStateService; import tech.easyflow.skill.service.SkillService; +import tech.easyflow.skill.service.SkillToolBindingService; +import tech.easyflow.skill.service.SkillToolOptionQueryService; +import tech.easyflow.skill.vo.SkillMcpToolManifestView; +import tech.easyflow.skill.vo.SkillToolOptionPage; import tech.easyflow.skill.validation.SkillValidationResult; import tech.easyflow.system.enums.CategoryResourceType; import tech.easyflow.system.enums.ResourceAction; @@ -73,6 +79,8 @@ public class SkillController { private final SkillImportService skillImportService; private final SkillExportService skillExportService; private final SkillFileService skillFileService; + private final SkillToolBindingService skillToolBindingService; + private final SkillToolOptionQueryService skillToolOptionQueryService; private final ResourceAccessService resourceAccessService; private final CategoryPermissionService categoryPermissionService; private final SkillVisibilityQueryHelper visibilityQueryHelper; @@ -87,6 +95,8 @@ public class SkillController { * @param skillImportService 导入服务 * @param skillExportService 导出服务 * @param skillFileService 文件服务 + * @param skillToolBindingService Skill Tool 绑定服务 + * @param skillToolOptionQueryService Skill Tool 候选查询服务 * @param resourceAccessService 资源权限服务 * @param categoryPermissionService 分类权限服务 * @param visibilityQueryHelper 可见性查询助手 @@ -98,6 +108,8 @@ public class SkillController { SkillImportService skillImportService, SkillExportService skillExportService, SkillFileService skillFileService, + SkillToolBindingService skillToolBindingService, + SkillToolOptionQueryService skillToolOptionQueryService, ResourceAccessService resourceAccessService, CategoryPermissionService categoryPermissionService, SkillVisibilityQueryHelper visibilityQueryHelper, @@ -108,6 +120,8 @@ public class SkillController { this.skillImportService = skillImportService; this.skillExportService = skillExportService; this.skillFileService = skillFileService; + this.skillToolBindingService = skillToolBindingService; + this.skillToolOptionQueryService = skillToolOptionQueryService; this.resourceAccessService = resourceAccessService; this.categoryPermissionService = categoryPermissionService; this.visibilityQueryHelper = visibilityQueryHelper; @@ -179,6 +193,54 @@ public class SkillController { return Result.ok(toView(skill)); } + /** + * 查询 Skill 可绑定的 Tool 候选。 + * + * @param keyword 名称或描述关键词 + * @param toolType 类型过滤 + * @param pageNum 页码 + * @param pageSize 每页数量 + * @return 安全候选分页 + */ + @GetMapping("/toolOptions") + @SaCheckPermission(value = {"/api/v1/skill/save", "/api/v1/skill/update"}, mode = SaMode.OR) + public Result toolOptions(String keyword, String toolType, + Long pageNum, Long pageSize) { + return Result.ok(skillToolOptionQueryService.page(keyword, toolType, + pageNum == null ? 1 : pageNum, pageSize == null ? 20 : pageSize)); + } + + /** + * 按需读取指定 MCP 的脱敏 Tool 清单。 + * + * @param mcpId MCP ID + * @return MCP Tool 清单 + */ + @GetMapping("/mcpTools") + @SaCheckPermission(value = {"/api/v1/skill/save", "/api/v1/skill/update"}, mode = SaMode.OR) + public Result mcpTools(BigInteger mcpId) { + return Result.ok(skillToolOptionQueryService.mcpTools(mcpId)); + } + + /** + * 原子替换 Skill 的全部平台 Tool 草稿绑定。 + * + * @param request 白名单绑定请求 + * @return 服务端规范化的安全绑定摘要 + */ + @PostMapping("/toolBinding/update") + @SaCheckPermission(value = {"/api/v1/skill/save", "/api/v1/skill/update"}, mode = SaMode.OR) + public Result> updateToolBindings( + @JsonBody(required = true, skipConvertError = false) SkillToolBindingUpdateRequest request) { + if (request == null || request.getSkillId() == null) { + throw new BusinessException("Skill ID 不能为空"); + } + List bindings = request.getBindings() == null + ? List.of() : request.getBindings().stream().map(SkillToolBindingUpdateRequest.Binding::toEntity).toList(); + return Result.ok(skillToolBindingService.replaceBindings(request.getSkillId(), bindings) + .stream().map(SkillView.ToolBindingView::from).toList()); + } + /** * 创建 Skill 草稿。 * diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillToolBindingUpdateRequest.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillToolBindingUpdateRequest.java new file mode 100644 index 00000000..1694c0d9 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillToolBindingUpdateRequest.java @@ -0,0 +1,112 @@ +package tech.easyflow.admin.controller.skill.vo; + +import tech.easyflow.skill.entity.SkillToolBinding; + +import java.math.BigInteger; +import java.util.List; + +/** + * Skill Tool 整组替换请求。 + * + *

{@code @JsonBody} 当前由 Fastjson 1 完成转换,使用标准 JavaBean 可确保嵌套列表元素 + * 按声明类型转换,避免嵌套 record 被保留为 {@code JSONObject}。

+ */ +public class SkillToolBindingUpdateRequest { + + private BigInteger skillId; + private List bindings; + + /** 创建空请求。 */ + public SkillToolBindingUpdateRequest() { + } + + /** + * 创建 Skill Tool 绑定请求。 + * + * @param skillId Skill ID + * @param bindings 绑定引用 + */ + public SkillToolBindingUpdateRequest(BigInteger skillId, List bindings) { + this.skillId = skillId; + this.bindings = bindings; + } + + /** @return Skill ID */ + public BigInteger getSkillId() { return skillId; } + /** @param skillId Skill ID */ + public void setSkillId(BigInteger skillId) { this.skillId = skillId; } + /** @return 绑定引用 */ + public List getBindings() { return bindings; } + /** @param bindings 绑定引用 */ + public void setBindings(List bindings) { this.bindings = bindings; } + + /** 客户端允许提交的最小绑定字段。 */ + public static class Binding { + + private String toolType; + private BigInteger targetId; + private Boolean hitlEnabled; + private Integer sortNo; + private String mcpToolManifestHash; + + /** 创建空绑定。 */ + public Binding() { + } + + /** + * 创建最小 Tool 绑定。 + * + * @param toolType Tool 类型 + * @param targetId 目标资源 ID + * @param hitlEnabled 是否调用前确认 + * @param sortNo 排序号 + * @param mcpToolManifestHash MCP Tool 清单 hash + */ + public Binding(String toolType, BigInteger targetId, Boolean hitlEnabled, + Integer sortNo, String mcpToolManifestHash) { + this.toolType = toolType; + this.targetId = targetId; + this.hitlEnabled = hitlEnabled; + this.sortNo = sortNo; + this.mcpToolManifestHash = mcpToolManifestHash; + } + + /** @return Tool 类型 */ + public String getToolType() { return toolType; } + /** @param toolType Tool 类型 */ + public void setToolType(String toolType) { this.toolType = toolType; } + /** @return 目标资源 ID */ + public BigInteger getTargetId() { return targetId; } + /** @param targetId 目标资源 ID */ + public void setTargetId(BigInteger targetId) { this.targetId = targetId; } + /** @return 是否调用前确认 */ + public Boolean getHitlEnabled() { return hitlEnabled; } + /** @param hitlEnabled 是否调用前确认 */ + public void setHitlEnabled(Boolean hitlEnabled) { this.hitlEnabled = hitlEnabled; } + /** @return 排序号 */ + public Integer getSortNo() { return sortNo; } + /** @param sortNo 排序号 */ + public void setSortNo(Integer sortNo) { this.sortNo = sortNo; } + /** @return MCP Tool 清单 hash */ + public String getMcpToolManifestHash() { return mcpToolManifestHash; } + /** @param mcpToolManifestHash MCP Tool 清单 hash */ + public void setMcpToolManifestHash(String mcpToolManifestHash) { + this.mcpToolManifestHash = mcpToolManifestHash; + } + + /** + * 转换为领域绑定引用。 + * + * @return 最小 Tool 绑定 + */ + public SkillToolBinding toEntity() { + SkillToolBinding value = new SkillToolBinding(); + value.setToolType(toolType); + value.setTargetId(targetId); + value.setHitlEnabled(hitlEnabled); + value.setSortNo(sortNo); + value.setMcpToolManifestHash(mcpToolManifestHash); + return value; + } + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillView.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillView.java index acc44484..b0b374cf 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillView.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/skill/vo/SkillView.java @@ -3,6 +3,7 @@ package tech.easyflow.admin.controller.skill.vo; import com.easyagents.skill.util.SkillResources; import tech.easyflow.skill.entity.Skill; import tech.easyflow.skill.entity.SkillResource; +import tech.easyflow.skill.entity.SkillToolBinding; import java.math.BigInteger; import java.util.Date; @@ -16,7 +17,6 @@ import java.util.List; * @param name 标准名称 * @param displayName 展示名称 * @param description 用途描述 - * @param skillContent SKILL.md 内容 * @param visibilityScope 使用范围 * @param packageHash 标准包哈希 * @param snapshotHash 发布快照哈希 @@ -31,13 +31,15 @@ import java.util.List; * @param readable 是否可读 * @param manageable 是否可管理 * @param resources 资源摘要 + * @param toolBindings 平台 Tool 草稿绑定摘要 + * @param toolCount 实际 Tool 数 + * @param hasToolUpdate Tool 草稿是否与线上快照不同 */ public record SkillView(BigInteger id, BigInteger categoryId, String name, String displayName, String description, - String skillContent, String visibilityScope, String packageHash, String snapshotHash, @@ -51,7 +53,10 @@ public record SkillView(BigInteger id, String createdByName, boolean readable, boolean manageable, - List resources) { + List resources, + List toolBindings, + int toolCount, + boolean hasToolUpdate) { /** * 从领域实体构造管理端视图。 @@ -64,11 +69,18 @@ public record SkillView(BigInteger id, public static SkillView from(Skill skill, boolean readable, boolean manageable) { List resources = skill.getResources() == null ? null : skill.getResources().stream().map(ResourceView::from).toList(); + List toolBindings = skill.getToolBindings() == null ? null + : skill.getToolBindings().stream().map(ToolBindingView::from).toList(); + int toolCount = skill.getToolBindings() == null ? 0 : skill.getToolBindings().stream() + .mapToInt(binding -> "MCP".equalsIgnoreCase(binding.getToolType()) + ? Math.max(0, binding.getMcpToolCount() == null ? 0 : binding.getMcpToolCount()) : 1) + .sum(); return new SkillView(skill.getId(), skill.getCategoryId(), skill.getName(), skill.getDisplayName(), - skill.getDescription(), skill.getSkillContent(), skill.getVisibilityScope(), skill.getPackageHash(), + skill.getDescription(), skill.getVisibilityScope(), skill.getPackageHash(), skill.getSnapshotHash(), skill.getPublishStatus(), skill.getCurrentApprovalInstanceId(), skill.getApprovalPending(), skill.getCurrentApprovalActionType(), skill.getDisplayPublishStatus(), - skill.getCreated(), skill.getModified(), skill.getCreatedByName(), readable, manageable, resources); + skill.getCreated(), skill.getModified(), skill.getCreatedByName(), readable, manageable, resources, + toolBindings, toolCount, hasToolUpdate(skill)); } /** @@ -97,4 +109,44 @@ public record SkillView(BigInteger id, resource.getMediaType(), resource.getIsText(), resource.getContentHash(), resource.getSize()); } } + + private static boolean hasToolUpdate(Skill skill) { + if (skill.getToolBindings() == null) { + return false; + } + Object published = skill.getPublishedToolBindingsJson() == null + ? null : skill.getPublishedToolBindingsJson().get("bindings"); + List currentKeys = skill.getToolBindings().stream().map(SkillView::bindingKey).toList(); + if (!(published instanceof List list)) { + return !currentKeys.isEmpty(); + } + List publishedKeys = list.stream().map(item -> { + if (!(item instanceof java.util.Map map)) { + return "INVALID"; + } + return String.valueOf(map.get("toolType")) + ":" + map.get("targetId") + ":" + + Boolean.TRUE.equals(map.get("hitlEnabled")) + ":" + map.get("mcpToolManifestHash"); + }).toList(); + return !currentKeys.equals(publishedKeys); + } + + private static String bindingKey(SkillToolBinding binding) { + return binding.getToolType() + ":" + binding.getTargetId() + ":" + + Boolean.TRUE.equals(binding.getHitlEnabled()) + ":" + binding.getMcpToolManifestHash(); + } + + /** + * Skill 平台 Tool 草稿绑定安全摘要。 + */ + public record ToolBindingView(BigInteger id, String toolType, BigInteger targetId, + Boolean hitlEnabled, Integer mcpToolCount, + String mcpToolManifestHash, Integer sortNo, + java.util.Map resourceSummary) { + /** @param binding 绑定实体 @return 安全摘要 */ + public static ToolBindingView from(SkillToolBinding binding) { + return new ToolBindingView(binding.getId(), binding.getToolType(), binding.getTargetId(), + binding.getHitlEnabled(), binding.getMcpToolCount(), binding.getMcpToolManifestHash(), + binding.getSortNo(), binding.getResourceSummary()); + } + } } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/agent/AgentSessionService.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/agent/AgentSessionService.java index e43517d6..caaa1824 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/agent/AgentSessionService.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/agent/AgentSessionService.java @@ -266,6 +266,9 @@ public class AgentSessionService { if (!Objects.equals(summary.getUserId(), account.getId())) { throw new BusinessException("无权访问该 Agent 会话"); } + if (!Objects.equals(summary.getTenantId(), account.getTenantId())) { + throw new BusinessException("无权访问该 Agent 会话"); + } } private Map resolveAgentAvailability(List sessions) { diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/ai/ChatWorkspaceService.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/ai/ChatWorkspaceService.java index 7deccce1..9e6bb594 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/ai/ChatWorkspaceService.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/ai/ChatWorkspaceService.java @@ -166,8 +166,14 @@ public class ChatWorkspaceService { roundIds.add(record.getRoundId()); } } + List allVariants = new ArrayList<>(); for (BigInteger roundId : roundIds) { - variantsByRound.put(roundId.toString(), chatRoundOperateService.listVariants(sessionId, roundId)); + List variants = chatRoundOperateService.listVariantsUnprojected(sessionId, roundId); + variantsByRound.put(roundId.toString(), variants); + allVariants.addAll(variants); + } + if (!allVariants.isEmpty()) { + chatRoundOperateService.projectVariants(sessionId, allVariants); } ChatWorkspaceConversationView view = new ChatWorkspaceConversationView(); view.setRecords(records); diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/agent/AgentSkillBindingContractTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/agent/AgentSkillBindingContractTest.java new file mode 100644 index 00000000..a031c3f8 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/agent/AgentSkillBindingContractTest.java @@ -0,0 +1,77 @@ +package tech.easyflow.admin.controller.agent; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import org.testng.Assert; +import org.testng.annotations.Test; +import tech.easyflow.common.domain.Result; +import tech.easyflow.common.web.jsonbody.JsonBody; +import tech.easyflow.common.web.jsonbody.JsonBodyParser; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.math.BigInteger; +import java.util.Arrays; +import java.util.List; + +/** + * Agent Skill 绑定管理端 API 安全契约测试。 + */ +public class AgentSkillBindingContractTest { + + /** + * 验证请求 DTO 仅暴露 Agent ID、Skill ID 与排序号。 + * + * @throws Exception 反序列化失败 + */ + @Test + public void requestUsesWhitelistFieldsAndDropsServerSnapshot() throws Exception { + JSONObject json = JSON.parseObject(""" + { + "agentId": 10, + "bindings": [{ + "skillId": 101, + "sortNo": 2, + "resourceSnapshot": {"skillContent": "forged"}, + "resourceSummary": {"displayName": "forged"} + }] + } + """); + + AgentSkillBindingUpdateRequest request = (AgentSkillBindingUpdateRequest) JsonBodyParser.parseJsonBody( + json, AgentSkillBindingUpdateRequest.class, AgentSkillBindingUpdateRequest.class, ""); + + Assert.assertEquals(request.getAgentId(), BigInteger.TEN); + Assert.assertEquals(request.getBindings().get(0).getSkillId(), BigInteger.valueOf(101)); + Assert.assertEquals(request.getBindings().get(0).getSortNo(), Integer.valueOf(2)); + Assert.assertTrue(request.getBindings().get(0).toEntity().getResourceSnapshot().isEmpty()); + Assert.assertTrue(request.getBindings().get(0).toEntity().getResourceSummary().isEmpty()); + Assert.assertEquals( + Arrays.stream(AgentSkillBindingUpdateRequest.Binding.class.getDeclaredFields()) + .filter(field -> !Modifier.isStatic(field.getModifiers())) + .map(field -> field.getName()).toList(), + List.of("skillId", "sortNo")); + } + + /** + * 验证更新入口使用白名单 DTO 并返回脱敏视图。 + * + * @throws Exception 反射失败 + */ + @Test + public void updateEndpointReturnsSafeSkillBindingViews() throws Exception { + Method method = AgentController.class.getMethod( + "updateSkillBinding", AgentSkillBindingUpdateRequest.class); + JsonBody jsonBody = method.getParameters()[0].getAnnotation(JsonBody.class); + ParameterizedType resultType = (ParameterizedType) method.getGenericReturnType(); + ParameterizedType listType = (ParameterizedType) resultType.getActualTypeArguments()[0]; + + Assert.assertNotNull(jsonBody); + Assert.assertEquals(resultType.getRawType(), Result.class); + Assert.assertEquals(listType.getRawType(), List.class); + Assert.assertEquals(listType.getActualTypeArguments()[0], AgentDetailView.SkillBindingView.class); + Assert.assertFalse(Arrays.stream(AgentDetailView.SkillBindingView.class.getRecordComponents()) + .anyMatch(component -> "resourceSnapshot".equals(component.getName()))); + } +} diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/skill/SkillControllerContractTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/skill/SkillControllerContractTest.java index 064f9cb6..3d27408b 100644 --- a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/skill/SkillControllerContractTest.java +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/skill/SkillControllerContractTest.java @@ -9,6 +9,7 @@ import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport; import tech.easyflow.admin.controller.skill.vo.SkillCopyRequest; import tech.easyflow.admin.controller.skill.vo.SkillDraftRequest; import tech.easyflow.admin.controller.skill.vo.SkillImportBatchResultView; +import tech.easyflow.admin.controller.skill.vo.SkillToolBindingUpdateRequest; import tech.easyflow.admin.controller.skill.vo.SkillView; import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.approval.entity.vo.ApprovalActionResult; @@ -25,11 +26,14 @@ import tech.easyflow.skill.publish.SkillPublishAppService; import tech.easyflow.skill.security.SkillVisibilityQueryHelper; import tech.easyflow.skill.service.SkillApprovalStateService; import tech.easyflow.skill.service.SkillService; +import tech.easyflow.skill.service.SkillToolBindingService; +import tech.easyflow.skill.service.SkillToolOptionQueryService; import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.ResourceAccessService; import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Modifier; import java.math.BigInteger; import java.util.Arrays; import java.util.List; @@ -44,6 +48,40 @@ import static org.mockito.Mockito.when; */ public class SkillControllerContractTest { + /** + * Tool 绑定请求能够把嵌套列表转换为白名单 JavaBean。 + * + * @throws Exception 反序列化失败 + */ + @Test + public void jsonBodyParserDeserializesNestedToolBindings() throws Exception { + JSONObject json = JSON.parseObject(""" + { + "skillId": 101, + "bindings": [{ + "toolType": "WORKFLOW", + "targetId": 202, + "hitlEnabled": true, + "sortNo": 0, + "resourceSnapshot": {"forged": true} + }] + } + """); + + SkillToolBindingUpdateRequest request = (SkillToolBindingUpdateRequest) JsonBodyParser.parseJsonBody( + json, SkillToolBindingUpdateRequest.class, SkillToolBindingUpdateRequest.class, ""); + + Assert.assertEquals(request.getSkillId(), BigInteger.valueOf(101)); + Assert.assertEquals(request.getBindings().get(0).getTargetId(), BigInteger.valueOf(202)); + Assert.assertEquals(request.getBindings().get(0).getToolType(), "WORKFLOW"); + Assert.assertTrue(request.getBindings().get(0).getHitlEnabled()); + Assert.assertEquals( + Arrays.stream(SkillToolBindingUpdateRequest.Binding.class.getDeclaredFields()) + .filter(field -> !Modifier.isStatic(field.getModifiers())) + .map(field -> field.getName()).toList(), + List.of("toolType", "targetId", "hitlEnabled", "sortNo", "mcpToolManifestHash")); + } + /** * 草稿白名单 DTO 只接受标准包治理字段。 * @@ -158,6 +196,7 @@ public class SkillControllerContractTest { when(accessService.canAccess(any(), any(), any())).thenReturn(true); return new SkillController(mock(SkillService.class), mock(SkillApprovalStateService.class), publishService, importService, mock(SkillExportService.class), mock(SkillFileService.class), + mock(SkillToolBindingService.class), mock(SkillToolOptionQueryService.class), accessService, mock(CategoryPermissionService.class), mock(SkillVisibilityQueryHelper.class), mock(AiResourceCreatorNameSupport.class)); } diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/agent/AgentSessionServiceTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/agent/AgentSessionServiceTest.java index 2c8fcbf5..5c559ff1 100644 --- a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/agent/AgentSessionServiceTest.java +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/agent/AgentSessionServiceTest.java @@ -9,7 +9,10 @@ import tech.easyflow.agent.runtime.composer.AgentComposerDraftService; import tech.easyflow.agent.runtime.media.AgentMediaService; import tech.easyflow.agent.service.AgentService; import tech.easyflow.ai.service.DocumentCollectionService; +import tech.easyflow.chatlog.domain.dto.ChatHistoryPage; +import tech.easyflow.chatlog.domain.dto.ChatMessageRecord; import tech.easyflow.chatlog.domain.dto.ChatSessionSummary; +import tech.easyflow.chatlog.domain.query.ChatPageQuery; import tech.easyflow.chatlog.service.ChatSessionCommandService; import tech.easyflow.chatlog.service.ChatSessionQueryService; import tech.easyflow.chatlog.support.ChatJsonSupport; @@ -18,6 +21,7 @@ import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.system.service.ResourceAccessService; import java.math.BigInteger; +import java.util.List; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -30,6 +34,7 @@ import static org.mockito.Mockito.when; public class AgentSessionServiceTest { private static final BigInteger ACCOUNT_ID = BigInteger.valueOf(7); + private static final BigInteger TENANT_ID = BigInteger.valueOf(3); private static final BigInteger SESSION_ID = BigInteger.valueOf(101); private ChatSessionQueryService chatSessionQueryService; @@ -63,6 +68,7 @@ public class AgentSessionServiceTest { ); account = new LoginAccount(); account.setId(ACCOUNT_ID); + account.setTenantId(TENANT_ID); } /** @@ -143,6 +149,7 @@ public class AgentSessionServiceTest { private ChatSessionSummary buildSession(BigInteger userId, Integer isDeleted, String assistantCode) { ChatSessionSummary summary = new ChatSessionSummary(); summary.setId(SESSION_ID); + summary.setTenantId(TENANT_ID); summary.setUserId(userId); summary.setIsDeleted(isDeleted); summary.setAssistantCode(assistantCode); diff --git a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcWorkflowController.java b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcWorkflowController.java index ca21718a..c26f0e5d 100644 --- a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcWorkflowController.java +++ b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcWorkflowController.java @@ -20,6 +20,7 @@ import tech.easyflow.common.constant.Constants; import tech.easyflow.common.domain.Result; import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.controller.BaseCurdController; +import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.system.enums.CategoryResourceType; import tech.easyflow.system.enums.ResourceAction; @@ -162,7 +163,12 @@ public class UcWorkflowController extends BaseCurdController resume(@JsonBody(value = "executeId", required = true) String executeId, @JsonBody("confirmParams") Map confirmParams) { - chainExecutor.resumeAsync(executeId, confirmParams); + if (!chainExecutor.resumeAsyncIfSuspended(executeId, confirmParams)) { + throw new BusinessException( + 409, + 40901, + "当前执行状态不可恢复,仅暂停中的工作流允许恢复"); + } return Result.ok(); } diff --git a/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/chat/protocol/sse/ChatSseEmitter.java b/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/chat/protocol/sse/ChatSseEmitter.java index f1c3e43e..12dad928 100644 --- a/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/chat/protocol/sse/ChatSseEmitter.java +++ b/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/chat/protocol/sse/ChatSseEmitter.java @@ -78,6 +78,39 @@ public class ChatSseEmitter { return send("needSaveMessage", envelope); } + /** + * 发送不带私有事件包装的 SSE data 数据。 + * + * @param data 已完成协议序列化的数据 + * @return 发送成功时为 true + */ + public boolean sendData(String data) { + if (closed.get()) { + return false; + } + try { + emitter.send(SseEmitter.event().data(data)); + return true; + } catch (IOException exception) { + markDisconnected("data", exception); + return false; + } catch (IllegalStateException exception) { + closed.compareAndSet(false, true); + LOG.warn("ChatSseEmitter data send failed, message={}, exception={}", + exception.getMessage(), exception.toString()); + return false; + } catch (Exception exception) { + if (isClientDisconnected(exception)) { + markDisconnected("data", exception); + return false; + } + LOG.error("ChatSseEmitter data send unexpected failed, message={}, exception={}", + exception.getMessage(), exception.toString(), exception); + safeCompleteWithError(exception); + return false; + } + } + /** SSE 底层发送 */ private boolean send(String event, ChatEnvelope envelope) { if (closed.get()) { diff --git a/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/runtime/ChatAssistantAccumulator.java b/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/runtime/ChatAssistantAccumulator.java index f7cc9e2b..20dcaf59 100644 --- a/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/runtime/ChatAssistantAccumulator.java +++ b/easyflow-commons/easyflow-common-chat-protocol/src/main/java/tech/easyflow/core/runtime/ChatAssistantAccumulator.java @@ -17,6 +17,8 @@ public class ChatAssistantAccumulator { private final List> chains = new ArrayList<>(); private final List> messageChain = new ArrayList<>(); private final List> toolMessages = new ArrayList<>(); + private final Map> skillInvocationStatuses = new LinkedHashMap<>(); + private final Map> artifacts = new LinkedHashMap<>(); private Map latestToolCallAssistant; private boolean toolCallBatchOpen; @@ -113,6 +115,63 @@ public class ChatAssistantAccumulator { toolCallBatchOpen = false; } + /** + * 记录可安全回放的 Skill 调用状态,并按稳定状态键原位覆盖。 + * + * @param status 仅含展示白名单字段的状态 + */ + public void appendSkillInvocationStatus(Map status) { + if (status == null || status.get("statusKey") == null) { + return; + } + String key = String.valueOf(status.get("statusKey")); + Map safe = new LinkedHashMap<>(); + for (String field : List.of("statusKey", "status", "skillId", "skillName", + "skillDisplayName", "toolCallId", "message")) { + if (status.get(field) != null) { + safe.put(field, status.get(field)); + } + } + skillInvocationStatuses.put(key, safe); + } + + /** + * 记录可安全持久化并回放的 Agent 产物投影。 + * + * @param artifact 产物安全字段 + */ + public void appendArtifact(Map artifact) { + if (artifact == null || artifact.get("artifactId") == null) { + return; + } + Map safe = new LinkedHashMap<>(); + for (String field : List.of("schemaVersion", "artifactId", "fileName", "mimeType", + "size", "sha256", "downloadUrl", "status")) { + if (artifact.get(field) != null) { + safe.put(field, artifact.get(field)); + } + } + artifacts.put(String.valueOf(artifact.get("artifactId")), safe); + } + + /** + * 将仍在运行的 Skill 状态收口为指定终态。 + * + * @param terminalStatus FAILED 或 CANCELLED + * @param message 可恢复提示 + */ + public void finalizePendingSkillInvocations(String terminalStatus, String message) { + for (Map status : skillInvocationStatuses.values()) { + if (!"RUNNING".equals(String.valueOf(status.get("status")))) { + continue; + } + status.put("status", terminalStatus); + if (message != null && !message.isBlank()) { + status.put("message", message); + } + } + } + /** * 获取当前 assistant 片段的文本内容。 * @@ -161,7 +220,24 @@ public class ChatAssistantAccumulator { if (!finalAssistantMessage.isEmpty()) { payloadMessageChain.add(finalAssistantMessage); } - return ChatRuntimeHistoryPayloadHelper.buildPayload(payloadMessageChain, toolMessages, payloadChains); + Map payload = ChatRuntimeHistoryPayloadHelper.buildPayload( + payloadMessageChain, toolMessages, payloadChains); + if (!skillInvocationStatuses.isEmpty()) { + List> statuses = new ArrayList<>(); + for (Map current : skillInvocationStatuses.values()) { + Map copy = new LinkedHashMap<>(current); + if ("RUNNING".equals(String.valueOf(copy.get("status")))) { + copy.put("status", "INCOMPLETE"); + copy.putIfAbsent("message", "技能调用未完成"); + } + statuses.add(copy); + } + payload.put("skillInvocationStatuses", statuses); + } + if (!artifacts.isEmpty()) { + payload.put("artifacts", new ArrayList<>(artifacts.values())); + } + return payload; } private Map findToolChain(String id, String name) { diff --git a/easyflow-commons/easyflow-common-chat-protocol/src/test/java/tech/easyflow/core/runtime/ChatAssistantAccumulatorArtifactTest.java b/easyflow-commons/easyflow-common-chat-protocol/src/test/java/tech/easyflow/core/runtime/ChatAssistantAccumulatorArtifactTest.java new file mode 100644 index 00000000..52a77305 --- /dev/null +++ b/easyflow-commons/easyflow-common-chat-protocol/src/test/java/tech/easyflow/core/runtime/ChatAssistantAccumulatorArtifactTest.java @@ -0,0 +1,39 @@ +package tech.easyflow.core.runtime; + +import org.junit.Assert; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +/** + * Assistant 历史 payload 的 Artifact 安全投影测试。 + */ +public class ChatAssistantAccumulatorArtifactTest { + + /** + * 验证只持久化安全字段,并按 artifactId 原位去重。 + */ + @Test + public void buildPayloadShouldPersistOnlySafeArtifactFields() { + ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator(); + accumulator.appendArtifact(Map.of( + "schemaVersion", 1, + "artifactId", "a1", + "fileName", "report.csv", + "mimeType", "text/csv", + "size", 12L, + "sha256", "abc", + "downloadUrl", "/api/v1/agent/artifacts/a1/content", + "status", "AVAILABLE", + "objectKey", "private/object/key")); + + Map payload = accumulator.buildPayload("done"); + @SuppressWarnings("unchecked") + List> artifacts = (List>) payload.get("artifacts"); + + Assert.assertEquals(1, artifacts.size()); + Assert.assertEquals("a1", artifacts.get(0).get("artifactId")); + Assert.assertFalse(artifacts.get(0).containsKey("objectKey")); + } +} diff --git a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImpl.java b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImpl.java index f1386325..710f1bcd 100644 --- a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImpl.java +++ b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImpl.java @@ -73,6 +73,34 @@ public class XFIleStorageServiceImpl implements FileStorageService { return fileInfo.getUrl(); } + /** + * 使用指定前置目录上传后端本地文件。 + * + * @param file 后端本地文件 + * @param prePath 前置目录 + * @return 文件 URL + * @throws IllegalArgumentException 文件不存在或不是普通文件时抛出 + */ + @Override + public String save(File file, String prePath) { + if (file == null || !file.isFile()) { + throw new IllegalArgumentException("待上传的本地文件不存在"); + } + String uploadPath = PathGeneratorUtil.generateUserPath(""); + if (StringUtils.hasText(prePath)) { + String normalized = prePath.replaceAll("^/+", "").replaceAll("/+$", ""); + uploadPath = "/" + normalized + uploadPath; + } + FileInfo fileInfo = fileStorageService.of(file) + .setPath(uploadPath) + .setSaveFilename(file.getName()) + .upload(); + if (fileInfo == null || !StringUtils.hasText(fileInfo.getUrl())) { + throw new RuntimeException("文件上传失败"); + } + return fileInfo.getUrl(); + } + /** * 幂等删除指定文件;物理文件已不存在时同步清理残留记录。 * diff --git a/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImplTest.java b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImplTest.java index 4a844058..193e1585 100644 --- a/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImplTest.java +++ b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImplTest.java @@ -35,6 +35,26 @@ import static org.junit.Assert.assertTrue; */ public class XFIleStorageServiceImplTest { + /** + * 验证后端生成的本地文件可以通过统一 x-file-storage 路由上传。 + * + * @throws Exception 创建临时文件或注入测试替身失败 + */ + @Test + public void localFileSaveUsesRequestedPathAndFilename() throws Exception { + RecoverablePlatform platform = new RecoverablePlatform("minio-main", "attachment", "https://files/"); + RecoverableStorageService delegate = new RecoverableStorageService(platform); + XFIleStorageServiceImpl service = createService(delegate); + File file = Files.createTempFile("generated-skill-", ".zip").toFile(); + + String url = service.save(file, "skill-imports/tenant-1"); + + assertEquals(file.getName(), delegate.uploadFilename); + assertTrue(delegate.uploadPath.startsWith("/skill-imports/tenant-1/")); + assertEquals("https://files/attachment" + delegate.uploadPath + file.getName(), url); + assertTrue(platform.exists); + } + /** * 验证底层明确返回 false 时抛出带有效消息的异常。 * diff --git a/easyflow-modules/easyflow-module-agent/pom.xml b/easyflow-modules/easyflow-module-agent/pom.xml index cf43c5a5..ac1955ae 100644 --- a/easyflow-modules/easyflow-module-agent/pom.xml +++ b/easyflow-modules/easyflow-module-agent/pom.xml @@ -29,6 +29,10 @@ tech.easyflow easyflow-module-system + + tech.easyflow + easyflow-module-skill + tech.easyflow easyflow-common-chat-protocol @@ -65,6 +69,10 @@ com.easyagents easy-agents-agent-runtime + + com.easyagents + easy-agents-agui + org.springframework.boot spring-boot-starter-web diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentBuiltinToolsConfig.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentBuiltinToolsConfig.java new file mode 100644 index 00000000..cd7d5dcf --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentBuiltinToolsConfig.java @@ -0,0 +1,112 @@ +package tech.easyflow.agent.config; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Agent 五类产品级内置工具的类型化配置。 + */ +public final class AgentBuiltinToolsConfig { + + /** 当前配置结构版本。 */ + public static final int SCHEMA_VERSION = 1; + + private final ToolSwitch read; + private final ToolSwitch write; + private final ToolSwitch patch; + private final ToolSwitch shell; + private final ToolSwitch artifactPublish; + + /** + * 创建内置工具配置。 + * + * @param read 读取工具配置 + * @param write 写入工具配置 + * @param patch 补丁工具配置 + * @param shell Shell 工具配置 + * @param artifactPublish 产物发布工具配置 + */ + public AgentBuiltinToolsConfig(ToolSwitch read, + ToolSwitch write, + ToolSwitch patch, + ToolSwitch shell, + ToolSwitch artifactPublish) { + this.read = read; + this.write = write; + this.patch = patch; + this.shell = shell; + this.artifactPublish = artifactPublish; + } + + /** + * 返回新 Agent 的安全默认配置。 + * + * @return 五项启用且仅 Shell 要求审批的配置 + */ + public static AgentBuiltinToolsConfig newAgentDefaults() { + return new AgentBuiltinToolsConfig( + new ToolSwitch(true, false), + new ToolSwitch(true, false), + new ToolSwitch(true, false), + new ToolSwitch(true, true), + new ToolSwitch(true, false)); + } + + /** + * 返回旧发布快照的无扩权兼容配置。 + * + * @return 五项全部禁用的配置 + */ + public static AgentBuiltinToolsConfig allDisabled() { + ToolSwitch disabled = new ToolSwitch(false, false); + return new AgentBuiltinToolsConfig(disabled, disabled, disabled, disabled, disabled); + } + + /** @return 读取工具配置 */ + public ToolSwitch read() { return read; } + /** @return 写入工具配置 */ + public ToolSwitch write() { return write; } + /** @return 补丁工具配置 */ + public ToolSwitch patch() { return patch; } + /** @return Shell 工具配置 */ + public ToolSwitch shell() { return shell; } + /** @return 产物发布工具配置 */ + public ToolSwitch artifactPublish() { return artifactPublish; } + + /** + * 转换为可写入 executionConfigJson 的稳定结构。 + * + * @return 不含权限确认临时字段的安全 Map + */ + public Map toMap() { + Map result = new LinkedHashMap<>(); + result.put("schemaVersion", SCHEMA_VERSION); + result.put("read", read.toMap()); + result.put("write", write.toMap()); + result.put("patch", patch.toMap()); + result.put("shell", shell.toMap()); + result.put("artifactPublish", artifactPublish.toMap()); + return result; + } + + /** + * 单个内置工具的启用与审批配置。 + * + * @param enabled 是否启用 + * @param approvalRequired 是否要求调用前审批 + */ + public record ToolSwitch(boolean enabled, boolean approvalRequired) { + + /** + * 转换为持久化结构。 + * + * @return 工具开关 Map + */ + public Map toMap() { + Map result = new LinkedHashMap<>(); + result.put("enabled", enabled); + result.put("approvalRequired", approvalRequired); + return result; + } + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentBuiltinToolsConfigResolver.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentBuiltinToolsConfigResolver.java new file mode 100644 index 00000000..64886ecc --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentBuiltinToolsConfigResolver.java @@ -0,0 +1,209 @@ +package tech.easyflow.agent.config; + +import org.springframework.stereotype.Component; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.service.CategoryPermissionService; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Agent 内置工具配置的默认值、兼容和权限统一解析器。 + */ +@Component +public class AgentBuiltinToolsConfigResolver { + + /** executionConfigJson 中的内置工具字段。 */ + public static final String BUILTIN_TOOLS_KEY = "builtinTools"; + /** 超级管理员关闭 Shell 审批时提交的一次性确认字段。 */ + public static final String SHELL_RISK_CONFIRMATION_KEY = "shellApprovalRiskConfirmed"; + + private final CategoryPermissionService categoryPermissionService; + + /** + * 创建解析器。 + * + * @param categoryPermissionService 平台超级管理员判定服务 + */ + public AgentBuiltinToolsConfigResolver(CategoryPermissionService categoryPermissionService) { + this.categoryPermissionService = categoryPermissionService; + } + + /** + * 为草稿详情补齐展示默认值,但不直接写回数据库。 + * + * @param source 原执行配置 + * @return 带完整五项配置的副本 + */ + public Map normalizeForDraftRead(Map source) { + return replaceBuiltinTools(source, parse(source, AgentBuiltinToolsConfig.newAgentDefaults())); + } + + /** + * 规范化一次显式草稿保存,并校验关闭 Shell 审批的权限与风险确认。 + * + * @param source 客户端提交的执行配置 + * @param existingSource 更新前执行配置;新建时为 null + * @param account 当前账号 + * @return 可持久化且已移除一次性确认字段的配置 + */ + public Map normalizeForDraftSave(Map source, + Map existingSource, + LoginAccount account) { + AgentBuiltinToolsConfig incoming = parse(source, AgentBuiltinToolsConfig.newAgentDefaults()); + AgentBuiltinToolsConfig existing = existingSource == null + ? AgentBuiltinToolsConfig.newAgentDefaults() + : parse(existingSource, AgentBuiltinToolsConfig.newAgentDefaults()); + boolean disablesShellApproval = disablesShellApproval(incoming, existing); + if (disablesShellApproval) { + if (!categoryPermissionService.isSuperAdmin(account)) { + throw new BusinessException(403, 403, "仅平台超级管理员可以关闭 Shell 调用前确认"); + } + if (!riskConfirmed(source)) { + throw new BusinessException(400, 400, "关闭 Shell 调用前确认前必须完成高风险确认"); + } + } + return replaceBuiltinTools(source, incoming); + } + + /** + * 判断保存前后是否真实发生了 Shell 审批关闭变更。 + * + * @param source 已规范化或待保存的执行配置 + * @param existingSource 保存前执行配置;新建时为 null + * @return 从需审批或禁用状态切换到启用且免审批时为 true + */ + public boolean isShellApprovalDisableTransition(Map source, + Map existingSource) { + AgentBuiltinToolsConfig incoming = parse(source, AgentBuiltinToolsConfig.newAgentDefaults()); + AgentBuiltinToolsConfig existing = existingSource == null + ? AgentBuiltinToolsConfig.newAgentDefaults() + : parse(existingSource, AgentBuiltinToolsConfig.newAgentDefaults()); + return disablesShellApproval(incoming, existing); + } + + /** + * 解析草稿运行配置;缺失时使用新 Agent 默认值。 + * + * @param source 执行配置 + * @return 类型化配置 + */ + public AgentBuiltinToolsConfig resolveDraftRuntime(Map source) { + return parse(source, AgentBuiltinToolsConfig.newAgentDefaults()); + } + + /** + * 解析发布快照;旧快照缺失内置工具字段时全部禁用。 + * + * @param source 发布快照中的执行配置 + * @return 类型化配置 + */ + public AgentBuiltinToolsConfig resolvePublishedRuntime(Map source) { + return parse(source, AgentBuiltinToolsConfig.allDisabled()); + } + + /** + * 规范化发布运行配置;旧快照缺失字段时显式写入五项禁用结果。 + * + * @param source 发布快照执行配置 + * @return 无静默扩权的完整配置副本 + */ + public Map normalizeForPublishedRuntime(Map source) { + return replaceBuiltinTools(source, resolvePublishedRuntime(source)); + } + + /** + * 判断执行配置是否显式包含内置工具结构。 + * + * @param source 执行配置 + * @return 包含时为 true + */ + public boolean hasBuiltinTools(Map source) { + return source != null && source.containsKey(BUILTIN_TOOLS_KEY); + } + + private Map replaceBuiltinTools(Map source, + AgentBuiltinToolsConfig config) { + Map result = source == null ? new LinkedHashMap<>() : new LinkedHashMap<>(source); + result.put(BUILTIN_TOOLS_KEY, config.toMap()); + return result; + } + + private AgentBuiltinToolsConfig parse(Map source, AgentBuiltinToolsConfig fallback) { + if (source == null || !source.containsKey(BUILTIN_TOOLS_KEY)) { + return fallback; + } + Map raw = requireMap(source.get(BUILTIN_TOOLS_KEY), "builtinTools 必须为对象"); + validateSchemaVersion(raw.get("schemaVersion")); + return new AgentBuiltinToolsConfig( + tool(raw, "read", fallback.read()), + tool(raw, "write", fallback.write()), + tool(raw, "patch", fallback.patch()), + tool(raw, "shell", fallback.shell()), + tool(raw, "artifactPublish", fallback.artifactPublish())); + } + + private boolean disablesShellApproval(AgentBuiltinToolsConfig incoming, + AgentBuiltinToolsConfig existing) { + return incoming.shell().enabled() + && !incoming.shell().approvalRequired() + && (existing.shell().approvalRequired() || !existing.shell().enabled()); + } + + private void validateSchemaVersion(Object value) { + if (value == null) { + return; + } + if (!(value instanceof Number number) + || number.doubleValue() != number.intValue() + || number.intValue() != AgentBuiltinToolsConfig.SCHEMA_VERSION) { + throw new BusinessException("不支持的 Agent 内置工具配置版本"); + } + } + + private AgentBuiltinToolsConfig.ToolSwitch tool(Map source, + String key, + AgentBuiltinToolsConfig.ToolSwitch fallback) { + if (!source.containsKey(key)) { + return fallback; + } + Map raw = requireMap(source.get(key), "Agent 内置工具项必须为对象: " + key); + return new AgentBuiltinToolsConfig.ToolSwitch( + booleanValue(raw, "enabled", fallback.enabled()), + booleanValue(raw, "approvalRequired", fallback.approvalRequired())); + } + + private boolean riskConfirmed(Map source) { + Map raw = mapValue(source == null ? null : source.get(BUILTIN_TOOLS_KEY)); + return raw != null && Boolean.TRUE.equals(raw.get(SHELL_RISK_CONFIRMATION_KEY)); + } + + private boolean booleanValue(Map source, String key, boolean fallback) { + if (!source.containsKey(key)) { + return fallback; + } + Object value = source.get(key); + if (value instanceof Boolean bool) { + return bool; + } + throw new BusinessException("Agent 内置工具开关必须为布尔值"); + } + + private Map requireMap(Object value, String message) { + Map mapped = mapValue(value); + if (mapped == null) { + throw new BusinessException(message); + } + return mapped; + } + + private Map mapValue(Object value) { + if (!(value instanceof Map raw)) { + return null; + } + Map result = new LinkedHashMap<>(); + raw.forEach((key, item) -> result.put(String.valueOf(key), item)); + return result; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentModuleConfig.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentModuleConfig.java index 1d9ff674..39ab0873 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentModuleConfig.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentModuleConfig.java @@ -16,7 +16,9 @@ import org.springframework.scheduling.annotation.EnableScheduling; @EnableConfigurationProperties({ AgentRuntimeProperties.class, AgentMediaProperties.class, - AgentDocumentProperties.class + AgentDocumentProperties.class, + AgentWorkspaceProperties.class, + AgentShellProperties.class }) public class AgentModuleConfig { } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentShellCommandAvailabilityReporter.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentShellCommandAvailabilityReporter.java new file mode 100644 index 00000000..b7e0a8d9 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentShellCommandAvailabilityReporter.java @@ -0,0 +1,68 @@ +package tech.easyflow.agent.config; + +import com.easyagents.agent.runtime.tool.operate.ControlledShellTool; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * 在应用启动后报告受控 Shell 固定白名单命令的实际可用性。 + */ +@Component +public class AgentShellCommandAvailabilityReporter { + + private static final Logger LOG = LoggerFactory.getLogger(AgentShellCommandAvailabilityReporter.class); + + /** + * 检查当前进程 PATH,并报告已安装与缺失的白名单命令。 + * + * @param event 应用就绪事件 + */ + @EventListener(ApplicationReadyEvent.class) + public void report(ApplicationReadyEvent event) { + List available = new ArrayList<>(); + List missing = new ArrayList<>(); + for (String command : ControlledShellTool.DEFAULT_ALLOWED_COMMANDS.stream().sorted().toList()) { + (isAvailable(command) ? available : missing).add(command); + } + LOG.info("Agent controlled Shell allowlist check completed, available={}", available); + if (!missing.isEmpty()) { + LOG.warn("Agent controlled Shell commands are unavailable in this runtime: {}", missing); + } + } + + /** + * 判断一个不含路径分隔符的固定命令是否存在于当前 PATH。 + * + * @param command 固定白名单命令 + * @return 存在可执行普通文件时为 true + */ + private boolean isAvailable(String command) { + String pathValue = System.getenv("PATH"); + if (pathValue == null || pathValue.isBlank()) { + return false; + } + for (String directory : pathValue.split(java.io.File.pathSeparator)) { + if (directory == null || directory.isBlank()) { + continue; + } + try { + Path executable = Path.of(directory).resolve(command); + if (Files.isRegularFile(executable) && Files.isExecutable(executable)) { + return true; + } + } catch (InvalidPathException ignored) { + // PATH 中的无效目录仅视为不可用,避免把宿主路径写入普通日志。 + } + } + return false; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentShellProperties.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentShellProperties.java new file mode 100644 index 00000000..df9b220d --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentShellProperties.java @@ -0,0 +1,62 @@ +package tech.easyflow.agent.config; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.unit.DataSize; + +import java.time.Duration; + +/** + * Agent 受控 Shell 的统一平台限制。 + */ +@ConfigurationProperties(prefix = "easyflow.agent.shell") +public class AgentShellProperties implements InitializingBean { + + private Duration defaultTimeout = Duration.ofSeconds(60); + private Duration maxTimeout = Duration.ofSeconds(300); + private int maxCommandLength = 4_096; + private DataSize maxOutputSize = DataSize.ofMegabytes(1); + private int maxConcurrentPerInstance = 2; + + /** @return 默认超时 */ + public Duration getDefaultTimeout() { return defaultTimeout; } + /** @param defaultTimeout 默认超时 */ + public void setDefaultTimeout(Duration defaultTimeout) { this.defaultTimeout = defaultTimeout; } + /** @return 最大超时 */ + public Duration getMaxTimeout() { return maxTimeout; } + /** @param maxTimeout 最大超时 */ + public void setMaxTimeout(Duration maxTimeout) { this.maxTimeout = maxTimeout; } + /** @return 命令最大字符数 */ + public int getMaxCommandLength() { return maxCommandLength; } + /** @param maxCommandLength 命令最大字符数 */ + public void setMaxCommandLength(int maxCommandLength) { this.maxCommandLength = maxCommandLength; } + /** @return 输出最大字节数 */ + public DataSize getMaxOutputSize() { return maxOutputSize; } + /** @param maxOutputSize 输出最大字节数 */ + public void setMaxOutputSize(DataSize maxOutputSize) { this.maxOutputSize = maxOutputSize; } + /** @return 单实例最大并发数 */ + public int getMaxConcurrentPerInstance() { return maxConcurrentPerInstance; } + /** @param maxConcurrentPerInstance 单实例最大并发数 */ + public void setMaxConcurrentPerInstance(int maxConcurrentPerInstance) { + this.maxConcurrentPerInstance = maxConcurrentPerInstance; + } + + /** + * 启动期校验 Shell 限制。 + */ + @Override + public void afterPropertiesSet() { + if (!positive(defaultTimeout) || !positive(maxTimeout) + || maxTimeout.compareTo(defaultTimeout) < 0) { + throw new IllegalStateException("Shell 最大超时必须大于等于正值默认超时"); + } + if (maxCommandLength <= 0 || maxOutputSize == null || maxOutputSize.toBytes() <= 0 + || maxConcurrentPerInstance <= 0) { + throw new IllegalStateException("Shell 命令长度、输出大小和并发数必须为正值"); + } + } + + private boolean positive(Duration value) { + return value != null && !value.isZero() && !value.isNegative(); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentWorkspaceProperties.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentWorkspaceProperties.java new file mode 100644 index 00000000..7b45ed05 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/config/AgentWorkspaceProperties.java @@ -0,0 +1,84 @@ +package tech.easyflow.agent.config; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.StringUtils; +import org.springframework.util.unit.DataSize; + +import java.time.Duration; + +/** + * Agent 单会话工作区的路径、配额与保留配置。 + */ +@ConfigurationProperties(prefix = "easyflow.agent.workspace") +public class AgentWorkspaceProperties implements InitializingBean { + + private String root = "./agent-workspaces"; + private DataSize maxTotalSize = DataSize.ofMegabytes(512); + private DataSize maxSingleFileSize = DataSize.ofMegabytes(100); + private int maxFileCount = 2_000; + private DataSize maxReadSize = DataSize.ofMegabytes(2); + private Duration retention = Duration.ofHours(24); + private Duration cleanupInterval = Duration.ofMinutes(30); + + /** @return 工作区根目录 */ + public String getRoot() { return root; } + /** @param root 工作区根目录 */ + public void setRoot(String root) { this.root = root; } + /** @return 单会话工作区总量上限 */ + public DataSize getMaxTotalSize() { return maxTotalSize; } + /** @param maxTotalSize 单会话工作区总量上限 */ + public void setMaxTotalSize(DataSize maxTotalSize) { this.maxTotalSize = maxTotalSize; } + /** @return 单文件大小上限 */ + public DataSize getMaxSingleFileSize() { return maxSingleFileSize; } + /** @param maxSingleFileSize 单文件大小上限 */ + public void setMaxSingleFileSize(DataSize maxSingleFileSize) { this.maxSingleFileSize = maxSingleFileSize; } + /** @return 文件数量上限 */ + public int getMaxFileCount() { return maxFileCount; } + /** @param maxFileCount 文件数量上限 */ + public void setMaxFileCount(int maxFileCount) { this.maxFileCount = maxFileCount; } + /** @return 单次读取大小上限 */ + public DataSize getMaxReadSize() { return maxReadSize; } + /** @param maxReadSize 单次读取大小上限 */ + public void setMaxReadSize(DataSize maxReadSize) { this.maxReadSize = maxReadSize; } + /** @return 工作区保留期 */ + public Duration getRetention() { return retention; } + /** @param retention 工作区保留期 */ + public void setRetention(Duration retention) { this.retention = retention; } + /** @return 清理周期 */ + public Duration getCleanupInterval() { return cleanupInterval; } + /** @param cleanupInterval 清理周期 */ + public void setCleanupInterval(Duration cleanupInterval) { this.cleanupInterval = cleanupInterval; } + + /** + * 启动期校验工作区配置,避免以无界或互相矛盾的限制启动。 + */ + @Override + public void afterPropertiesSet() { + if (!StringUtils.hasText(root)) { + throw new IllegalStateException("easyflow.agent.workspace.root 不能为空"); + } + requirePositive(maxTotalSize, "max-total-size"); + requirePositive(maxSingleFileSize, "max-single-file-size"); + requirePositive(maxReadSize, "max-read-size"); + if (maxTotalSize.toBytes() < maxSingleFileSize.toBytes()) { + throw new IllegalStateException("工作区总量上限不能小于单文件大小上限"); + } + if (maxReadSize.toBytes() > maxSingleFileSize.toBytes()) { + throw new IllegalStateException("工作区读取上限不能大于单文件大小上限"); + } + if (maxFileCount <= 0 || !positive(retention) || !positive(cleanupInterval)) { + throw new IllegalStateException("工作区文件数量、保留期和清理周期必须为正值"); + } + } + + private void requirePositive(DataSize value, String name) { + if (value == null || value.toBytes() <= 0) { + throw new IllegalStateException("easyflow.agent.workspace." + name + " 必须为正值"); + } + } + + private boolean positive(Duration value) { + return value != null && !value.isZero() && !value.isNegative(); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentApprovalRoute.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentApprovalRoute.java new file mode 100644 index 00000000..7c03dd9b --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentApprovalRoute.java @@ -0,0 +1,46 @@ +package tech.easyflow.agent.distributed; + +/** + * 不透明审批 ID 对应的内部恢复路由。 + */ +public class AgentApprovalRoute { + + private String requestId; + private String resumeToken; + + /** + * 获取内部请求 ID。 + * + * @return 请求 ID + */ + public String getRequestId() { + return requestId; + } + + /** + * 设置内部请求 ID。 + * + * @param requestId 请求 ID + */ + public void setRequestId(String requestId) { + this.requestId = requestId; + } + + /** + * 获取内部恢复令牌。 + * + * @return 恢复令牌 + */ + public String getResumeToken() { + return resumeToken; + } + + /** + * 设置内部恢复令牌。 + * + * @param resumeToken 恢复令牌 + */ + public void setResumeToken(String resumeToken) { + this.resumeToken = resumeToken; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandConsumer.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandConsumer.java index 2e235b87..3178deca 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandConsumer.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandConsumer.java @@ -84,12 +84,25 @@ public class AgentRuntimeCommandConsumer implements MQConsumerHandler { } try { if (command.getAction() == AgentRuntimeCommandAction.APPROVE) { - agentRunService.approveRuntimeLocal( - command.getRequestId(), command.getResumeToken(), command.getOperatorId(), command.getUserId()); + if (command.getApprovalId() == null || command.getApprovalId().isBlank()) { + agentRunService.approveRuntimeLocal( + command.getRequestId(), command.getResumeToken(), + command.getOperatorId(), command.getUserId()); + } else { + agentRunService.approveAguiRuntimeLocal( + command.getRequestId(), command.getResumeToken(), command.getApprovalId(), + command.getOperatorId(), command.getUserId()); + } } else if (command.getAction() == AgentRuntimeCommandAction.REJECT) { - agentRunService.rejectRuntimeLocal( - command.getRequestId(), command.getResumeToken(), command.getReason(), - command.getOperatorId(), command.getUserId()); + if (command.getApprovalId() == null || command.getApprovalId().isBlank()) { + agentRunService.rejectRuntimeLocal( + command.getRequestId(), command.getResumeToken(), command.getReason(), + command.getOperatorId(), command.getUserId()); + } else { + agentRunService.rejectAguiRuntimeLocal( + command.getRequestId(), command.getResumeToken(), command.getApprovalId(), command.getReason(), + command.getOperatorId(), command.getUserId()); + } } else if (command.getAction() == AgentRuntimeCommandAction.EXPIRE) { agentRunService.expireApprovalLocal( command.getRequestId(), command.getResumeToken(), command.getReason()); diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandMessage.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandMessage.java index 687ddd9d..ec3b7d8d 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandMessage.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandMessage.java @@ -11,6 +11,7 @@ public class AgentRuntimeCommandMessage { private String commandId; private String requestId; private String resumeToken; + private String approvalId; private AgentRuntimeCommandAction action; private String reason; private BigInteger operatorId; @@ -43,6 +44,24 @@ public class AgentRuntimeCommandMessage { this.resumeToken = resumeToken; } + /** + * 获取 AG-UI 不透明审批 ID。 + * + * @return 审批 ID + */ + public String getApprovalId() { + return approvalId; + } + + /** + * 设置 AG-UI 不透明审批 ID。 + * + * @param approvalId 审批 ID + */ + public void setApprovalId(String approvalId) { + this.approvalId = approvalId; + } + public AgentRuntimeCommandAction getAction() { return action; } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandProducer.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandProducer.java index 825708a4..f87a63c3 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandProducer.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeCommandProducer.java @@ -70,7 +70,29 @@ public class AgentRuntimeCommandProducer { BigInteger operatorId, String userId) { sendAndWait( - targetNodeId, requestId, resumeToken, null, + targetNodeId, requestId, resumeToken, null, null, + AgentRuntimeCommandAction.APPROVE, null, operatorId, userId + ); + } + + /** + * 投递携带 AG-UI 审批 ID 的远程批准命令。 + * + * @param targetNodeId 目标节点 ID + * @param requestId 请求 ID + * @param resumeToken 恢复令牌 + * @param approvalId 不透明审批 ID + * @param operatorId 操作人 ID + * @param userId 用户 ID + */ + public void sendApprove(String targetNodeId, + String requestId, + String resumeToken, + String approvalId, + BigInteger operatorId, + String userId) { + sendAndWait( + targetNodeId, requestId, resumeToken, null, approvalId, AgentRuntimeCommandAction.APPROVE, null, operatorId, userId ); } @@ -92,7 +114,31 @@ public class AgentRuntimeCommandProducer { BigInteger operatorId, String userId) { sendAndWait( - targetNodeId, requestId, resumeToken, null, + targetNodeId, requestId, resumeToken, null, null, + AgentRuntimeCommandAction.REJECT, reason, operatorId, userId + ); + } + + /** + * 投递携带 AG-UI 审批 ID 的远程拒绝命令。 + * + * @param targetNodeId 目标节点 ID + * @param requestId 请求 ID + * @param resumeToken 恢复令牌 + * @param approvalId 不透明审批 ID + * @param reason 拒绝原因 + * @param operatorId 操作人 ID + * @param userId 用户 ID + */ + public void sendReject(String targetNodeId, + String requestId, + String resumeToken, + String approvalId, + String reason, + BigInteger operatorId, + String userId) { + sendAndWait( + targetNodeId, requestId, resumeToken, null, approvalId, AgentRuntimeCommandAction.REJECT, reason, operatorId, userId ); } @@ -110,7 +156,7 @@ public class AgentRuntimeCommandProducer { String resumeToken, String reason) { sendAndWait( - targetNodeId, requestId, resumeToken, null, + targetNodeId, requestId, resumeToken, null, null, AgentRuntimeCommandAction.EXPIRE, reason, null, null ); } @@ -124,7 +170,7 @@ public class AgentRuntimeCommandProducer { */ public void sendCancelAgent(String targetNodeId, String agentId, String reason) { sendAndWait( - targetNodeId, null, null, agentId, + targetNodeId, null, null, agentId, null, AgentRuntimeCommandAction.CANCEL_AGENT, reason, null, null ); } @@ -136,6 +182,7 @@ public class AgentRuntimeCommandProducer { * @param requestId 请求 ID * @param resumeToken 恢复令牌 * @param agentId Agent ID + * @param approvalId AG-UI 不透明审批 ID * @param action 命令动作 * @param reason 操作原因 * @param operatorId 操作人 ID @@ -146,6 +193,7 @@ public class AgentRuntimeCommandProducer { String requestId, String resumeToken, String agentId, + String approvalId, AgentRuntimeCommandAction action, String reason, BigInteger operatorId, @@ -158,6 +206,7 @@ public class AgentRuntimeCommandProducer { command.setRequestId(requestId); command.setResumeToken(resumeToken); command.setAgentId(agentId); + command.setApprovalId(approvalId); command.setAction(action); command.setReason(reason); command.setOperatorId(operatorId); diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeRouteRegistry.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeRouteRegistry.java index e6c679ad..15f66f89 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeRouteRegistry.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/distributed/AgentRuntimeRouteRegistry.java @@ -23,6 +23,7 @@ public class AgentRuntimeRouteRegistry { private static final String REQUEST_ROUTE_PREFIX = "easyflow:agent:runtime:request:"; private static final String TOKEN_ROUTE_PREFIX = "easyflow:agent:runtime:resume-token:"; + private static final String APPROVAL_ROUTE_PREFIX = "easyflow:agent:runtime:approval:"; private static final String NODE_HEARTBEAT_PREFIX = "easyflow:agent:runtime:node:"; private static final String AGENT_RUNS_PREFIX = "easyflow:agent:runtime:agent:"; @@ -89,6 +90,30 @@ public class AgentRuntimeRouteRegistry { stringRedisTemplate.opsForValue().set(tokenKey(resumeToken), requestId, properties.getRouteTtl()); } + /** + * 注册不透明审批 ID 与内部恢复目标的关系。 + * + * @param approvalId 公开审批 ID + * @param requestId 内部请求 ID + * @param resumeToken 内部恢复令牌 + */ + public void registerApproval(String approvalId, String requestId, String resumeToken) { + if (approvalId == null || approvalId.isBlank() + || requestId == null || requestId.isBlank() + || resumeToken == null || resumeToken.isBlank()) { + return; + } + AgentApprovalRoute route = new AgentApprovalRoute(); + route.setRequestId(requestId); + route.setResumeToken(resumeToken); + try { + stringRedisTemplate.opsForValue().set( + approvalKey(approvalId), objectMapper.writeValueAsString(route), properties.getRouteTtl()); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Agent 审批路由序列化失败", exception); + } + } + /** * 查询请求 ID 所属节点。 * @@ -130,6 +155,27 @@ public class AgentRuntimeRouteRegistry { return stringRedisTemplate.opsForValue().get(tokenKey(resumeToken)); } + /** + * 根据公开审批 ID 查询内部恢复目标。 + * + * @param approvalId 公开审批 ID + * @return 审批恢复目标,不存在时返回 null + */ + public AgentApprovalRoute findApproval(String approvalId) { + if (approvalId == null || approvalId.isBlank()) { + return null; + } + String value = stringRedisTemplate.opsForValue().get(approvalKey(approvalId)); + if (value == null || value.isBlank()) { + return null; + } + try { + return objectMapper.readValue(value, AgentApprovalRoute.class); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Agent 审批路由反序列化失败", exception); + } + } + /** * 查询指定 Agent 当前活跃运行所在的节点。 * @@ -192,6 +238,18 @@ public class AgentRuntimeRouteRegistry { deleteQuietly(tokenKey(resumeToken)); } + /** + * 删除公开审批 ID 的内部路由。 + * + * @param approvalId 公开审批 ID + */ + public void removeApproval(String approvalId) { + if (approvalId == null || approvalId.isBlank()) { + return; + } + deleteQuietly(approvalKey(approvalId)); + } + /** * 获取当前节点 ID。 * @@ -241,6 +299,10 @@ public class AgentRuntimeRouteRegistry { return TOKEN_ROUTE_PREFIX + resumeToken; } + private String approvalKey(String approvalId) { + return APPROVAL_ROUTE_PREFIX + approvalId; + } + private String nodeKey(String nodeId) { return NODE_HEARTBEAT_PREFIX + nodeId; } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/Agent.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/Agent.java index a342e383..3fc4239f 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/Agent.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/Agent.java @@ -71,6 +71,8 @@ public class Agent extends DateEntity implements VisibilityResource, Serializabl private List toolBindings; @Column(ignore = true) private List knowledgeBindings; + @Column(ignore = true) + private List skillBindings; public BigInteger getId() { return id; } public void setId(BigInteger id) { this.id = id; } @@ -144,4 +146,8 @@ public class Agent extends DateEntity implements VisibilityResource, Serializabl public void setToolBindings(List toolBindings) { this.toolBindings = toolBindings; } public List getKnowledgeBindings() { return knowledgeBindings; } public void setKnowledgeBindings(List knowledgeBindings) { this.knowledgeBindings = knowledgeBindings; } + /** @return Skill 绑定 */ + public List getSkillBindings() { return skillBindings; } + /** @param skillBindings Skill 绑定 */ + public void setSkillBindings(List skillBindings) { this.skillBindings = skillBindings; } } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentArtifact.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentArtifact.java new file mode 100644 index 00000000..610db58f --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentArtifact.java @@ -0,0 +1,168 @@ +package tech.easyflow.agent.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import tech.easyflow.common.entity.DateEntity; + +import java.io.Serializable; +import java.math.BigInteger; +import java.util.Date; + +/** + * Agent 正式产物的对象存储状态账本。 + */ +@Table("tb_agent_artifact") +public class AgentArtifact extends DateEntity implements Serializable { + + @Id(keyType = KeyType.Generator, value = "snowFlakeId") + private BigInteger id; + private String artifactId; + @Column(tenantId = true) + private BigInteger tenantId; + private BigInteger agentId; + private BigInteger ownerUserId; + private String chatMode; + private BigInteger chatSessionId; + private String runtimeSessionId; + private String requestId; + private BigInteger roundId; + private Integer variantIndex; + private String toolCallId; + private String fileName; + private String mimeType; + private Long sizeBytes; + private String sha256; + private String storagePlatform; + private String objectKey; + private String storageEtag; + private String status; + private Date expiresAt; + private Integer retryCount; + private Date nextRetryAt; + private String lastErrorCode; + private Date created; + private BigInteger createdBy; + private Date modified; + private BigInteger modifiedBy; + @Column(isLogicDelete = true) + private Integer isDeleted; + + /** @return 内部主键 */ + public BigInteger getId() { return id; } + /** @param id 内部主键 */ + public void setId(BigInteger id) { this.id = id; } + /** @return 对外稳定产物 ID */ + public String getArtifactId() { return artifactId; } + /** @param artifactId 对外稳定产物 ID */ + public void setArtifactId(String artifactId) { this.artifactId = artifactId; } + /** @return 租户 ID */ + public BigInteger getTenantId() { return tenantId; } + /** @param tenantId 租户 ID */ + public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; } + /** @return Agent ID */ + public BigInteger getAgentId() { return agentId; } + /** @param agentId Agent ID */ + public void setAgentId(BigInteger agentId) { this.agentId = agentId; } + /** @return 所有者用户 ID */ + public BigInteger getOwnerUserId() { return ownerUserId; } + /** @param ownerUserId 所有者用户 ID */ + public void setOwnerUserId(BigInteger ownerUserId) { this.ownerUserId = ownerUserId; } + /** @return 聊天模式 */ + public String getChatMode() { return chatMode; } + /** @param chatMode 聊天模式 */ + public void setChatMode(String chatMode) { this.chatMode = chatMode; } + /** @return 正式聊天会话 ID */ + public BigInteger getChatSessionId() { return chatSessionId; } + /** @param chatSessionId 正式聊天会话 ID */ + public void setChatSessionId(BigInteger chatSessionId) { this.chatSessionId = chatSessionId; } + /** @return Runtime 会话 ID */ + public String getRuntimeSessionId() { return runtimeSessionId; } + /** @param runtimeSessionId Runtime 会话 ID */ + public void setRuntimeSessionId(String runtimeSessionId) { this.runtimeSessionId = runtimeSessionId; } + /** @return 运行请求 ID */ + public String getRequestId() { return requestId; } + /** @param requestId 运行请求 ID */ + public void setRequestId(String requestId) { this.requestId = requestId; } + /** @return 聊天轮次 ID */ + public BigInteger getRoundId() { return roundId; } + /** @param roundId 聊天轮次 ID */ + public void setRoundId(BigInteger roundId) { this.roundId = roundId; } + /** @return 正式聊天答案版本序号 */ + public Integer getVariantIndex() { return variantIndex; } + /** @param variantIndex 正式聊天答案版本序号 */ + public void setVariantIndex(Integer variantIndex) { this.variantIndex = variantIndex; } + /** @return 工具调用 ID */ + public String getToolCallId() { return toolCallId; } + /** @param toolCallId 工具调用 ID */ + public void setToolCallId(String toolCallId) { this.toolCallId = toolCallId; } + /** @return 安全展示文件名 */ + public String getFileName() { return fileName; } + /** @param fileName 安全展示文件名 */ + public void setFileName(String fileName) { this.fileName = fileName; } + /** @return MIME 类型 */ + public String getMimeType() { return mimeType; } + /** @param mimeType MIME 类型 */ + public void setMimeType(String mimeType) { this.mimeType = mimeType; } + /** @return 字节数 */ + public Long getSizeBytes() { return sizeBytes; } + /** @param sizeBytes 字节数 */ + public void setSizeBytes(Long sizeBytes) { this.sizeBytes = sizeBytes; } + /** @return SHA-256 */ + public String getSha256() { return sha256; } + /** @param sha256 SHA-256 */ + public void setSha256(String sha256) { this.sha256 = sha256; } + /** @return 内部存储平台 */ + public String getStoragePlatform() { return storagePlatform; } + /** @param storagePlatform 内部存储平台 */ + public void setStoragePlatform(String storagePlatform) { this.storagePlatform = storagePlatform; } + /** @return 内部对象键 */ + public String getObjectKey() { return objectKey; } + /** @param objectKey 内部对象键 */ + public void setObjectKey(String objectKey) { this.objectKey = objectKey; } + /** @return 对象 ETag */ + public String getStorageEtag() { return storageEtag; } + /** @param storageEtag 对象 ETag */ + public void setStorageEtag(String storageEtag) { this.storageEtag = storageEtag; } + /** @return 账本状态 */ + public String getStatus() { return status; } + /** @param status 账本状态 */ + public void setStatus(String status) { this.status = status; } + /** @return 过期时间 */ + public Date getExpiresAt() { return expiresAt; } + /** @param expiresAt 过期时间 */ + public void setExpiresAt(Date expiresAt) { this.expiresAt = expiresAt; } + /** @return 重试次数 */ + public Integer getRetryCount() { return retryCount; } + /** @param retryCount 重试次数 */ + public void setRetryCount(Integer retryCount) { this.retryCount = retryCount; } + /** @return 下次重试时间 */ + public Date getNextRetryAt() { return nextRetryAt; } + /** @param nextRetryAt 下次重试时间 */ + public void setNextRetryAt(Date nextRetryAt) { this.nextRetryAt = nextRetryAt; } + /** @return 最近错误码 */ + public String getLastErrorCode() { return lastErrorCode; } + /** @param lastErrorCode 最近错误码 */ + public void setLastErrorCode(String lastErrorCode) { this.lastErrorCode = lastErrorCode; } + /** @return 创建时间 */ + @Override public Date getCreated() { return created; } + /** @param created 创建时间 */ + @Override public void setCreated(Date created) { this.created = created; } + /** @return 创建人 */ + public BigInteger getCreatedBy() { return createdBy; } + /** @param createdBy 创建人 */ + public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; } + /** @return 修改时间 */ + @Override public Date getModified() { return modified; } + /** @param modified 修改时间 */ + @Override public void setModified(Date modified) { this.modified = modified; } + /** @return 修改人 */ + public BigInteger getModifiedBy() { return modifiedBy; } + /** @param modifiedBy 修改人 */ + public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; } + /** @return 逻辑删除标记 */ + public Integer getIsDeleted() { return isDeleted; } + /** @param isDeleted 逻辑删除标记 */ + public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentSkillBinding.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentSkillBinding.java new file mode 100644 index 00000000..abbd5f11 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/entity/AgentSkillBinding.java @@ -0,0 +1,88 @@ +package tech.easyflow.agent.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import com.mybatisflex.core.handler.FastjsonTypeHandler; +import tech.easyflow.common.entity.DateEntity; + +import java.io.Serializable; +import java.math.BigInteger; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Agent 与已发布 Skill 的原子草稿绑定。 + */ +@Table("tb_agent_skill_binding") +public class AgentSkillBinding extends DateEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id(keyType = KeyType.Generator, value = "snowFlakeId") + private BigInteger id; + @Column(tenantId = true) + private BigInteger tenantId; + private BigInteger agentId; + private BigInteger skillId; + private Integer sortNo; + private Date created; + private BigInteger createdBy; + private Date modified; + private BigInteger modifiedBy; + @Column(ignore = true, typeHandler = FastjsonTypeHandler.class) + private Map resourceSnapshot = new LinkedHashMap<>(); + @Column(ignore = true, typeHandler = FastjsonTypeHandler.class) + private Map resourceSummary = new LinkedHashMap<>(); + + /** @return 绑定 ID */ + public BigInteger getId() { return id; } + /** @param id 绑定 ID */ + public void setId(BigInteger id) { this.id = id; } + /** @return 租户 ID */ + public BigInteger getTenantId() { return tenantId; } + /** @param tenantId 租户 ID */ + public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; } + /** @return Agent ID */ + public BigInteger getAgentId() { return agentId; } + /** @param agentId Agent ID */ + public void setAgentId(BigInteger agentId) { this.agentId = agentId; } + /** @return Skill ID */ + public BigInteger getSkillId() { return skillId; } + /** @param skillId Skill ID */ + public void setSkillId(BigInteger skillId) { this.skillId = skillId; } + /** @return 排序号 */ + public Integer getSortNo() { return sortNo; } + /** @param sortNo 排序号 */ + public void setSortNo(Integer sortNo) { this.sortNo = sortNo; } + /** @return 创建时间 */ + @Override public Date getCreated() { return created; } + /** @param created 创建时间 */ + @Override public void setCreated(Date created) { this.created = created; } + /** @return 创建人 */ + public BigInteger getCreatedBy() { return createdBy; } + /** @param createdBy 创建人 */ + public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; } + /** @return 修改时间 */ + @Override public Date getModified() { return modified; } + /** @param modified 修改时间 */ + @Override public void setModified(Date modified) { this.modified = modified; } + /** @return 修改人 */ + public BigInteger getModifiedBy() { return modifiedBy; } + /** @param modifiedBy 修改人 */ + public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; } + /** @return Agent 内部冻结 Skill 运行快照 */ + public Map getResourceSnapshot() { return resourceSnapshot; } + /** @param resourceSnapshot Agent 内部冻结 Skill 运行快照 */ + public void setResourceSnapshot(Map resourceSnapshot) { + this.resourceSnapshot = resourceSnapshot == null ? new LinkedHashMap<>() : resourceSnapshot; + } + /** @return 脱敏 Skill 摘要 */ + public Map getResourceSummary() { return resourceSummary; } + /** @param resourceSummary 脱敏 Skill 摘要 */ + public void setResourceSummary(Map resourceSummary) { + this.resourceSummary = resourceSummary == null ? new LinkedHashMap<>() : resourceSummary; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentArtifactMapper.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentArtifactMapper.java new file mode 100644 index 00000000..f44dc04e --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentArtifactMapper.java @@ -0,0 +1,41 @@ +package tech.easyflow.agent.mapper; + +import com.mybatisflex.core.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import tech.easyflow.agent.entity.AgentArtifact; + +import java.util.List; + +/** + * Agent Artifact 状态账本 Mapper。 + */ +public interface AgentArtifactMapper extends BaseMapper { + + /** + * 有界查询正式会话已删除、缺失或归属不一致的 Artifact。 + * + * @param limit 最大返回数量 + * @return 待补偿删除的 Artifact + */ + @Select(""" + SELECT artifact.* + FROM tb_agent_artifact artifact + WHERE artifact.is_deleted = 0 + AND artifact.chat_mode = 'FORMAL' + AND artifact.status IN ('PUBLISHING', 'AVAILABLE', 'FAILED', 'DELETE_FAILED') + AND NOT EXISTS ( + SELECT 1 + FROM chat_session chat + WHERE chat.id = artifact.chat_session_id + AND chat.is_deleted = 0 + AND chat.assistant_code = 'AGENT' + AND chat.tenant_id = artifact.tenant_id + AND chat.user_id = artifact.owner_user_id + AND chat.assistant_id = artifact.agent_id + ) + ORDER BY artifact.id + LIMIT #{limit} + """) + List selectOrphanedFormalArtifacts(@Param("limit") int limit); +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentSkillBindingMapper.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentSkillBindingMapper.java new file mode 100644 index 00000000..70fa8c2e --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/mapper/AgentSkillBindingMapper.java @@ -0,0 +1,10 @@ +package tech.easyflow.agent.mapper; + +import com.mybatisflex.core.BaseMapper; +import tech.easyflow.agent.entity.AgentSkillBinding; + +/** + * Agent Skill 绑定 Mapper。 + */ +public interface AgentSkillBindingMapper extends BaseMapper { +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandler.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandler.java index ed99aae6..fc4d887a 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandler.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandler.java @@ -9,11 +9,13 @@ import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry; import tech.easyflow.agent.entity.Agent; import tech.easyflow.agent.entity.AgentKnowledgeBinding; import tech.easyflow.agent.entity.AgentToolBinding; +import tech.easyflow.agent.entity.AgentSkillBinding; import tech.easyflow.agent.runtime.AgentRunRegistry; import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService; import tech.easyflow.agent.service.AgentKnowledgeBindingService; import tech.easyflow.agent.service.AgentService; import tech.easyflow.agent.service.AgentToolBindingService; +import tech.easyflow.agent.service.AgentSkillBindingService; import tech.easyflow.agent.support.AgentBindingLockExecutor; import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.ai.publish.AbstractAiResourceLifecycleHandler; @@ -38,6 +40,7 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand private final AgentService agentService; private final AgentToolBindingService agentToolBindingService; private final AgentKnowledgeBindingService agentKnowledgeBindingService; + private final AgentSkillBindingService agentSkillBindingService; private final ResourceAccessService resourceAccessService; private final AgentBindingLockExecutor agentBindingLockExecutor; private final AgentRunRegistry agentRunRegistry; @@ -53,6 +56,7 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand * @param agentService Agent 服务 * @param agentToolBindingService Agent 工具绑定服务 * @param agentKnowledgeBindingService Agent 知识库绑定服务 + * @param agentSkillBindingService Agent Skill 绑定服务 * @param resourceAccessService 资源访问服务 * @param agentBindingLockExecutor Agent 配置锁执行器 * @param agentRunRegistry Agent 运行态注册表 @@ -65,6 +69,7 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand AgentService agentService, AgentToolBindingService agentToolBindingService, AgentKnowledgeBindingService agentKnowledgeBindingService, + AgentSkillBindingService agentSkillBindingService, ResourceAccessService resourceAccessService, AgentBindingLockExecutor agentBindingLockExecutor, AgentRunRegistry agentRunRegistry, @@ -75,6 +80,7 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand this.agentService = agentService; this.agentToolBindingService = agentToolBindingService; this.agentKnowledgeBindingService = agentKnowledgeBindingService; + this.agentSkillBindingService = agentSkillBindingService; this.resourceAccessService = resourceAccessService; this.agentBindingLockExecutor = agentBindingLockExecutor; this.agentRunRegistry = agentRunRegistry; @@ -196,6 +202,8 @@ public class AgentApprovalSubjectHandler extends AbstractAiResourceLifecycleHand QueryWrapper.create().eq(AgentToolBinding::getAgentId, resourceId)); agentKnowledgeBindingService.remove( QueryWrapper.create().eq(AgentKnowledgeBinding::getAgentId, resourceId)); + agentSkillBindingService.remove( + QueryWrapper.create().eq(AgentSkillBinding::getAgentId, resourceId)); agentService.removeById(resourceId); return null; }); diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentDraftChatRequest.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentDraftChatRequest.java index 5da83fa2..1621df19 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentDraftChatRequest.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentDraftChatRequest.java @@ -3,6 +3,7 @@ package tech.easyflow.agent.runtime; import tech.easyflow.agent.entity.Agent; import tech.easyflow.agent.entity.AgentKnowledgeBinding; import tech.easyflow.agent.entity.AgentToolBinding; +import tech.easyflow.agent.entity.AgentSkillBinding; import java.util.List; import java.util.ArrayList; @@ -15,6 +16,7 @@ public class AgentDraftChatRequest { private Agent agent; private List toolBindings; private List knowledgeBindings; + private List skillBindings; private String sessionId; private String prompt; private List imageUploadIds = new ArrayList<>(); @@ -74,6 +76,24 @@ public class AgentDraftChatRequest { this.knowledgeBindings = knowledgeBindings; } + /** + * 获取 Skill 绑定快照。 + * + * @return Skill 绑定快照 + */ + public List getSkillBindings() { + return skillBindings; + } + + /** + * 设置 Skill 绑定快照。 + * + * @param skillBindings Skill 绑定快照 + */ + public void setSkillBindings(List skillBindings) { + this.skillBindings = skillBindings; + } + /** * 获取草稿试运行会话 ID。 * diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunRegistry.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunRegistry.java index fca248c4..bac8b322 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunRegistry.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunRegistry.java @@ -10,11 +10,14 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import reactor.core.Disposable; import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry; +import tech.easyflow.agent.distributed.AgentApprovalRoute; import tech.easyflow.agent.runtime.lock.AgentRunLock; import tech.easyflow.common.web.exceptions.BusinessException; -import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter; +import tech.easyflow.agent.runtime.output.AgentRunOutput; import tech.easyflow.core.runtime.ChatAssistantAccumulator; import tech.easyflow.core.runtime.ChatRuntimeContext; +import tech.easyflow.core.chat.protocol.ChatDomain; +import tech.easyflow.core.chat.protocol.ChatType; import java.util.ArrayList; import java.util.Map; @@ -23,6 +26,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; +import java.util.UUID; /** * Agent 运行态注册表。 @@ -36,6 +40,8 @@ public class AgentRunRegistry { private final Map sessionRuns = new ConcurrentHashMap<>(); private final Map resumeTokenIndex = new ConcurrentHashMap<>(); private final Map> requestTokens = new ConcurrentHashMap<>(); + private final Map approvalTargets = new ConcurrentHashMap<>(); + private final Map> requestApprovals = new ConcurrentHashMap<>(); private final Map owners = new ConcurrentHashMap<>(); private AgentRuntimeRouteRegistry routeRegistry; @@ -102,6 +108,16 @@ public class AgentRunRegistry { return requestId == null ? null : runs.get(requestId); } + /** + * 判断指定 Runtime 会话当前是否仍有活动运行。 + * + * @param sessionId Runtime 会话 ID + * @return 有活动运行时为 true + */ + public boolean hasActiveSession(String sessionId) { + return sessionId != null && sessionRuns.containsKey(sessionId); + } + /** * 取消并移除指定会话当前活跃运行。 * @@ -174,6 +190,121 @@ public class AgentRunRegistry { } } + /** + * 为内部恢复目标注册随机且不可推测的公开审批 ID。 + * + * @param requestId 内部请求 ID + * @param resumeToken 内部恢复令牌 + * @return 公开审批 ID + */ + public String registerApproval(String requestId, String resumeToken) { + if (requestId == null || requestId.isBlank() || resumeToken == null || resumeToken.isBlank()) { + throw new BusinessException("Agent 审批恢复目标不能为空"); + } + String approvalId = "approval_" + UUID.randomUUID(); + ApprovalTarget target = new ApprovalTarget(requestId, resumeToken); + approvalTargets.put(approvalId, target); + requestApprovals.computeIfAbsent(requestId, ignored -> ConcurrentHashMap.newKeySet()).add(approvalId); + if (routeRegistry != null) { + routeRegistry.registerApproval(approvalId, requestId, resumeToken); + } + return approvalId; + } + + /** + * 解析公开审批 ID 对应的内部恢复目标。 + * + * @param approvalId 公开审批 ID + * @return 内部恢复目标 + */ + public ApprovalTarget resolveApproval(String approvalId) { + if (approvalId == null || approvalId.isBlank()) { + throw new BusinessException("Agent 审批 ID 不能为空"); + } + ApprovalTarget local = approvalTargets.get(approvalId); + if (local != null) { + return local; + } + AgentApprovalRoute route = routeRegistry == null ? null : routeRegistry.findApproval(approvalId); + if (route == null || route.getRequestId() == null || route.getResumeToken() == null) { + throw new BusinessException("Agent 审批请求不存在或已失效"); + } + return new ApprovalTarget(route.getRequestId(), route.getResumeToken()); + } + + /** + * 根据当前节点的内部恢复目标查询公开审批 ID。 + * + * @param requestId 内部请求 ID + * @param resumeToken 内部恢复令牌 + * @return 公开审批 ID,不存在时返回 null + */ + public String findApprovalId(String requestId, String resumeToken) { + Set approvals = requestApprovals.get(requestId); + if (approvals == null || approvals.isEmpty()) { + return null; + } + for (String approvalId : approvals) { + ApprovalTarget target = approvalTargets.get(approvalId); + if (target != null && java.util.Objects.equals(resumeToken, target.resumeToken())) { + return approvalId; + } + } + return null; + } + + /** + * 校验本节点审批目标归属。 + * + * @param approvalId 公开审批 ID + * @param userId 当前用户 ID + */ + public void assertApprovalOwner(String approvalId, String userId) { + ApprovalTarget target = resolveApproval(approvalId); + if (runs.containsKey(target.requestId())) { + assertOwner(target.requestId(), userId); + } + } + + /** + * 清理已经消费的公开审批 ID。 + * + * @param approvalId 公开审批 ID + */ + public void removeApproval(String approvalId) { + ApprovalTarget target = approvalTargets.remove(approvalId); + if (target != null) { + Set approvals = requestApprovals.get(target.requestId()); + if (approvals != null) { + approvals.remove(approvalId); + } + } + if (routeRegistry != null) { + routeRegistry.removeApproval(approvalId); + } + } + + /** + * 在恢复 Runtime 前向原连接发送审批决议。 + * + * @param approvalId 公开审批 ID + * @param status 决议状态 + * @param reason 拒绝原因 + * @return 本节点存在连接且发送成功时为 true + */ + public boolean emitApprovalResolved(String approvalId, String status, String reason) { + ApprovalTarget target = resolveApproval(approvalId); + AgentRunContext context = runs.get(target.requestId()); + if (context == null) { + return false; + } + Map payload = new java.util.LinkedHashMap<>(); + payload.put("approvalId", approvalId); + payload.put("status", status); + payload.put("reason", reason); + return context.runOutput().emitViewEvent(ChatDomain.TOOL, ChatType.FORM_CANCEL, payload); + } + /** * 运行结束后移除运行态。 * @@ -199,6 +330,15 @@ public class AgentRunRegistry { } }); } + Set approvals = requestApprovals.remove(requestId); + if (approvals != null) { + approvals.forEach(approvalId -> { + approvalTargets.remove(approvalId); + if (routeRegistry != null) { + routeRegistry.removeApproval(approvalId); + } + }); + } if (routeRegistry != null) { routeRegistry.removeRun(requestId); } @@ -368,6 +508,15 @@ public class AgentRunRegistry { public record RunOwner(String agentId, String sessionId, String userId) { } + /** + * 公开审批 ID 解析后的内部恢复目标。 + * + * @param requestId 内部请求 ID + * @param resumeToken 内部恢复令牌 + */ + public record ApprovalTarget(String requestId, String resumeToken) { + } + /** * 单机内存运行态。 * @@ -377,7 +526,7 @@ public class AgentRunRegistry { private final String requestId; private final String sessionId; private final AgentRuntime runtime; - private final ChatSseEmitter chatSseEmitter; + private final AgentRunOutput runOutput; private final ChatRuntimeContext chatContext; private final StringBuilder answer; private final ChatAssistantAccumulator assistantAccumulator; @@ -397,7 +546,7 @@ public class AgentRunRegistry { * @param requestId 请求 ID * @param sessionId 会话 ID * @param runtime 有状态运行时 - * @param chatSseEmitter SSE 连接 + * @param runOutput SSE 连接 * @param chatContext 聊天上下文 * @param answer 助手正文累计缓冲 * @param assistantAccumulator 助手结构化累计器 @@ -411,7 +560,7 @@ public class AgentRunRegistry { public AgentRunContext(String requestId, String sessionId, AgentRuntime runtime, - ChatSseEmitter chatSseEmitter, + AgentRunOutput runOutput, ChatRuntimeContext chatContext, StringBuilder answer, ChatAssistantAccumulator assistantAccumulator, @@ -425,7 +574,7 @@ public class AgentRunRegistry { this.requestId = requestId; this.sessionId = sessionId; this.runtime = runtime; - this.chatSseEmitter = chatSseEmitter; + this.runOutput = runOutput; this.chatContext = chatContext; this.answer = answer; this.assistantAccumulator = assistantAccumulator; @@ -465,6 +614,15 @@ public class AgentRunRegistry { return owner; } + /** + * 获取协议无关运行输出。 + * + * @return 运行输出 + */ + public AgentRunOutput runOutput() { + return runOutput; + } + /** * 获取运行事件处理器。 * @@ -550,8 +708,8 @@ public class AgentRunRegistry { */ public void cancelAndComplete() { cancel(); - if (finished.compareAndSet(false, true) && chatSseEmitter != null) { - chatSseEmitter.complete(); + if (finished.compareAndSet(false, true) && runOutput != null) { + runOutput.complete(); } } 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 54f933f8..db336e9a 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 @@ -11,6 +11,7 @@ import com.easyagents.agent.runtime.message.AgentMessageRole; import com.easyagents.agent.runtime.message.AgentMediaBlock; import com.easyagents.agent.runtime.message.AgentTextBlock; import com.easyagents.agent.runtime.persistence.session.AgentSessionStore; +import io.agentscope.core.agui.model.RunAgentInput; import com.mybatisflex.core.keygen.impl.SnowFlakeIDKeyGenerator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,18 +20,29 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.support.TransactionTemplate; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.config.AgentBuiltinToolsConfigResolver; import tech.easyflow.agent.entity.AgentDocumentAttachment; import tech.easyflow.agent.entity.AgentKnowledgeBinding; import tech.easyflow.agent.entity.AgentToolBinding; +import tech.easyflow.agent.entity.AgentSkillBinding; import tech.easyflow.agent.enums.AgentToolType; import tech.easyflow.agent.distributed.AgentRuntimeCommandAction; import tech.easyflow.agent.distributed.AgentRuntimeCommandProducer; import tech.easyflow.agent.distributed.AgentRuntimeRoute; import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry; import tech.easyflow.agent.runtime.event.AgentRunEventRecorder; +import tech.easyflow.agent.runtime.agui.AgentAguiRunInputMapper; +import tech.easyflow.agent.runtime.agui.AgentAguiWireContext; +import tech.easyflow.agent.runtime.agui.AgentAguiHitlResolveRequest; +import tech.easyflow.agent.runtime.artifact.AgentArtifactService; import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService; +import tech.easyflow.agent.runtime.hitl.ToolApprovalInputProjection; import tech.easyflow.agent.runtime.lock.AgentRunLock; +import tech.easyflow.agent.runtime.output.AgentRunOutput; +import tech.easyflow.agent.runtime.output.AguiAgentRunOutput; +import tech.easyflow.agent.runtime.output.LegacyAgentRunOutput; import tech.easyflow.agent.runtime.session.EasyFlowAgentSessionStore; +import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeProjector; import tech.easyflow.agent.runtime.media.AgentBoundMedia; import tech.easyflow.agent.runtime.media.AgentMediaService; import tech.easyflow.agent.runtime.media.AgentMediaUploadRecord; @@ -57,9 +69,7 @@ import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.core.chat.protocol.ChatDomain; -import tech.easyflow.core.chat.protocol.ChatEnvelope; import tech.easyflow.core.chat.protocol.ChatType; -import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter; import tech.easyflow.core.runtime.*; import tech.easyflow.system.enums.CategoryResourceType; import tech.easyflow.system.enums.ResourceAction; @@ -90,6 +100,8 @@ public class AgentRunService { @Resource private AgentRuntimeCompiler agentRuntimeCompiler; @Resource + private AgentSkillRuntimeProjector agentSkillRuntimeProjector; + @Resource private AgentRuntimeFactory agentRuntimeFactory; @Resource private AgentChatCapabilityService agentChatCapabilityService; @@ -139,6 +151,12 @@ public class AgentRunService { private AgentDocumentContextSelector agentDocumentContextSelector; @Resource private TransactionTemplate transactionTemplate; + @Resource + private AgentAguiRunInputMapper agentAguiRunInputMapper; + @Resource + private AgentBuiltinToolsConfigResolver agentBuiltinToolsConfigResolver; + @Resource + private AgentArtifactService agentArtifactService; /** * 启动 Agent 聊天。 @@ -147,6 +165,22 @@ public class AgentRunService { * @return SSE Emitter */ public SseEmitter chat(AgentChatRequest chatRequest) { + return chat(chatRequest, null); + } + + /** + * 通过受控 AG-UI 输入启动正式 Agent 聊天。 + * + * @param agentId URL 中的可信 Agent ID + * @param input AG-UI 运行输入 + * @return SSE Emitter + */ + public SseEmitter chatAgui(BigInteger agentId, RunAgentInput input) { + AgentChatRequest request = agentAguiRunInputMapper.toFormalRequest(agentId, input); + return chat(request, agentAguiRunInputMapper.wireContext(input)); + } + + private SseEmitter chat(AgentChatRequest chatRequest, AgentAguiWireContext wireContext) { // 判定Agent是否对当前用户可用 validateChatRequest(chatRequest); LoginAccount account = requireCurrentLoginAccount(); @@ -185,7 +219,8 @@ public class AgentRunService { // 执行对话 return run(agent, chatRequest.getPrompt(), mediaUploads, documentUploads, account, requestId, traceId, sessionId.toString(), - ASSISTANT_CODE, chatContext, true, easyFlowAgentSessionStore); + ASSISTANT_CODE, chatContext, true, easyFlowAgentSessionStore, + createRunOutput(wireContext)); } /** @@ -264,6 +299,21 @@ public class AgentRunService { * @return SSE Emitter */ public SseEmitter chatDraft(AgentDraftChatRequest draftRequest) { + return chatDraft(draftRequest, null); + } + + /** + * 通过受控 AG-UI 输入启动草稿 Agent 试用。 + * + * @param input AG-UI 运行输入 + * @return SSE Emitter + */ + public SseEmitter chatDraftAgui(RunAgentInput input) { + AgentDraftChatRequest request = agentAguiRunInputMapper.toDraftRequest(input); + return chatDraft(request, agentAguiRunInputMapper.wireContext(input)); + } + + private SseEmitter chatDraft(AgentDraftChatRequest draftRequest, AgentAguiWireContext wireContext) { validateDraftChatRequest(draftRequest); LoginAccount account = requireCurrentLoginAccount(); Agent agent = buildDraftAgent(draftRequest, account); @@ -288,7 +338,8 @@ public class AgentRunService { agent, chatSessionId, titlePrompt, account, DRAFT_ASSISTANT_CODE); return run(agent, draftRequest.getPrompt(), mediaUploads, documentUploads, account, requestId, traceId, runtimeSessionId, - DRAFT_ASSISTANT_CODE, chatContext, false, draftAgentSessionStore); + DRAFT_ASSISTANT_CODE, chatContext, false, draftAgentSessionStore, + createRunOutput(wireContext)); } private SseEmitter run(Agent agent, @@ -303,7 +354,24 @@ public class AgentRunService { ChatRuntimeContext chatContext, boolean persistChatlog, AgentSessionStore runtimeSessionStore) { - ChatSseEmitter chatSseEmitter = new ChatSseEmitter(); + return run(agent, prompt, mediaUploads, documentUploads, account, requestId, traceId, + runtimeSessionId, assistantCode, chatContext, persistChatlog, runtimeSessionStore, + new LegacyAgentRunOutput()); + } + + private SseEmitter run(Agent agent, + String prompt, + List mediaUploads, + List documentUploads, + LoginAccount account, + String requestId, + String traceId, + String runtimeSessionId, + String assistantCode, + ChatRuntimeContext chatContext, + boolean persistChatlog, + AgentSessionStore runtimeSessionStore, + AgentRunOutput runOutput) { // 获取会话锁 AgentRunLock.Handle lockHandle = acquireRunLock(agent, runtimeSessionId); boolean submitted = false; @@ -315,25 +383,25 @@ public class AgentRunService { if (persistChatlog) { // 持久化会话初始信息 chatRuntimeManager.prepareSession(chatContext); - if (!sendSessionCreated(chatSseEmitter, chatContext.getSessionId())) { + if (!sendSessionCreated(runOutput, chatContext.getSessionId())) { chatRuntimeManager.recordFailure(chatContext, new BusinessException("客户端连接已断开,Agent 运行已取消")); - return chatSseEmitter.getEmitter(); + return runOutput.emitter(); } BigInteger messageId = BigInteger.valueOf(new SnowFlakeIDKeyGenerator().nextId()); PreparedInput preparedInput = bindAndRecordFormalInput( mediaUploads, documentUploads, account, chatContext, messageId, prompt); boundMedia = preparedInput.media(); boundDocuments = preparedInput.documents(); - if (!sendInputAccepted(chatSseEmitter, chatContext.getSessionId(), messageId, + if (!sendInputAccepted(runOutput, chatContext.getSessionId(), messageId, boundMedia, boundDocuments)) { chatRuntimeManager.recordFailure(chatContext, new BusinessException("客户端连接已断开,Agent 运行已取消")); - return chatSseEmitter.getEmitter(); + return runOutput.emitter(); } } else { boundMedia = agentMediaService.bindDraft(mediaUploads); boundDocuments = bindDraftDocuments(documentUploads); - if (!sendInputAccepted(chatSseEmitter, null, null, boundMedia, boundDocuments)) { - return chatSseEmitter.getEmitter(); + if (!sendInputAccepted(runOutput, null, null, boundMedia, boundDocuments)) { + return runOutput.emitter(); } } chatContext.getExt().put(DOCUMENT_CITATIONS_EXT_KEY, documentContext.citations()); @@ -343,9 +411,9 @@ public class AgentRunService { AgentMessage userMessage = buildAgentMessage(runtimePrompt, boundMedia); threadPoolTaskExecutor.execute(() -> startRuntime( agent, userMessage, documentContext, account, requestId, traceId, runtimeSessionId, - assistantCode, chatContext, chatSseEmitter, persistChatlog, runtimeSessionStore, lockHandle)); + assistantCode, chatContext, runOutput, persistChatlog, runtimeSessionStore, lockHandle)); submitted = true; - return chatSseEmitter.getEmitter(); + return runOutput.emitter(); } finally { // 释放锁 if (!submitted && lockHandle != null) { @@ -354,6 +422,17 @@ public class AgentRunService { } } + private AgentRunOutput createRunOutput(AgentAguiWireContext wireContext) { + if (wireContext == null) { + return new LegacyAgentRunOutput(); + } + return new AguiAgentRunOutput( + wireContext.threadId(), + wireContext.runId(), + wireContext.userMessageId(), + wireContext.userMessageContent()); + } + /** * 校验本轮文档上传。无文档时不访问文档服务,兼容纯文本和图片聊天。 * @@ -526,12 +605,18 @@ public class AgentRunService { throw new BusinessException("仅允许清理 Agent 草稿试运行会话"); } LoginAccount account = requireCurrentLoginAccount(); - clearDraftSessionInternal(sessionId, account.getId() == null ? null : account.getId().toString()); + clearDraftSessionInternal(sessionId, + account.getId() == null ? null : account.getId().toString(), + account.getTenantId() == null ? null : account.getTenantId().toString()); } - private void clearDraftSessionInternal(String sessionId, String userId) { + private void clearDraftSessionInternal(String sessionId, String userId, String tenantId) { agentRunRegistry.cancelSession(sessionId, userId); draftAgentSessionStore.delete(sessionId); + if (userId != null && tenantId != null && agentArtifactService != null) { + agentArtifactService.markDraftSessionDeletePending( + sessionId, new BigInteger(tenantId), new BigInteger(userId)); + } } /** @@ -547,7 +632,8 @@ public class AgentRunService { private void approveRuntime(String requestId, String resumeToken, BigInteger operatorId, String userId) { if (!agentRunRegistry.containsResumeTarget(requestId, resumeToken)) { - dispatchRemoteRuntimeCommand(requestId, resumeToken, AgentRuntimeCommandAction.APPROVE, null, operatorId, userId); + dispatchRemoteRuntimeCommand( + requestId, resumeToken, AgentRuntimeCommandAction.APPROVE, null, operatorId, userId, null); return; } approveRuntimeLocal(requestId, resumeToken, operatorId, userId); @@ -582,9 +668,50 @@ public class AgentRunService { rejectRuntime(requestId, resumeToken, reason, account.getId(), account.getId() == null ? null : account.getId().toString()); } + /** + * 通过不透明审批 ID 处理 AG-UI HITL 决策。 + * + * @param request 审批请求 + */ + public void resolveAguiApproval(AgentAguiHitlResolveRequest request) { + if (request == null || request.getApprovalId() == null || request.getApprovalId().isBlank()) { + throw new BusinessException("Agent 审批 ID 不能为空"); + } + String decision = request.getDecision() == null ? "" : request.getDecision().trim().toUpperCase(); + if (!"APPROVE".equals(decision) && !"REJECT".equals(decision)) { + throw new BusinessException("Agent 审批决策不合法"); + } + LoginAccount account = requireCurrentLoginAccount(); + agentRunRegistry.assertApprovalOwner(request.getApprovalId(), account.getId().toString()); + AgentRunRegistry.ApprovalTarget target = agentRunRegistry.resolveApproval(request.getApprovalId()); + if ("APPROVE".equals(decision)) { + if (agentRunRegistry.containsResumeTarget(target.requestId(), target.resumeToken())) { + approveAguiRuntimeLocal( + target.requestId(), target.resumeToken(), request.getApprovalId(), + account.getId(), account.getId().toString()); + } else { + dispatchRemoteRuntimeCommand( + target.requestId(), target.resumeToken(), AgentRuntimeCommandAction.APPROVE, null, + account.getId(), account.getId().toString(), request.getApprovalId()); + } + } else { + if (agentRunRegistry.containsResumeTarget(target.requestId(), target.resumeToken())) { + rejectAguiRuntimeLocal( + target.requestId(), target.resumeToken(), request.getApprovalId(), request.getReason(), + account.getId(), account.getId().toString()); + } else { + dispatchRemoteRuntimeCommand( + target.requestId(), target.resumeToken(), AgentRuntimeCommandAction.REJECT, request.getReason(), + account.getId(), account.getId().toString(), request.getApprovalId()); + } + } + agentRunRegistry.removeApproval(request.getApprovalId()); + } + private void rejectRuntime(String requestId, String resumeToken, String reason, BigInteger operatorId, String userId) { if (!agentRunRegistry.containsResumeTarget(requestId, resumeToken)) { - dispatchRemoteRuntimeCommand(requestId, resumeToken, AgentRuntimeCommandAction.REJECT, reason, operatorId, userId); + dispatchRemoteRuntimeCommand( + requestId, resumeToken, AgentRuntimeCommandAction.REJECT, reason, operatorId, userId, null); return; } rejectRuntimeLocal(requestId, resumeToken, reason, operatorId, userId); @@ -608,6 +735,62 @@ public class AgentRunService { () -> agentHitlPendingService.reject(resumeToken, operatorId, reason)); } + /** + * 在当前节点批准 AG-UI 工具执行,并在恢复前发送脱敏决议事件。 + * + * @param requestId 请求 ID + * @param resumeToken 恢复令牌 + * @param approvalId 不透明审批 ID + * @param operatorId 操作人 ID + * @param userId 用户 ID + */ + public void approveAguiRuntimeLocal( + String requestId, + String resumeToken, + String approvalId, + BigInteger operatorId, + String userId) { + Runnable emitResolved = () -> agentRunRegistry.emitApprovalResolved(approvalId, "APPROVED", null); + if (agentRunRegistry.isDraftResumeTarget(requestId, resumeToken)) { + agentRunRegistry.approve(requestId, resumeToken, userId, emitResolved); + } else { + agentRunRegistry.approve(requestId, resumeToken, userId, () -> { + agentHitlPendingService.approve(resumeToken, operatorId); + emitResolved.run(); + }); + } + agentRunRegistry.removeApproval(approvalId); + } + + /** + * 在当前节点拒绝 AG-UI 工具执行,并在恢复前发送脱敏决议事件。 + * + * @param requestId 请求 ID + * @param resumeToken 恢复令牌 + * @param approvalId 不透明审批 ID + * @param reason 拒绝原因 + * @param operatorId 操作人 ID + * @param userId 用户 ID + */ + public void rejectAguiRuntimeLocal( + String requestId, + String resumeToken, + String approvalId, + String reason, + BigInteger operatorId, + String userId) { + Runnable emitResolved = () -> agentRunRegistry.emitApprovalResolved(approvalId, "REJECTED", reason); + if (agentRunRegistry.isDraftResumeTarget(requestId, resumeToken)) { + agentRunRegistry.reject(requestId, resumeToken, userId, reason, emitResolved); + } else { + agentRunRegistry.reject(requestId, resumeToken, userId, reason, () -> { + agentHitlPendingService.reject(resumeToken, operatorId, reason); + emitResolved.run(); + }); + } + agentRunRegistry.removeApproval(approvalId); + } + /** * 将已经持久化为过期状态的审批同步到运行节点。 * @@ -620,7 +803,7 @@ public class AgentRunService { return; } dispatchRemoteRuntimeCommand(requestId, resumeToken, AgentRuntimeCommandAction.EXPIRE, - HITL_APPROVAL_EXPIRED_REASON, null, null); + HITL_APPROVAL_EXPIRED_REASON, null, null, null); } /** @@ -637,7 +820,14 @@ public class AgentRunService { String resolvedReason = reason == null || reason.isBlank() ? HITL_APPROVAL_EXPIRED_REASON : reason; - agentRunRegistry.reject(requestId, resumeToken, null, resolvedReason); + String approvalId = agentRunRegistry.findApprovalId(requestId, resumeToken); + Runnable emitResolved = approvalId == null + ? null + : () -> agentRunRegistry.emitApprovalResolved(approvalId, "EXPIRED", resolvedReason); + agentRunRegistry.reject(requestId, resumeToken, null, resolvedReason, emitResolved); + if (approvalId != null) { + agentRunRegistry.removeApproval(approvalId); + } } private void dispatchRemoteRuntimeCommand(String requestId, @@ -645,7 +835,8 @@ public class AgentRunService { AgentRuntimeCommandAction action, String reason, BigInteger operatorId, - String userId) { + String userId, + String approvalId) { String resolvedRequestId = resolveRequestIdForRemoteDispatch(requestId, resumeToken); AgentRuntimeRoute ownerRoute = agentRuntimeRouteRegistry.findOwnerRoute(resolvedRequestId); String ownerNodeId = ownerRoute == null ? null : ownerRoute.getNodeId(); @@ -663,12 +854,23 @@ public class AgentRunService { throw new BusinessException("Agent 运行节点不可用,请重新发起对话"); } if (action == AgentRuntimeCommandAction.APPROVE) { - agentRuntimeCommandProducer.sendApprove(ownerNodeId, resolvedRequestId, resumeToken, operatorId, userId); + if (approvalId == null || approvalId.isBlank()) { + agentRuntimeCommandProducer.sendApprove( + ownerNodeId, resolvedRequestId, resumeToken, operatorId, userId); + } else { + agentRuntimeCommandProducer.sendApprove( + ownerNodeId, resolvedRequestId, resumeToken, approvalId, operatorId, userId); + } return; } if (action == AgentRuntimeCommandAction.REJECT) { - agentRuntimeCommandProducer.sendReject( - ownerNodeId, resolvedRequestId, resumeToken, reason, operatorId, userId); + if (approvalId == null || approvalId.isBlank()) { + agentRuntimeCommandProducer.sendReject( + ownerNodeId, resolvedRequestId, resumeToken, reason, operatorId, userId); + } else { + agentRuntimeCommandProducer.sendReject( + ownerNodeId, resolvedRequestId, resumeToken, approvalId, reason, operatorId, userId); + } return; } if (action == AgentRuntimeCommandAction.EXPIRE) { @@ -701,7 +903,7 @@ public class AgentRunService { * @param runtimeSessionId 运行会话 ID * @param assistantCode 助手类型 * @param chatContext 聊天上下文 - * @param chatSseEmitter SSE 发射器 + * @param runOutput SSE 发射器 * @param persistChatlog 是否持久化聊天日志 * @param runtimeSessionStore 运行会话存储 * @param initialLockHandle 会话运行锁 @@ -715,14 +917,14 @@ public class AgentRunService { String runtimeSessionId, String assistantCode, ChatRuntimeContext chatContext, - ChatSseEmitter chatSseEmitter, + AgentRunOutput runOutput, boolean persistChatlog, AgentSessionStore runtimeSessionStore, AgentRunLock.Handle initialLockHandle) { if (!persistChatlog || agent == null || agent.getId() == null) { startRuntimeLocked( agent, userMessage, documentContext, account, requestId, traceId, - runtimeSessionId, assistantCode, chatContext, chatSseEmitter, + runtimeSessionId, assistantCode, chatContext, runOutput, persistChatlog, runtimeSessionStore, initialLockHandle ); return; @@ -732,7 +934,7 @@ public class AgentRunService { agentRunStartGuard.assertRunnable(agent.getId()); startRuntimeLocked( agent, userMessage, documentContext, account, requestId, traceId, - runtimeSessionId, assistantCode, chatContext, chatSseEmitter, + runtimeSessionId, assistantCode, chatContext, runOutput, true, runtimeSessionStore, initialLockHandle ); return null; @@ -748,7 +950,7 @@ public class AgentRunService { handleRuntimeError( exception, requestId, - chatSseEmitter, + runOutput, chatContext, new AtomicBoolean(false), true @@ -768,7 +970,7 @@ public class AgentRunService { * @param runtimeSessionId 运行会话 ID * @param assistantCode 助手类型 * @param chatContext 聊天上下文 - * @param chatSseEmitter SSE 发射器 + * @param runOutput SSE 发射器 * @param persistChatlog 是否持久化聊天日志 * @param runtimeSessionStore 运行会话存储 * @param initialLockHandle 会话运行锁 @@ -782,7 +984,7 @@ public class AgentRunService { String runtimeSessionId, String assistantCode, ChatRuntimeContext chatContext, - ChatSseEmitter chatSseEmitter, + AgentRunOutput runOutput, boolean persistChatlog, AgentSessionStore runtimeSessionStore, AgentRunLock.Handle initialLockHandle) { @@ -791,21 +993,22 @@ public class AgentRunService { ChatAssistantAccumulator assistantAccumulator = new ChatAssistantAccumulator(); LegacyThinkingTagParser legacyThinkingTagParser = new LegacyThinkingTagParser(); // 注册 emit 服务 - registerEmitterCancellation(requestId, chatSseEmitter, chatContext, answer, + registerEmitterCancellation(requestId, runOutput, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog); AgentRunLock.Handle lockHandle = initialLockHandle; try { if (persistChatlog) { bindAgentSession(agent, runtimeSessionId, chatContext); } - AgentRuntimeBundle bundle = agentRuntimeCompiler.compile(agent); + AgentRuntimeContext runtimeContext = buildAgentRuntimeContext(chatContext, traceId, runtimeSessionId); + AgentRuntimeBundle bundle = agentRuntimeCompiler.compile(agent, runtimeContext, !persistChatlog); appendDocumentContext(bundle, documentContext); AgentRuntime runtime = agentRuntimeFactory.create(); // 会话初始化请求 AgentInitRequest request = new AgentInitRequest(); request.setSessionId(runtimeSessionId); request.setAgentDefinition(bundle.getDefinition()); - request.setRuntimeContext(buildAgentRuntimeContext(chatContext, traceId, runtimeSessionId)); + request.setRuntimeContext(runtimeContext); request.setToolInvokers(bundle.getToolInvokers()); request.setKnowledgeRetrievers(bundle.getKnowledgeRetrievers()); request.setSessionStore(runtimeSessionStore); @@ -822,7 +1025,7 @@ public class AgentRunService { requestId, runtimeSessionId, runtime, - chatSseEmitter, + runOutput, chatContext, answer, assistantAccumulator, @@ -830,11 +1033,11 @@ public class AgentRunService { persistChatlog, owner, lockHandle, - event -> handleRuntimeEvent(event, requestId, chatSseEmitter, answer, + event -> handleRuntimeEvent(event, requestId, runOutput, answer, assistantAccumulator, legacyThinkingTagParser, chatContext, finished, persistChatlog), - error -> handleRuntimeStreamError(error, requestId, chatSseEmitter, chatContext, answer, + error -> handleRuntimeStreamError(error, requestId, runOutput, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog), - () -> finishRuntimeStream(requestId, chatSseEmitter, chatContext, answer, + () -> finishRuntimeStream(requestId, runOutput, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog) ); agentRunRegistry.register(runContext); @@ -859,7 +1062,7 @@ public class AgentRunService { if (lockHandle != null) { lockHandle.release(); } - handleRuntimeError(e, requestId, chatSseEmitter, chatContext, finished, persistChatlog); + handleRuntimeError(e, requestId, runOutput, chatContext, finished, persistChatlog); } } @@ -915,7 +1118,7 @@ public class AgentRunService { } private void registerEmitterCancellation(String requestId, - ChatSseEmitter chatSseEmitter, + AgentRunOutput runOutput, ChatRuntimeContext chatContext, StringBuilder answer, ChatAssistantAccumulator assistantAccumulator, @@ -924,7 +1127,7 @@ public class AgentRunService { boolean persistChatlog) { Runnable cancelTask = () -> cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog); - SseEmitter emitter = chatSseEmitter.getEmitter(); + SseEmitter emitter = runOutput.emitter(); emitter.onCompletion(cancelTask); emitter.onTimeout(cancelTask); emitter.onError(error -> cancelTask.run()); @@ -964,19 +1167,19 @@ public class AgentRunService { private void handleRuntimeEvent(AgentRuntimeEvent event, String requestId, - ChatSseEmitter chatSseEmitter, + AgentRunOutput runOutput, StringBuilder answer, ChatAssistantAccumulator assistantAccumulator, ChatRuntimeContext chatContext, AtomicBoolean finished, boolean persistChatlog) { - handleRuntimeEvent(event, requestId, chatSseEmitter, answer, assistantAccumulator, + handleRuntimeEvent(event, requestId, runOutput, answer, assistantAccumulator, new LegacyThinkingTagParser(), chatContext, finished, persistChatlog); } private void handleRuntimeEvent(AgentRuntimeEvent event, String requestId, - ChatSseEmitter chatSseEmitter, + AgentRunOutput runOutput, StringBuilder answer, ChatAssistantAccumulator assistantAccumulator, LegacyThinkingTagParser legacyThinkingTagParser, @@ -986,74 +1189,148 @@ public class AgentRunService { if (event == null || event.getEventType() == null) { return; } - recordRuntimeEvent(requestId, chatContext, event, persistChatlog); + Map artifact = buildArtifactPublishedPayload(event); + if (artifact != null) { + Map persistedPayload = new LinkedHashMap<>(); + persistedPayload.put("artifactPublished", artifact); + recordRuntimeEvent(requestId, chatContext, publicRuntimeEvent(event, persistedPayload), persistChatlog); + assistantAccumulator.appendArtifact(artifact); + Map statusPayload = new LinkedHashMap<>(artifact); + statusPayload.put("statusKey", "artifact-published"); + if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, statusPayload)) { + cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, + legacyThinkingTagParser, finished, persistChatlog); + } + return; + } + Map skillStatus = isSkillInvocationEvent(event.getEventType()) + ? buildSkillInvocationStatusPayload(event, chatContext, requestId) + : null; + Map toolStatus = event.getEventType() == AgentRuntimeEventType.TOOL_CALL + || event.getEventType() == AgentRuntimeEventType.TOOL_RESULT + ? buildToolEventPayload(event) + : null; + Map asyncToolStatus = isAsyncToolEvent(event.getEventType()) + ? buildAsyncToolEventPayload(event) + : null; + AgentRuntimeEvent toolApprovalEvent = event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED + ? buildToolApprovalPublicEvent(event) + : null; + Map persistedStatus = skillStatus != null + ? skillStatus : (toolStatus != null ? toolStatus : asyncToolStatus); + if (event.getEventType() != AgentRuntimeEventType.SKILL_STEP + && event.getEventType() != AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) { + AgentRuntimeEvent persistedEvent = toolApprovalEvent != null + ? toolApprovalEvent + : (persistedStatus == null ? event : publicRuntimeEvent(event, persistedStatus)); + recordRuntimeEvent(requestId, chatContext, persistedEvent, persistChatlog); + } + if (!deferRuntimeOutput(event.getEventType()) + && !runOutput.emitRuntimeEvent(event)) { + cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, + legacyThinkingTagParser, finished, persistChatlog); + return; + } if (event.getEventType() == AgentRuntimeEventType.REASONING_STARTED) { - emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, chatSseEmitter, chatContext, + emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog); legacyThinkingTagParser.reset(); return; } if (event.getEventType() == AgentRuntimeEventType.MESSAGE_DELTA) { String text = stringPayload(event, "text"); - emitAssistantSegments(legacyThinkingTagParser.acceptContent(text), requestId, chatSseEmitter, chatContext, + emitAssistantSegments(legacyThinkingTagParser.acceptContent(text), requestId, runOutput, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog); return; } if (event.getEventType() == AgentRuntimeEventType.REASONING_DELTA) { String reasoning = firstText(stringPayload(event, "reasoning"), stringPayload(event, "text")); - emitAssistantSegments(legacyThinkingTagParser.acceptReasoning(reasoning), requestId, chatSseEmitter, chatContext, + emitAssistantSegments(legacyThinkingTagParser.acceptReasoning(reasoning), requestId, runOutput, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog); return; } + if (isSkillInvocationEvent(event.getEventType())) { + assistantAccumulator.appendSkillInvocationStatus(skillStatus); + AgentRuntimeEvent publicEvent = publicRuntimeEvent(event, skillStatus); + if (!runOutput.emitRuntimeEvent(publicEvent)) { + cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, + legacyThinkingTagParser, finished, persistChatlog); + } + return; + } + if (event.getEventType() == AgentRuntimeEventType.SKILL_STEP) { + // Skill 内部 Tool 继续走标准 Tool 事件;旁路步骤包含 input/path,不进入响应或持久化。 + return; + } if (event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) { String resumeToken = stringPayload(event, "resumeToken"); agentRunRegistry.registerResumeToken(requestId, resumeToken); + String approvalId = agentRunRegistry.registerApproval(requestId, resumeToken); + event.getMetadata().put("approvalId", approvalId); + toolApprovalEvent.getMetadata().put("approvalId", approvalId); + recordRuntimeEvent(requestId, chatContext, toolApprovalEvent, persistChatlog); recordApprovalRequired(requestId, chatContext, event, persistChatlog); - if (!sendEnvelope(chatSseEmitter, ChatDomain.TOOL, ChatType.FORM_REQUEST, buildToolHitlPayload(requestId, event))) { + if (!runOutput.emitRuntimeEvent(toolApprovalEvent)) { + cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, + legacyThinkingTagParser, finished, persistChatlog); + return; + } + if (!sendEnvelope(runOutput, ChatDomain.TOOL, ChatType.FORM_REQUEST, buildToolHitlPayload(requestId, event))) { cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog); } return; } if (isAsyncToolEvent(event.getEventType())) { - if (!sendEnvelope(chatSseEmitter, ChatDomain.TOOL, asyncToolChatType(event), buildAsyncToolEventPayload(event))) { + if (!sendEnvelope(runOutput, ChatDomain.TOOL, asyncToolChatType(event), asyncToolStatus)) { cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog); } return; } if (event.getEventType() == AgentRuntimeEventType.TOOL_CALL) { - if (!emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, chatSseEmitter, chatContext, + if (!emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog)) { return; } - LOG.info("Agent runtime tool call, requestId={}, toolCallId={}, payload={}, metadata={}", - requestId, event.getToolCallId(), event.getPayload(), event.getMetadata()); - Map toolPayload = buildToolEventPayload(event); + Map toolPayload = toolStatus; + if (!runOutput.emitRuntimeEvent(publicRuntimeEvent(event, toolPayload))) { + cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, + legacyThinkingTagParser, finished, persistChatlog); + return; + } + LOG.info("Agent runtime tool call, requestId={}, toolCallId={}, toolName={}, status={}", + requestId, event.getToolCallId(), stringValue(toolPayload, "toolName"), + stringValue(toolPayload, "status")); assistantAccumulator.appendToolCall( firstText(stringValue(toolPayload, "toolCallId"), event.getToolCallId()), firstText(stringValue(toolPayload, "toolName"), stringValue(toolPayload, "name")), stringValue(toolPayload, "toolDisplayName"), - firstNonNull(toolPayload.get("input"), toolPayload.get("toolInput")) + null ); - if (!sendEnvelope(chatSseEmitter, ChatDomain.TOOL, ChatType.TOOL_CALL, toolPayload)) { + if (!sendEnvelope(runOutput, ChatDomain.TOOL, ChatType.TOOL_CALL, toolPayload)) { cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog); } return; } if (event.getEventType() == AgentRuntimeEventType.TOOL_RESULT) { - LOG.info("Agent runtime tool result, requestId={}, toolCallId={}, payload={}, metadata={}", - requestId, event.getToolCallId(), event.getPayload(), event.getMetadata()); - Map toolPayload = buildToolEventPayload(event); + Map toolPayload = toolStatus; + LOG.info("Agent runtime tool result, requestId={}, toolCallId={}, toolName={}, status={}", + requestId, event.getToolCallId(), stringValue(toolPayload, "toolName"), + stringValue(toolPayload, "status")); + if (!runOutput.emitRuntimeEvent(publicRuntimeEvent(event, toolPayload))) { + cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, + legacyThinkingTagParser, finished, persistChatlog); + return; + } assistantAccumulator.appendToolResult( firstText(stringValue(toolPayload, "toolCallId"), event.getToolCallId()), firstText(stringValue(toolPayload, "toolName"), stringValue(toolPayload, "name")), stringValue(toolPayload, "toolDisplayName"), - firstNonNull(firstNonNull(toolPayload.get("output"), toolPayload.get("result")), - toolPayload.get("text")) + null ); - if (!sendEnvelope(chatSseEmitter, ChatDomain.TOOL, ChatType.TOOL_RESULT, toolPayload)) { + if (!sendEnvelope(runOutput, ChatDomain.TOOL, ChatType.TOOL_RESULT, toolPayload)) { cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog); return; @@ -1064,7 +1341,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(chatSseEmitter, ChatDomain.BUSINESS, ChatType.STATUS, buildKnowledgeRetrievalStatusPayload(event))) { + if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, buildKnowledgeRetrievalStatusPayload(event))) { cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog); } @@ -1074,7 +1351,8 @@ public class AgentRunService { || event.getEventType() == AgentRuntimeEventType.MEMORY_COMPRESSION_COMPLETED) { LOG.info("Agent runtime memory compression, requestId={}, eventType={}, payload={}, metadata={}", requestId, event.getEventType(), event.getPayload(), event.getMetadata()); - if (!sendEnvelope(chatSseEmitter, ChatDomain.BUSINESS, ChatType.STATUS, event.getPayload())) { + if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, + buildMemoryCompressionStatusPayload(event))) { cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog); } @@ -1083,7 +1361,7 @@ public class AgentRunService { if (event.getEventType() == AgentRuntimeEventType.SUSPENDED) { LOG.info("Agent runtime suspended, requestId={}, payload={}, metadata={}", requestId, event.getPayload(), event.getMetadata()); - if (!sendEnvelope(chatSseEmitter, ChatDomain.BUSINESS, ChatType.STATUS, buildSuspendedStatusPayload(event))) { + if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, buildSuspendedStatusPayload(event))) { cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog); return; @@ -1095,7 +1373,7 @@ public class AgentRunService { return; } if (event.getEventType() == AgentRuntimeEventType.COMPLETED) { - if (!emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, chatSseEmitter, chatContext, + if (!emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog)) { return; } @@ -1106,40 +1384,122 @@ public class AgentRunService { } List> citations = buildKnowledgeCitationPayload(event); if (!citations.isEmpty()) { - if (!sendEnvelope(chatSseEmitter, ChatDomain.BUSINESS, ChatType.CITATIONS, Map.of("items", citations))) { + if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.CITATIONS, Map.of("items", citations))) { cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog); return; } } - finishIfNeeded(requestId, chatSseEmitter, chatContext, answer, + if (!runOutput.emitRuntimeEvent(event)) { + cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, + legacyThinkingTagParser, finished, persistChatlog); + return; + } + finishIfNeeded(requestId, runOutput, chatContext, answer, assistantAccumulator, finished, persistChatlog, citations); return; } if (event.getEventType() == AgentRuntimeEventType.CANCELLED) { - if (!emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, chatSseEmitter, chatContext, + if (!emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog)) { return; } - handleRuntimeCancelled(event, requestId, chatSseEmitter, chatContext, answer, + if (!runOutput.emitRuntimeEvent(event)) { + cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, + legacyThinkingTagParser, finished, persistChatlog); + return; + } + handleRuntimeCancelled(event, requestId, runOutput, chatContext, answer, assistantAccumulator, finished, persistChatlog); return; } if (event.getEventType() == AgentRuntimeEventType.FAILED) { - if (!emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, chatSseEmitter, chatContext, + if (!emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog)) { return; } - handleRuntimeError(new BusinessException(errorMessage(event)), requestId, chatSseEmitter, chatContext, finished, persistChatlog); + if (!runOutput.emitRuntimeEvent(event)) { + cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, + legacyThinkingTagParser, finished, persistChatlog); + return; + } + assistantAccumulator.finalizePendingSkillInvocations("FAILED", "技能调用失败"); + if (persistChatlog) { + recordPartialAssistantIfPresent(chatContext, answer, assistantAccumulator, errorMessage(event)); + } + handleRuntimeError(new BusinessException(errorMessage(event)), requestId, runOutput, chatContext, finished, persistChatlog); } } + private boolean deferRuntimeOutput(AgentRuntimeEventType type) { + return type == AgentRuntimeEventType.MESSAGE_DELTA + || type == AgentRuntimeEventType.REASONING_STARTED + || type == AgentRuntimeEventType.REASONING_DELTA + || type == AgentRuntimeEventType.REASONING_COMPLETED + || type == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED + || type == AgentRuntimeEventType.TOOL_CALL + || type == AgentRuntimeEventType.TOOL_RESULT + || type == AgentRuntimeEventType.COMPLETED + || type == AgentRuntimeEventType.CANCELLED + || type == AgentRuntimeEventType.FAILED + || type == AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL + || type == AgentRuntimeEventType.MEMORY_COMPRESSION_STARTED + || type == AgentRuntimeEventType.MEMORY_COMPRESSION_COMPLETED + || isSkillInvocationEvent(type) + || type == AgentRuntimeEventType.SKILL_STEP + || isAsyncToolEvent(type); + } + + private boolean isSkillInvocationEvent(AgentRuntimeEventType type) { + return type == AgentRuntimeEventType.SKILL_CALL + || type == AgentRuntimeEventType.SKILL_RESULT + || type == AgentRuntimeEventType.SKILL_FAILED; + } + + /** + * 构建可公开、可持久化的 Skill 调用状态白名单载荷。 + * + * @param event Runtime Skill 事件 + * @param chatContext 当前聊天上下文 + * @return 不含正文、路径和 Tool 输入的状态载荷 + */ + private Map buildSkillInvocationStatusPayload( + AgentRuntimeEvent event, ChatRuntimeContext chatContext, String requestId) { + Map source = event == null || event.getPayload() == null + ? Map.of() : event.getPayload(); + String skillId = firstText(stringValue(source, "skillId"), + event == null ? null : stringValue(event.getMetadata(), "skillId")); + String skillName = firstText(stringValue(source, "skillName"), + event == null ? null : stringValue(event.getMetadata(), "skillName")); + String displayName = firstText(stringValue(source, "skillDisplayName"), + event == null ? null : stringValue(event.getMetadata(), "skillDisplayName"), + skillName, "技能"); + Object roundId = chatContext == null || chatContext.getExt() == null + ? null : chatContext.getExt().get(ChatRuntimeExtKeys.CURRENT_ROUND_ID); + String resolvedRoundId = roundId == null ? firstText(requestId, "draft") : String.valueOf(roundId); + String status = event == null || event.getEventType() == AgentRuntimeEventType.SKILL_CALL + ? "RUNNING" + : event.getEventType() == AgentRuntimeEventType.SKILL_RESULT ? "SUCCESS" : "FAILED"; + Map result = new LinkedHashMap<>(); + result.put("statusKey", "skill-invocation:" + resolvedRoundId + ":" + firstText(skillId, skillName, "unknown")); + result.put("status", status); + result.put("skillId", skillId); + result.put("skillName", skillName); + result.put("skillDisplayName", displayName); + result.put("toolCallId", event == null ? null : firstText( + event.getToolCallId(), stringValue(source, "toolCallId"))); + if ("FAILED".equals(status)) { + result.put("message", "技能调用失败"); + } + return result; + } + /** * 将解析后的助手片段累计、持久化并发送到前端。 * * @param segments 解析片段 * @param requestId 运行请求 ID - * @param chatSseEmitter SSE 发送器 + * @param runOutput SSE 发送器 * @param chatContext 聊天上下文 * @param answer 最终正文缓冲 * @param assistantAccumulator 结构化消息缓冲 @@ -1150,7 +1510,7 @@ public class AgentRunService { */ private boolean emitAssistantSegments(List segments, String requestId, - ChatSseEmitter chatSseEmitter, + AgentRunOutput runOutput, ChatRuntimeContext chatContext, StringBuilder answer, ChatAssistantAccumulator assistantAccumulator, @@ -1175,7 +1535,7 @@ public class AgentRunService { LOG.debug("Agent runtime message delta, requestId={}, deltaLength={}, answerLength={}, delta={}", requestId, text.length(), answer.length(), toVisibleLogText(text)); } - if (!sendEnvelope(chatSseEmitter, ChatDomain.LLM, chatType, payload)) { + if (!sendEnvelope(runOutput, ChatDomain.LLM, chatType, payload)) { cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog); return false; @@ -1208,7 +1568,7 @@ public class AgentRunService { * 在运行时自然结束但未显式发出完成事件时收口兼容解析器。 * * @param requestId 运行请求 ID - * @param chatSseEmitter SSE 发送器 + * @param runOutput SSE 发送器 * @param chatContext 聊天上下文 * @param answer 最终正文缓冲 * @param assistantAccumulator 结构化消息缓冲 @@ -1217,18 +1577,18 @@ public class AgentRunService { * @param persistChatlog 是否持久化聊天记录 */ private void finishRuntimeStream(String requestId, - ChatSseEmitter chatSseEmitter, + AgentRunOutput runOutput, ChatRuntimeContext chatContext, StringBuilder answer, ChatAssistantAccumulator assistantAccumulator, LegacyThinkingTagParser legacyThinkingTagParser, AtomicBoolean finished, boolean persistChatlog) { - if (!emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, chatSseEmitter, chatContext, + if (!emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog)) { return; } - finishIfNeeded(requestId, chatSseEmitter, chatContext, answer, + finishIfNeeded(requestId, runOutput, chatContext, answer, assistantAccumulator, finished, persistChatlog); } @@ -1237,7 +1597,7 @@ public class AgentRunService { * * @param error 运行异常 * @param requestId 运行请求 ID - * @param chatSseEmitter SSE 发送器 + * @param runOutput SSE 发送器 * @param chatContext 聊天上下文 * @param answer 最终正文缓冲 * @param assistantAccumulator 结构化消息缓冲 @@ -1247,33 +1607,37 @@ public class AgentRunService { */ private void handleRuntimeStreamError(Throwable error, String requestId, - ChatSseEmitter chatSseEmitter, + AgentRunOutput runOutput, ChatRuntimeContext chatContext, StringBuilder answer, ChatAssistantAccumulator assistantAccumulator, LegacyThinkingTagParser legacyThinkingTagParser, AtomicBoolean finished, boolean persistChatlog) { - if (!emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, chatSseEmitter, chatContext, + if (!emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext, answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog)) { return; } - handleRuntimeError(error, requestId, chatSseEmitter, chatContext, finished, persistChatlog); + assistantAccumulator.finalizePendingSkillInvocations("FAILED", "技能调用失败"); + if (persistChatlog) { + recordPartialAssistantIfPresent(chatContext, answer, assistantAccumulator, safeErrorMessage(error)); + } + handleRuntimeError(error, requestId, runOutput, chatContext, finished, persistChatlog); } private void finishIfNeeded(String requestId, - ChatSseEmitter chatSseEmitter, + AgentRunOutput runOutput, ChatRuntimeContext chatContext, StringBuilder answer, ChatAssistantAccumulator assistantAccumulator, AtomicBoolean finished, boolean persistChatlog) { - finishIfNeeded(requestId, chatSseEmitter, chatContext, answer, + finishIfNeeded(requestId, runOutput, chatContext, answer, assistantAccumulator, finished, persistChatlog, List.of()); } private void finishIfNeeded(String requestId, - ChatSseEmitter chatSseEmitter, + AgentRunOutput runOutput, ChatRuntimeContext chatContext, StringBuilder answer, ChatAssistantAccumulator assistantAccumulator, @@ -1285,6 +1649,11 @@ public class AgentRunService { LOG.info("Agent runtime stream suspended, keep SSE and runtime active, requestId={}", requestId); return; } + if (!runOutput.canFinishSuccessfully()) { + handleRuntimeError(new BusinessException("Agent 事件流缺少完成事件"), + requestId, runOutput, chatContext, finished, persistChatlog); + return; + } if (!finished.compareAndSet(false, true)) { return; } @@ -1297,12 +1666,12 @@ public class AgentRunService { buildAssistantRuntimeMessage(chatContext, finalAnswer, assistantAccumulator, citations)); chatRuntimeManager.recordCompleted(chatContext); } - sendDone(chatSseEmitter, finalAnswer); + sendDone(runOutput, finalAnswer); } private void handleRuntimeError(Throwable error, String requestId, - ChatSseEmitter chatSseEmitter, + AgentRunOutput runOutput, ChatRuntimeContext chatContext, AtomicBoolean finished, boolean persistChatlog) { @@ -1320,8 +1689,8 @@ public class AgentRunService { Map payload = new LinkedHashMap<>(); payload.put("message", safeError.getMessage() == null ? "Agent 运行失败" : safeError.getMessage()); payload.put("code", "AGENT_RUN_FAILED"); - sendEnvelope(chatSseEmitter, ChatDomain.SYSTEM, ChatType.ERROR, payload); - chatSseEmitter.complete(); + sendEnvelope(runOutput, ChatDomain.SYSTEM, ChatType.ERROR, payload); + runOutput.complete(); } private String safeErrorMessage(Throwable error) { @@ -1333,7 +1702,7 @@ public class AgentRunService { private void handleRuntimeCancelled(AgentRuntimeEvent event, String requestId, - ChatSseEmitter chatSseEmitter, + AgentRunOutput runOutput, ChatRuntimeContext chatContext, StringBuilder answer, ChatAssistantAccumulator assistantAccumulator, @@ -1344,6 +1713,7 @@ public class AgentRunService { } agentRunRegistry.remove(requestId); String reason = errorMessage(event); + assistantAccumulator.finalizePendingSkillInvocations("CANCELLED", "技能调用已停止"); cancelPending(requestId, reason, persistChatlog); LOG.info("Agent run cancelled, requestId={}, reason={}", requestId, reason); if (persistChatlog) { @@ -1355,8 +1725,8 @@ public class AgentRunService { payload.put("status", "cancelled"); payload.put("label", "已取消"); payload.put("message", reason); - sendEnvelope(chatSseEmitter, ChatDomain.BUSINESS, ChatType.STATUS, payload); - sendDone(chatSseEmitter); + sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, payload); + sendDone(runOutput); } /** @@ -1378,8 +1748,11 @@ public class AgentRunService { if (partialAnswer.isBlank() && !hasAssistantPayload(assistantAccumulator)) { return; } - chatRuntimeManager.recordAssistantCompleted(context, - buildAssistantRuntimeMessage(context, partialAnswer, assistantAccumulator, List.of())); + ChatRuntimeMessage partialMessage = + buildAssistantRuntimeMessage(context, partialAnswer, assistantAccumulator, List.of()); + partialMessage.getContentPayload().put("terminalStatus", "CANCELLED"); + partialMessage.getContentPayload().put("terminalMessage", reason); + chatRuntimeManager.recordAssistantCompleted(context, partialMessage); LOG.info("Agent partial answer persisted after cancellation, sessionId={}, answerLength={}, reason={}", context == null ? null : context.getSessionId(), partialAnswer.length(), reason); } @@ -1497,6 +1870,14 @@ public class AgentRunService { context.setUserName(chatContext.getUserName()); context.setSessionId(sessionId); context.setTraceId(traceId); + Object roundId = chatContext.getExt().get(ChatRuntimeExtKeys.CURRENT_ROUND_ID); + if (roundId != null) { + context.getMetadata().put(ChatRuntimeExtKeys.CURRENT_ROUND_ID, String.valueOf(roundId)); + } + Object variantIndex = chatContext.getExt().get(ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX); + if (variantIndex != null) { + context.getMetadata().put(ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX, variantIndex); + } return context; } @@ -1582,50 +1963,40 @@ public class AgentRunService { return message; } - private boolean sendEnvelope(ChatSseEmitter chatSseEmitter, ChatDomain domain, ChatType type, Object payload) { - ChatEnvelope envelope = new ChatEnvelope<>(); - envelope.setDomain(domain); - envelope.setType(type); - envelope.setPayload(payload); - return chatSseEmitter.send(envelope); + private boolean sendEnvelope(AgentRunOutput runOutput, ChatDomain domain, ChatType type, Object payload) { + return runOutput.emitViewEvent(domain, type, payload); } /** * 发送不携带最终正文的完成事件。 * - * @param chatSseEmitter SSE 发送器 + * @param runOutput SSE 发送器 * @return 发送成功时为 {@code true} */ - private boolean sendDone(ChatSseEmitter chatSseEmitter) { - return sendDone(chatSseEmitter, null); + private boolean sendDone(AgentRunOutput runOutput) { + return sendDone(runOutput, null); } /** * 发送完成事件,并提供最终正文供前端校正流式增量。 * - * @param chatSseEmitter SSE 发送器 + * @param runOutput SSE 发送器 * @param finalText 最终完整正文;取消场景可为空 * @return 发送成功时为 {@code true} */ - private boolean sendDone(ChatSseEmitter chatSseEmitter, String finalText) { - ChatEnvelope> envelope = new ChatEnvelope<>(); - envelope.setDomain(ChatDomain.SYSTEM); - envelope.setType(ChatType.DONE); - if (finalText != null) { - envelope.setPayload(Map.of("finalText", finalText)); - } - return chatSseEmitter.sendDone(envelope); + private boolean sendDone(AgentRunOutput runOutput, String finalText) { + return runOutput.finish(finalText); } - private boolean sendSessionCreated(ChatSseEmitter chatSseEmitter, BigInteger sessionId) { + private boolean sendSessionCreated(AgentRunOutput runOutput, BigInteger sessionId) { if (sessionId == null) { return true; } - return sendEnvelope(chatSseEmitter, ChatDomain.SYSTEM, ChatType.SESSION_CREATED, + return sendEnvelope(runOutput, ChatDomain.SYSTEM, ChatType.SESSION_CREATED, Map.of("sessionId", sessionId.toString())); } - private boolean sendInputAccepted(ChatSseEmitter chatSseEmitter, + private boolean sendInputAccepted(AgentRunOutput runOutput, BigInteger sessionId, BigInteger messageId, List boundMedia, @@ -1644,7 +2015,7 @@ public class AgentRunService { payload.put("attachments", boundDocuments.stream().map(AgentBoundDocument::payload).toList()); } - return sendEnvelope(chatSseEmitter, ChatDomain.SYSTEM, ChatType.INPUT_ACCEPTED, payload); + return sendEnvelope(runOutput, ChatDomain.SYSTEM, ChatType.INPUT_ACCEPTED, payload); } private void validateChatRequest(AgentChatRequest request) { @@ -1763,6 +2134,10 @@ public class AgentRunService { agent.setPromptConfigJson(incoming.getPromptConfigJson()); agent.setMemoryConfigJson(incoming.getMemoryConfigJson()); agent.setExecutionConfigJson(incoming.getExecutionConfigJson()); + if (agentBuiltinToolsConfigResolver != null) { + agent.setExecutionConfigJson(agentBuiltinToolsConfigResolver.normalizeForDraftSave( + agent.getExecutionConfigJson(), existingDraftExecutionConfig(incoming), account)); + } agent.setStatus(incoming.getStatus() == null ? 1 : incoming.getStatus()); agent.setVisibilityScope(incoming.getVisibilityScope()); agent.setPublishStatus(incoming.getPublishStatus()); @@ -1777,9 +2152,57 @@ public class AgentRunService { } agent.setToolBindings(copyDraftToolBindings(request.getToolBindings(), agent, account)); agent.setKnowledgeBindings(copyDraftKnowledgeBindings(request.getKnowledgeBindings(), agent, account)); + List skillBindings = copyDraftSkillBindings(request.getSkillBindings(), agent, account); + agent.setSkillBindings(agentSkillRuntimeProjector.projectCurrentBindings(agent, skillBindings)); return agent; } + /** + * 读取已有草稿配置,用于判断本次请求是否真正关闭 Shell 审批。 + * + * @param incoming 客户端草稿 Agent + * @return 已有草稿执行配置;新 Agent 返回 null + */ + private Map existingDraftExecutionConfig(Agent incoming) { + if (incoming == null || incoming.getId() == null) { + return null; + } + Agent existing = agentService.getById(incoming.getId()); + return existing == null ? null : existing.getExecutionConfigJson(); + } + + /** + * 复制草稿试用请求中的 Skill 引用,忽略客户端伪造的正文和快照。 + * + * @param bindings Skill 绑定请求 + * @param agent 草稿 Agent + * @param account 当前账号 + * @return 安全 Skill 引用 + */ + private List copyDraftSkillBindings(List bindings, + Agent agent, + LoginAccount account) { + List result = new ArrayList<>(); + if (bindings == null || bindings.isEmpty()) { + return result; + } + for (int index = 0; index < bindings.size(); index++) { + AgentSkillBinding source = bindings.get(index); + if (source == null || source.getSkillId() == null) { + throw new BusinessException("Agent Skill 绑定参数不完整"); + } + AgentSkillBinding binding = new AgentSkillBinding(); + binding.setTenantId(account.getTenantId()); + binding.setAgentId(agent.getId()); + binding.setSkillId(source.getSkillId()); + binding.setSortNo(index); + binding.setCreatedBy(account.getId()); + binding.setModifiedBy(account.getId()); + result.add(binding); + } + return result; + } + private List copyDraftToolBindings(List bindings, Agent agent, LoginAccount account) { List result = new ArrayList<>(); if (bindings == null || bindings.isEmpty()) { @@ -1906,15 +2329,15 @@ public class AgentRunService { Map rawPayload = event.getPayload() == null ? Map.of() : event.getPayload(); AgentToolHitlPayload payload = new AgentToolHitlPayload(); payload.setRequestId(requestId); - payload.setResumeToken(stringValue(rawPayload, "resumeToken")); + payload.setApprovalId(stringValue(event.getMetadata(), "approvalId")); payload.setSessionId(stringValue(rawPayload, "sessionId")); payload.setAgentId(stringValue(rawPayload, "agentId")); payload.setToolCallId(firstText(stringValue(rawPayload, "toolCallId"), event.getToolCallId())); payload.setToolName(stringValue(rawPayload, "toolName")); payload.setToolDisplayName(firstText(stringValue(rawPayload, "toolDisplayName"), stringValue(rawPayload, "toolName"))); - payload.setInput(mapPayload(rawPayload.get("toolInput"))); + payload.setInput(ToolApprovalInputProjection.project(rawPayload.get("toolInput"))); if (payload.getInput().isEmpty()) { - payload.setInput(mapPayload(rawPayload.get("input"))); + payload.setInput(ToolApprovalInputProjection.project(rawPayload.get("input"))); } payload.setExpiresAt(stringValue(rawPayload, "expiresAt")); Map metadata = buildHitlMetadata(rawPayload); @@ -1927,6 +2350,27 @@ public class AgentRunService { return payload; } + /** + * 构建可发送至 AG-UI 和运行事件存储的工具审批事件。 + * + * @param event Runtime 原始审批事件 + * @return 仅保留审批身份、期限和脱敏业务参数的事件 + */ + private AgentRuntimeEvent buildToolApprovalPublicEvent(AgentRuntimeEvent event) { + Map rawPayload = event.getPayload() == null ? Map.of() : event.getPayload(); + Map payload = selectPayload(rawPayload, + "agentId", "expiresAt", "sessionId", "toolCallId", + "toolDisplayName", "toolName", "toolType"); + Map input = ToolApprovalInputProjection.project(rawPayload.get("toolInput")); + if (input.isEmpty()) { + input = ToolApprovalInputProjection.project(rawPayload.get("input")); + } + payload.put("toolInput", input); + AgentRuntimeEvent projected = publicRuntimeEvent(event, payload); + putIfPresent(projected.getMetadata(), "approvalId", event.getMetadata().get("approvalId")); + return projected; + } + /** * 构建发送给聊天时间线的工具事件载荷。 * @@ -1934,14 +2378,17 @@ public class AgentRunService { * @return 包含稳定工具调用 ID 的前端载荷 */ private Map buildToolEventPayload(AgentRuntimeEvent event) { - Map payload = new LinkedHashMap<>(event.getPayload() == null ? Map.of() : event.getPayload()); - String toolCallId = firstText(event.getToolCallId(), stringValue(payload, "toolCallId")); + Map rawPayload = event.getPayload() == null ? Map.of() : event.getPayload(); + Map payload = selectPayload(rawPayload, + "name", "status", "success", "toolDisplayName", "toolName", + "skillDisplayName", "skillId"); + String toolCallId = firstText(event.getToolCallId(), stringValue(rawPayload, "toolCallId")); if (toolCallId != null && !toolCallId.isBlank()) { payload.put("toolCallId", toolCallId); } if (Boolean.TRUE.equals(event.getMetadata().get("asyncTool"))) { putIfPresent(payload, "sourceToolCallId", toolCallId); - enrichAsyncToolPayload(payload, event.getMetadata(), toolCallId); + enrichAsyncToolPayload(payload, event.getMetadata(), rawPayload, toolCallId); String taskId = stringValue(payload, "taskId"); if (taskId != null && !taskId.isBlank()) { payload.put("toolCallId", taskId); @@ -1950,6 +2397,31 @@ public class AgentRunService { return payload; } + /** + * 从 artifact_publish 的内部投影事件中提取安全产物字段。 + * + * @param event Runtime 事件 + * @return 安全产物字段;非产物投影事件返回 {@code null} + */ + private Map buildArtifactPublishedPayload(AgentRuntimeEvent event) { + if (event.getEventType() != AgentRuntimeEventType.TOOL_RESULT || event.getPayload() == null + || !Boolean.TRUE.equals(event.getPayload().get("artifactProjectionOnly"))) { + return null; + } + Object rawArtifact = event.getPayload().get("artifactPublished"); + if (!(rawArtifact instanceof Map source)) { + return null; + } + Map artifact = new LinkedHashMap<>(); + for (String field : List.of("schemaVersion", "artifactId", "fileName", "mimeType", + "size", "sha256", "downloadUrl", "status")) { + if (source.get(field) != null) { + artifact.put(field, source.get(field)); + } + } + return artifact.get("artifactId") == null ? null : artifact; + } + private boolean isAsyncToolEvent(AgentRuntimeEventType type) { return type == AgentRuntimeEventType.ASYNC_TOOL_SUBMITTED || type == AgentRuntimeEventType.ASYNC_TOOL_OBSERVED @@ -1974,7 +2446,10 @@ public class AgentRunService { } private Map buildAsyncToolEventPayload(AgentRuntimeEvent event) { - Map payload = new LinkedHashMap<>(event.getPayload() == null ? Map.of() : event.getPayload()); + Map rawPayload = event.getPayload() == null ? Map.of() : event.getPayload(); + Map payload = selectPayload(rawPayload, + "asyncToolName", "name", "phase", "status", "success", "taskId", + "toolDisplayName", "toolName", "skillDisplayName", "skillId"); String taskId = stringValue(payload, "taskId"); String sourceToolCallId = event.getToolCallId(); String toolCallId = firstText(taskId, sourceToolCallId); @@ -1982,16 +2457,19 @@ public class AgentRunService { payload.put("toolCallId", toolCallId); } putIfPresent(payload, "sourceToolCallId", sourceToolCallId); - enrichAsyncToolPayload(payload, event.getMetadata(), toolCallId); + enrichAsyncToolPayload(payload, event.getMetadata(), rawPayload, toolCallId); return payload; } - private void enrichAsyncToolPayload(Map payload, Map metadata, String fallbackId) { + private void enrichAsyncToolPayload(Map payload, + Map metadata, + Map runtimePayload, + String fallbackId) { Map safeMetadata = metadata == null ? Map.of() : metadata; payload.put("asyncTool", true); putIfPresent(payload, "asyncToolName", firstText(stringValue(payload, "asyncToolName"), stringValue(safeMetadata, "asyncToolName"))); putIfPresent(payload, "phase", firstText(stringValue(safeMetadata, "asyncToolPhase"), stringValue(payload, "phase"))); - putIfPresent(payload, "taskId", resolveAsyncTaskId(payload, safeMetadata)); + putIfPresent(payload, "taskId", resolveAsyncTaskId(payload, safeMetadata, runtimePayload)); putIfPresent(payload, "status", firstText(stringValue(payload, "status"), stringValue(safeMetadata, "status"))); String displayName = firstText(stringValue(payload, "toolDisplayName"), firstText(stringValue(safeMetadata, "toolDisplayName"), stringValue(payload, "asyncToolName"))); @@ -2010,12 +2488,15 @@ public class AgentRunService { * @param metadata 事件元数据 * @return 异步任务 ID;不存在时返回 null */ - private String resolveAsyncTaskId(Map payload, Map metadata) { + private String resolveAsyncTaskId(Map payload, + Map metadata, + Map runtimePayload) { String taskId = firstText(stringValue(payload, "taskId"), stringValue(metadata, "taskId")); if (taskId != null && !taskId.isBlank()) { return taskId; } - Map input = mapPayload(firstNonNull(payload.get("input"), payload.get("toolInput"))); + Map source = runtimePayload == null ? Map.of() : runtimePayload; + Map input = mapPayload(firstNonNull(source.get("input"), source.get("toolInput"))); return firstText(stringValue(input, "taskId"), stringValue(input, "task_id")); } @@ -2040,13 +2521,26 @@ public class AgentRunService { * @return 知识库检索状态载荷 */ private Map buildKnowledgeRetrievalStatusPayload(AgentRuntimeEvent event) { - Map payload = new LinkedHashMap<>(event.getPayload() == null ? Map.of() : event.getPayload()); + Map payload = new LinkedHashMap<>(); payload.put("statusKey", "knowledge-retrieval"); payload.put("status", "done"); payload.put("label", "已检索知识库"); return payload; } + /** + * 构建不含 Runtime 原始上下文的内存压缩公开状态载荷。 + * + * @param event 内存压缩事件 + * @return 前端公开状态载荷 + */ + private Map buildMemoryCompressionStatusPayload(AgentRuntimeEvent event) { + Map rawPayload = event.getPayload() == null ? Map.of() : event.getPayload(); + Map payload = selectPayload(rawPayload, "compressed", "label", "phase", "status"); + payload.put("statusKey", "memory-compression"); + return payload; + } + /** * 构建挂起状态载荷。 * @@ -2054,13 +2548,35 @@ public class AgentRunService { * @return 前端状态载荷 */ private Map buildSuspendedStatusPayload(AgentRuntimeEvent event) { - Map payload = new LinkedHashMap<>(event.getPayload() == null ? Map.of() : event.getPayload()); + Map payload = new LinkedHashMap<>(); payload.put("statusKey", "agent-suspended"); payload.put("status", "waiting"); payload.put("label", "等待人工确认"); return payload; } + /** + * 复制标准投影所需字段,并替换为公开白名单载荷。 + * + * @param source Runtime 原始事件 + * @param payload 公开载荷 + * @return 不携带原始 metadata 和消息对象的投影事件 + */ + private AgentRuntimeEvent publicRuntimeEvent(AgentRuntimeEvent source, Map payload) { + AgentRuntimeEvent event = AgentRuntimeEvent.of(source.getEventType()); + event.setEventId(source.getEventId()); + event.setTraceId(source.getTraceId()); + event.setSessionId(source.getSessionId()); + event.setAgentId(source.getAgentId()); + event.setMessageId(firstText( + source.getMessageId(), + source.getMessage() == null ? null : source.getMessage().getMessageId())); + event.setToolCallId(source.getToolCallId()); + event.setCreatedAt(source.getCreatedAt()); + event.setPayload(payload == null ? new LinkedHashMap<>() : new LinkedHashMap<>(payload)); + return event; + } + /** * 构建发送给前端的最终知识库引用载荷。 * @@ -2103,6 +2619,23 @@ public class AgentRunService { } } + /** + * 从 Runtime 载荷中选取允许公开的字段。 + * + * @param source 原始载荷 + * @param allowedKeys 允许字段 + * @return 保持字段顺序的公开载荷 + */ + private Map selectPayload(Map source, String... allowedKeys) { + Map selected = new LinkedHashMap<>(); + for (String key : allowedKeys) { + if (source.containsKey(key)) { + selected.put(key, source.get(key)); + } + } + return selected; + } + private Map buildHitlMetadata(Map rawPayload) { Map metadata = new LinkedHashMap<>(); mapPayload(rawPayload.get("approvalMetadata")).forEach((key, value) -> { @@ -2116,7 +2649,7 @@ public class AgentRunService { metadata.put(key, value); } } - return metadata; + return ToolApprovalInputProjection.project(metadata); } private boolean isHitlPromptKey(String key) { @@ -2171,8 +2704,22 @@ public class AgentRunService { return builder.toString(); } - private String firstText(String first, String second) { - return first == null || first.isBlank() ? second : first; + /** + * 返回候选值中的首个非空文本。 + * + * @param candidates 按优先级排列的文本候选 + * @return 首个非空文本;全部为空时返回 {@code null} + */ + private String firstText(String... candidates) { + if (candidates == null) { + return null; + } + for (String candidate : candidates) { + if (candidate != null && !candidate.isBlank()) { + return candidate; + } + } + return null; } private Object firstNonNull(Object first, Object second) { 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 c36188b6..fe672ad6 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 @@ -2,7 +2,9 @@ package tech.easyflow.agent.runtime; import com.easyagents.agent.runtime.AgentDefinition; import com.easyagents.agent.runtime.AgentExecutionOptions; -import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest; +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.AgentKnowledgeRetrievalResult; @@ -11,28 +13,39 @@ import com.easyagents.agent.runtime.memory.AgentMemoryCompressionParameter; import com.easyagents.agent.runtime.memory.AgentMemoryPolicy; import com.easyagents.agent.runtime.memory.AgentMemoryType; import com.easyagents.agent.runtime.mcp.McpSpec; -import com.easyagents.agent.runtime.mcp.McpTransportType; +import com.easyagents.agent.runtime.mcp.McpToolManifestEntry; import com.easyagents.agent.runtime.model.AgentGenerationOptions; import com.easyagents.agent.runtime.model.AgentModelSpec; +import com.easyagents.agent.runtime.tool.AgentToolSpec; import com.easyagents.agent.runtime.tool.AgentToolCategory; import com.easyagents.agent.runtime.tool.AgentToolResult; -import com.easyagents.agent.runtime.tool.AgentToolSpec; +import com.easyagents.agent.runtime.tool.AgentToolVisibility; +import com.easyagents.agent.runtime.tool.operate.AgentOperateToolAdapter; +import com.easyagents.agent.runtime.tool.operate.AgentOperateToolSpec; +import com.easyagents.agent.runtime.tool.operate.AgentOperateToolType; +import com.easyagents.agent.runtime.tool.operate.ControlledShellTool; +import com.easyagents.agent.runtime.tool.operate.WorkspaceQuotaLimits; +import com.easyagents.agent.runtime.tool.operate.WorkspaceQuotaHook; import com.easyagents.core.document.Document; -import com.easyagents.core.model.chat.tool.Parameter; -import com.easyagents.core.model.chat.tool.Tool; +import io.agentscope.core.tool.Toolkit; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import tech.easyflow.agent.entity.Agent; import tech.easyflow.agent.entity.AgentKnowledgeBinding; -import tech.easyflow.agent.entity.AgentToolBinding; -import tech.easyflow.agent.enums.AgentToolType; +import tech.easyflow.agent.config.AgentBuiltinToolsConfig; +import tech.easyflow.agent.config.AgentBuiltinToolsConfigResolver; +import tech.easyflow.agent.config.AgentShellProperties; +import tech.easyflow.agent.config.AgentWorkspaceProperties; +import tech.easyflow.agent.runtime.artifact.AgentArtifactOperationException; +import tech.easyflow.agent.runtime.artifact.AgentArtifactService; +import tech.easyflow.agent.runtime.artifact.AgentArtifactView; +import tech.easyflow.agent.runtime.workspace.AgentWorkspaceResolver; import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompilation; import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler; -import tech.easyflow.ai.easyagents.tool.ChatToolNameHelper; -import tech.easyflow.ai.easyagents.tool.WorkflowTool; -import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds; +import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeCompilation; +import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeCompiler; import tech.easyflow.ai.entity.*; import tech.easyflow.ai.rag.KnowledgeRetrievalModes; import tech.easyflow.ai.rag.KnowledgeRetrievalRequest; @@ -41,9 +54,8 @@ import tech.easyflow.common.web.exceptions.BusinessException; import javax.annotation.Resource; import java.math.BigInteger; +import java.nio.file.Path; import java.time.Duration; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import java.util.*; /** @@ -59,22 +71,29 @@ public class AgentRuntimeCompiler { * EasyFlow 仅按 Token 阈值触发压缩,消息数阈值固定为不可达上限。 */ private static final int DISABLED_MESSAGE_COMPRESSION_THRESHOLD = Integer.MAX_VALUE; - private static final Pattern MCP_INPUT_PATTERN = Pattern.compile("\\$\\{input:([A-Za-z0-9_.-]+)}"); + private static final int MAX_RUNTIME_TOOL_COUNT = 128; + private static final long MAX_RUNTIME_SCHEMA_BYTES = 2L * 1024L * 1024L; @Resource private ModelService modelService; @Resource - private WorkflowService workflowService; - @Resource - private PluginItemService pluginItemService; - @Resource - private McpService mcpService; - @Resource private DocumentCollectionService documentCollectionService; @Resource private ObjectMapper objectMapper; @Resource private AgentToolRuntimeCompiler agentToolRuntimeCompiler; + @Resource + private AgentSkillRuntimeCompiler agentSkillRuntimeCompiler; + @Resource + private AgentBuiltinToolsConfigResolver agentBuiltinToolsConfigResolver; + @Resource + private AgentWorkspaceResolver agentWorkspaceResolver; + @Resource + private AgentWorkspaceProperties agentWorkspaceProperties; + @Resource + private AgentShellProperties agentShellProperties; + @Resource + private AgentArtifactService agentArtifactService; /** * 编译 Agent 运行时定义和调用器。 @@ -99,10 +118,39 @@ public class AgentRuntimeCompiler { bundle.setDefinition(definition); compileTools(agent, definition, bundle); + if (agentBuiltinToolsConfigResolver != null) { + validateBuiltinTools(definition, + agentBuiltinToolsConfigResolver.resolvePublishedRuntime(agent.getExecutionConfigJson())); + } compileKnowledge(agent, definition, bundle); return bundle; } + /** + * 为真实运行会话编译并附加会话隔离的内置工具。 + * + *

该重载先复用发布校验编译,再使用可信 RuntimeContext 创建当前会话工作区。

+ * + * @param agent Agent 运行视图 + * @param runtimeContext 可信运行上下文 + * @param draftMode 是否为草稿试运行 + * @return 带会话操作工具及 Artifact 调用器的运行时编译结果 + */ + public AgentRuntimeBundle compile(Agent agent, + AgentRuntimeContext runtimeContext, + boolean draftMode) { + AgentRuntimeBundle bundle = compile(agent); + // 仅兼容未经过 Spring 装配的历史单元测试桩;生产 Bean 必须完整注入以下依赖。 + if (agentBuiltinToolsConfigResolver == null) { + return bundle; + } + AgentBuiltinToolsConfig config = draftMode + ? agentBuiltinToolsConfigResolver.resolveDraftRuntime(agent.getExecutionConfigJson()) + : agentBuiltinToolsConfigResolver.resolvePublishedRuntime(agent.getExecutionConfigJson()); + attachBuiltinTools(agent, runtimeContext, draftMode, config, bundle); + return bundle; + } + private AgentModelSpec buildModelSpec(Agent agent) { Model model = modelService.getModelInstance(agent.getModelId()); if (model == null) { @@ -211,172 +259,336 @@ public class AgentRuntimeCompiler { } private void compileTools(Agent agent, AgentDefinition definition, AgentRuntimeBundle bundle) { - AgentToolRuntimeCompilation compilation = agentToolRuntimeCompiler.compile(agent); - definition.setToolSpecs(compilation.getToolSpecs()); - definition.setMcpSpecs(compilation.getMcpSpecs()); - bundle.setToolInvokers(compilation.getToolInvokers()); - } - - private Tool buildTool(AgentToolBinding binding) { - AgentToolType type = AgentToolType.from(binding.getToolType()); - if (type == AgentToolType.WORKFLOW) { - Workflow workflow = snapshotOrPublishedWorkflow(binding); - if (workflow == null) { - throw new BusinessException("绑定工作流不存在"); + AgentToolRuntimeCompilation direct = agentToolRuntimeCompiler.compile(agent); + AgentSkillRuntimeCompilation skills = agentSkillRuntimeCompiler.compile(agent); + List toolSpecs = new ArrayList<>(direct.getToolSpecs()); + Set names = new LinkedHashSet<>(); + direct.getToolSpecs().forEach(spec -> names.add(spec.getName())); + for (AgentToolSpec spec : skills.getToolSpecs()) { + if (!names.add(spec.getName())) { + throw new BusinessException("Agent Tool 运行名冲突:" + spec.getName()); } - return new WorkflowTool( - workflow, - true, - PublishedWorkflowDefinitionIds.published(String.valueOf(workflow.getId())) - ); + toolSpecs.add(spec); } - if (type == AgentToolType.PLUGIN) { - PluginItem pluginItem = snapshotOrCurrentPlugin(binding); - if (pluginItem == null) { - throw new BusinessException("绑定插件不存在"); + List mcpSpecs = new ArrayList<>(direct.getMcpSpecs()); + mcpSpecs.addAll(skills.getMcpSpecs()); + assertToolBudget(toolSpecs, mcpSpecs); + Map invokers = + new LinkedHashMap<>(direct.getToolInvokers()); + skills.getToolInvokers().forEach((name, invoker) -> { + if (invokers.putIfAbsent(name, invoker) != null) { + throw new BusinessException("Agent Tool 运行名冲突:" + name); } - return pluginItem.toFunction(); - } - throw new BusinessException("不支持的 Agent 工具类型:" + type.name()); + }); + definition.setToolSpecs(toolSpecs); + definition.setMcpSpecs(mcpSpecs); + definition.setSkillBoxSpec(skills.getSkillBoxSpec()); + bundle.setToolInvokers(invokers); } - private McpSpec buildMcpSpec(AgentToolBinding binding) { - Mcp mcp = snapshotOrCurrentMcp(binding); - if (mcp == null) { - throw new BusinessException("绑定 MCP 不存在"); + private void validateBuiltinTools(AgentDefinition definition, AgentBuiltinToolsConfig config) { + Set builtinNames = builtinToolNames(config); + assertNoBuiltinNameConflict(definition, builtinNames); + List specs = new ArrayList<>(definition.getToolSpecs()); + specs.addAll(buildOperateBudgetSpecs(config)); + if (config.artifactPublish().enabled()) { + specs.add(buildArtifactPublishSpec(config.artifactPublish())); } - Map.Entry> server = firstMcpServer(mcp); - Map serverConfig = server.getValue(); - McpTransportType transportType = parseMcpTransportType(mcp, serverConfig); - - McpSpec spec = new McpSpec(); - spec.setName(mcpRuntimeName(mcp)); - spec.setDescription(firstNonBlank(mcp.getDescription(), mcp.getTitle())); - spec.setTransportType(transportType); - spec.setCommand(resolveMcpInput(stringValue(serverConfig, "command", null))); - spec.setArgs(resolveMcpInputs(stringListValue(serverConfig, "args"))); - spec.setEnv(resolveMcpInputMap(stringMapValue(serverConfig, "env"))); - spec.setUrl(resolveMcpInput(stringValue(serverConfig, "url", null))); - spec.setHeaders(resolveMcpInputMap(stringMapValue(serverConfig, "headers"))); - spec.setQueryParams(resolveMcpInputMap(stringMapValue(serverConfig, "queryParams"))); - Duration timeout = durationValue(serverConfig, "timeout"); - if (timeout != null) { - spec.setTimeout(timeout); - } - Duration initializationTimeout = durationValue(serverConfig, "initializationTimeout"); - if (initializationTimeout != null) { - spec.setInitializationTimeout(initializationTimeout); - } - spec.setGroupName(mcpRuntimeName(mcp)); - spec.setApprovalRequired(Boolean.TRUE.equals(mcp.getApprovalRequired())); - spec.setApprovalRequest(buildMcpApprovalRequest(mcp)); - spec.setToolNamePrefix(mcpRuntimeToolPrefix(mcp.getId())); - spec.getMetadata().put("toolType", AgentToolType.MCP.name()); - spec.getMetadata().put("mcpId", String.valueOf(mcp.getId())); - spec.getMetadata().put("mcpTitle", mcp.getTitle()); - spec.getMetadata().put("serverName", server.getKey()); - return spec; + assertToolBudget(specs, definition.getMcpSpecs()); } - private void applyMcpToolBinding(McpSpec spec, AgentToolBinding binding) { - if (Boolean.TRUE.equals(binding.getHitlEnabled())) { - spec.setApprovalRequired(true); - spec.setApprovalRequest(buildBindingApprovalRequest(binding)); + private void attachBuiltinTools(Agent agent, + AgentRuntimeContext runtimeContext, + boolean draftMode, + AgentBuiltinToolsConfig config, + AgentRuntimeBundle bundle) { + if (runtimeContext == null || runtimeContext.getTenantId() == null + || runtimeContext.getSessionId() == null) { + throw new BusinessException("Agent 内置工具运行上下文不完整"); } - } - - private AgentToolApprovalRequest buildMcpApprovalRequest(Mcp mcp) { - AgentToolApprovalRequest request = new AgentToolApprovalRequest(); - request.setApprovalPrompt("是否批准执行 MCP 工具:" + firstNonBlank(mcp.getTitle(), mcpRuntimeName(mcp))); - Map metadata = new LinkedHashMap<>(); - metadata.put("toolType", AgentToolType.MCP.name()); - metadata.put("mcpId", String.valueOf(mcp.getId())); - metadata.put("mcpTitle", mcp.getTitle()); - request.setMetadata(metadata); - return request; - } - - private AgentToolApprovalRequest buildBindingApprovalRequest(AgentToolBinding binding) { - AgentToolApprovalRequest request = new AgentToolApprovalRequest(); - request.setApprovalPrompt(stringValue(binding.getHitlConfigJson(), "prompt", "是否批准执行 MCP 工具")); - Map metadata = sanitizedHitlMetadata(binding.getHitlConfigJson()); - metadata.put("toolType", binding.getToolType()); - metadata.put("bindingId", binding.getId()); - metadata.put("targetId", binding.getTargetId()); - request.setMetadata(metadata); - return request; - } - - private AgentToolSpec toToolSpec(Tool tool, AgentToolBinding binding) { - AgentToolSpec spec = new AgentToolSpec(); - String name = resolveRuntimeToolName(tool, binding); - spec.setName(name); - spec.setDescription(safeDescription(tool == null ? null : tool.getDescription())); - spec.setCategory(AgentToolCategory.valueOf(AgentToolType.from(binding.getToolType()).name())); - spec.setParametersSchema(toSchema(tool == null ? null : tool.getParameters())); - spec.setApprovalRequired(Boolean.TRUE.equals(binding.getHitlEnabled())); - if (Boolean.TRUE.equals(binding.getHitlEnabled())) { - AgentToolApprovalRequest request = new AgentToolApprovalRequest(); - request.setApprovalPrompt(stringValue(binding.getHitlConfigJson(), "prompt", "是否批准执行工具:" + name)); - Map metadata = sanitizedHitlMetadata(binding.getHitlConfigJson()); - metadata.put("toolType", binding.getToolType()); - metadata.put("bindingId", binding.getId()); - metadata.put("targetId", binding.getTargetId()); - request.setMetadata(metadata); - spec.setApprovalRequest(request); + validateBuiltinTools(bundle.getDefinition(), config); + if (builtinToolNames(config).isEmpty()) { + return; } - spec.getMetadata().put("bindingId", binding.getId()); - spec.getMetadata().put("targetId", binding.getTargetId()); - return spec; - } - - private Map sanitizedHitlMetadata(Map config) { - Map metadata = new LinkedHashMap<>(); - if (config != null) { - config.forEach((key, value) -> { - if (!isHitlPromptKey(key)) { - metadata.put(key, value); - } - }); - } - return metadata; - } - - private boolean isHitlPromptKey(String key) { - if (key == null) { - return false; - } - String normalized = key.trim(); - return "prompt".equalsIgnoreCase(normalized) - || "question".equalsIgnoreCase(normalized) - || "approvalPrompt".equalsIgnoreCase(normalized); - } - - private AgentToolResult invokeTool(Tool tool, Map arguments) { - String toolName = tool == null ? null : tool.getName(); - LOG.info("Agent tool invoke started, toolName={}, arguments={}", toolName, arguments); + Path workspace; try { - Object result = tool.invoke(arguments == null ? Map.of() : arguments); - String resultText = result == null ? "" : String.valueOf(result); - LOG.info("Agent tool invoke completed, toolName={}, result={}", toolName, truncate(resultText)); - return AgentToolResult.success(resultText); - } catch (Exception e) { - LOG.error("Agent tool invoke failed, toolName={}, message={}", toolName, e.getMessage(), e); - return AgentToolResult.failure(e.getMessage() == null ? "工具执行失败" : e.getMessage()); + workspace = agentWorkspaceResolver.resolve( + new BigInteger(runtimeContext.getTenantId()), agent.getId(), runtimeContext.getSessionId()); + } catch (NumberFormatException error) { + throw new BusinessException("Agent 内置工具租户标识不合法"); + } + WorkspaceQuotaLimits quota = workspaceQuota(); + List operateSpecs = new ArrayList<>(); + addOperateSpec(operateSpecs, AgentOperateToolType.READ_FILE, config.read(), workspace, quota); + addOperateSpec(operateSpecs, AgentOperateToolType.WRITE_FILE, config.write(), workspace, quota); + addOperateSpec(operateSpecs, AgentOperateToolType.PATCH, config.patch(), workspace, quota); + addOperateSpec(operateSpecs, AgentOperateToolType.SHELL, config.shell(), workspace, quota); + bundle.getDefinition().setOperateToolSpecs(operateSpecs); + if (config.artifactPublish().enabled()) { + AgentToolSpec spec = buildArtifactPublishSpec(config.artifactPublish()); + bundle.getDefinition().getToolSpecs().add(spec); + if (bundle.getToolInvokers().putIfAbsent(spec.getName(), + (arguments, context) -> publishArtifact(arguments, context, workspace, draftMode)) != null) { + throw new BusinessException("Agent Tool 运行名冲突:" + spec.getName()); + } } } - private String resolveRuntimeToolName(Tool tool, AgentToolBinding binding) { - String bindingName = binding == null ? null : binding.getToolName(); - if (ChatToolNameHelper.isSafeToolName(bindingName)) { - return bindingName; + private List buildOperateBudgetSpecs(AgentBuiltinToolsConfig config) { + if (agentWorkspaceResolver == null || agentWorkspaceResolver.getRealRoot() == null) { + throw new BusinessException("Agent 工作区尚未初始化"); } - String toolName = tool == null ? null : tool.getName(); - if (ChatToolNameHelper.isSafeToolName(toolName)) { - return toolName; + List operateSpecs = new ArrayList<>(); + Path root = agentWorkspaceResolver.getRealRoot(); + WorkspaceQuotaLimits quota = workspaceQuota(); + addOperateSpec(operateSpecs, AgentOperateToolType.READ_FILE, config.read(), root, quota); + addOperateSpec(operateSpecs, AgentOperateToolType.WRITE_FILE, config.write(), root, quota); + addOperateSpec(operateSpecs, AgentOperateToolType.PATCH, config.patch(), root, quota); + addOperateSpec(operateSpecs, AgentOperateToolType.SHELL, config.shell(), root, quota); + Toolkit toolkit = new Toolkit(); + List specs = new AgentOperateToolAdapter().register(operateSpecs, toolkit); + for (AgentToolSpec spec : specs) { + io.agentscope.core.tool.AgentTool tool = toolkit.getTool(spec.getName()); + if (tool == null) { + throw new BusinessException("Agent 内置工具 Schema 生成失败:" + spec.getName()); + } + spec.setParametersSchema(tool.getParameters()); + spec.setOutputSchema(tool.getOutputSchema()); + } + return specs; + } + + private WorkspaceQuotaLimits workspaceQuota() { + return new WorkspaceQuotaLimits( + agentWorkspaceProperties.getMaxTotalSize().toBytes(), + agentWorkspaceProperties.getMaxSingleFileSize().toBytes(), + agentWorkspaceProperties.getMaxFileCount(), + agentWorkspaceProperties.getMaxReadSize().toBytes()); + } + + private void addOperateSpec(List target, + AgentOperateToolType type, + AgentBuiltinToolsConfig.ToolSwitch toolSwitch, + Path workspace, + WorkspaceQuotaLimits quota) { + if (!toolSwitch.enabled()) { + return; + } + AgentOperateToolSpec spec = new AgentOperateToolSpec(); + spec.setType(type); + spec.setBaseDir(workspace.toString()); + spec.setApprovalRequired(toolSwitch.approvalRequired()); + spec.setWorkspaceQuotaLimits(quota); + spec.setWorkspaceQuotaHook(new WorkspaceQuotaHook() { + @Override + public void beforeRead(Path workspaceRoot, Path target, long requestedBytes) { + agentWorkspaceResolver.touch(workspaceRoot); + } + + @Override + public void beforeWrite(Path workspaceRoot, Path target, long previousBytes, long resultingBytes) { + agentWorkspaceResolver.touch(workspaceRoot); + } + }); + if (type == AgentOperateToolType.PATCH) { + spec.setPatchMaxSize(agentWorkspaceProperties.getMaxReadSize().toBytes()); + spec.setPatchMaxFiles(agentWorkspaceProperties.getMaxFileCount()); + spec.setPatchMaxAffectedBytes(agentWorkspaceProperties.getMaxTotalSize().toBytes()); + } + if (type == AgentOperateToolType.SHELL) { + spec.setShellAllowedCommands(ControlledShellTool.DEFAULT_ALLOWED_COMMANDS); + spec.setShellDefaultTimeout(agentShellProperties.getDefaultTimeout()); + spec.setShellMaxTimeout(agentShellProperties.getMaxTimeout()); + spec.setShellMaxCommandLength(agentShellProperties.getMaxCommandLength()); + spec.setShellMaxOutputSize(agentShellProperties.getMaxOutputSize().toBytes()); + spec.setShellMaxConcurrency(agentShellProperties.getMaxConcurrentPerInstance()); + } + target.add(spec); + } + + private AgentToolSpec buildArtifactPublishSpec(AgentBuiltinToolsConfig.ToolSwitch toolSwitch) { + AgentToolSpec spec = new AgentToolSpec(); + spec.setName("artifact_publish"); + spec.setDescription("Publish a completed user-facing file from the current Agent workspace as a private " + + "downloadable artifact. You MUST call this tool after creating or updating any final file that " + + "the user expects to receive or download, including DOCX, XLSX, PPTX, PDF, CSV, images, archives, " + + "or source files. Do not finish with only a workspace path. Publish each final deliverable after " + + "validation, use a clear download filename, and publish the updated version again if the file " + + "changes. Do not publish temporary files, intermediate scripts, caches, previews, or internal " + + "working files. If publishing fails, report the failure clearly to the user."); + spec.setCategory(AgentToolCategory.CUSTOM); + spec.setVisibility(AgentToolVisibility.VISIBLE); + spec.setApprovalRequired(toolSwitch.approvalRequired()); + spec.setParametersSchema(Map.of( + "type", "object", + "properties", Map.of( + "path", Map.of("type", "string", "description", + "Workspace-relative path to the completed final file; directories and temporary or intermediate files are not allowed."), + "fileName", Map.of("type", "string", "description", + "Optional user-facing download name with the correct file extension.")), + "required", List.of("path"), + "additionalProperties", false)); + spec.setOutputSchema(Map.of( + "type", "object", + "properties", Map.of( + "schemaVersion", Map.of("type", "integer"), + "artifactId", Map.of("type", "string"), + "fileName", Map.of("type", "string"), + "mimeType", Map.of("type", "string"), + "size", Map.of("type", "integer"), + "sha256", Map.of("type", "string"), + "downloadUrl", Map.of("type", "string"), + "status", Map.of("type", "string")), + "required", List.of("schemaVersion", "artifactId", "fileName", "mimeType", "size", + "sha256", "downloadUrl", "status"), + "additionalProperties", false)); + return spec; + } + + private AgentToolResult publishArtifact(Map arguments, + com.easyagents.agent.runtime.tool.AgentToolContext context, + Path workspace, + boolean draftMode) { + try { + String path = stringValue(arguments, "path", null); + String fileName = stringValue(arguments, "fileName", null); + AgentArtifactView artifact = agentArtifactService.publish( + workspace, path, fileName, + draftMode ? AgentArtifactService.MODE_DRAFT : AgentArtifactService.MODE_FORMAL, + context); + Map safe = artifact.toMap(); + AgentRuntimeEvent projection = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_RESULT); + projection.setTraceId(context.getTraceId()); + projection.setSessionId(context.getSessionId()); + projection.setAgentId(context.getAgentId()); + projection.setToolCallId(context.getToolCallId()); + projection.getPayload().put("artifactProjectionOnly", true); + projection.getPayload().put("toolName", "artifact_publish"); + projection.getPayload().put("artifactPublished", safe); + context.emitEvent(projection); + String json = objectMapper.writeValueAsString(safe); + AgentToolResult result = AgentToolResult.success(json); + result.setDisplayContent(safe); + return result; + } catch (AgentArtifactOperationException error) { + return artifactFailure(error.getCode(), error.getMessage(), error.isRetryable()); + } catch (Exception error) { + LOG.error("Agent artifact_publish tool failed", error); + return artifactFailure("ARTIFACT_PUBLISH_FAILED", "产物发布失败", true); + } + } + + private AgentToolResult artifactFailure(String code, String message, boolean retryable) { + try { + return AgentToolResult.failure(objectMapper.writeValueAsString(Map.of( + "code", code, "message", message, "retryable", retryable))); + } catch (Exception error) { + LOG.error("Serialize Agent artifact failure payload failed, code={}", code, error); + return AgentToolResult.failure("{\"code\":\"ARTIFACT_PUBLISH_FAILED\",\"message\":\"产物发布失败\",\"retryable\":true}"); + } + } + + private Set builtinToolNames(AgentBuiltinToolsConfig config) { + Set names = new LinkedHashSet<>(); + if (config.read().enabled()) { + names.add(AgentOperateToolAdapter.VIEW_TEXT_FILE_TOOL); + names.add(AgentOperateToolAdapter.LIST_DIRECTORY_TOOL); + } + if (config.write().enabled()) { + names.add(AgentOperateToolAdapter.WRITE_TEXT_FILE_TOOL); + names.add(AgentOperateToolAdapter.INSERT_TEXT_FILE_TOOL); + } + if (config.patch().enabled()) { + names.add(AgentOperateToolAdapter.APPLY_PATCH_TOOL); + } + if (config.shell().enabled()) { + names.add(AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL); + } + if (config.artifactPublish().enabled()) { + names.add("artifact_publish"); + } + return names; + } + + private void assertNoBuiltinNameConflict(AgentDefinition definition, Set builtinNames) { + Set existing = new LinkedHashSet<>(); + for (AgentToolSpec spec : definition.getToolSpecs()) { + existing.add(spec.getName()); + } + for (McpSpec mcp : definition.getMcpSpecs()) { + if (mcp.getFrozenToolManifest() != null) { + mcp.getFrozenToolManifest().forEach(entry -> existing.add(entry.getName())); + } + if (mcp.getEnableTools() != null) { + existing.addAll(mcp.getEnableTools()); + } + } + for (String name : builtinNames) { + if (existing.contains(name)) { + throw new BusinessException("Agent Tool 运行名冲突:" + name); + } + } + } + + /** + * 对最终合并后的直接 Tool、Skill Tool 与冻结 MCP 清单执行统一预算校验。 + * + * @param toolSpecs 静态 Tool 声明 + * @param mcpSpecs MCP 声明 + */ + private void assertToolBudget(List toolSpecs, List mcpSpecs) { + assertToolBudget(toolSpecs, mcpSpecs, 0); + } + + private void assertToolBudget(List toolSpecs, + List mcpSpecs, + int additionalToolCount) { + int toolCount = toolSpecs == null ? 0 : toolSpecs.size(); + toolCount = Math.addExact(toolCount, additionalToolCount); + long schemaBytes = 0L; + if (toolSpecs != null) { + for (AgentToolSpec spec : toolSpecs) { + schemaBytes = addSchemaBytes(schemaBytes, spec.getParametersSchema()); + schemaBytes = addSchemaBytes(schemaBytes, spec.getOutputSchema()); + } + } + if (mcpSpecs != null) { + for (McpSpec spec : mcpSpecs) { + List manifest = spec.getFrozenToolManifest(); + if (manifest != null && !manifest.isEmpty()) { + toolCount = Math.addExact(toolCount, manifest.size()); + for (McpToolManifestEntry entry : manifest) { + schemaBytes = addSchemaBytes(schemaBytes, entry.getInputSchema()); + schemaBytes = addSchemaBytes(schemaBytes, entry.getOutputSchema()); + } + } else if (spec.getEnableTools() != null && !spec.getEnableTools().isEmpty()) { + toolCount = Math.addExact(toolCount, spec.getEnableTools().size()); + } else { + // 历史直接 MCP 尚无冻结清单时至少计为一个动态工具;新 Skill MCP 均必须有清单。 + toolCount = Math.addExact(toolCount, 1); + } + } + } + if (toolCount > MAX_RUNTIME_TOOL_COUNT) { + throw new BusinessException("Agent Runtime Tool 数量超过 128 个,请减少直接工具或 Skill 绑定"); + } + if (schemaBytes > MAX_RUNTIME_SCHEMA_BYTES) { + throw new BusinessException("Agent Runtime Tool Schema 超过 2 MiB,请减少工具或精简 Schema"); + } + } + + private long addSchemaBytes(long current, Object schema) { + try { + long total = Math.addExact(current, objectMapper.writeValueAsBytes(schema == null ? Map.of() : schema).length); + if (total > MAX_RUNTIME_SCHEMA_BYTES) { + throw new BusinessException("Agent Runtime Tool Schema 超过 2 MiB,请减少工具或精简 Schema"); + } + return total; + } catch (BusinessException exception) { + throw exception; + } catch (Exception exception) { + throw new BusinessException(500, 500, "计算 Agent Tool Schema 预算失败", exception); } - BigInteger targetId = binding == null ? null : binding.getTargetId(); - return ChatToolNameHelper.buildFallbackName("tool", targetId); } private void compileKnowledge(Agent agent, AgentDefinition definition, AgentRuntimeBundle bundle) { @@ -502,165 +714,6 @@ public class AgentRuntimeCompiler { return text.substring(0, LOG_TEXT_MAX_LENGTH) + "..."; } - private Workflow snapshotOrPublishedWorkflow(AgentToolBinding binding) { - if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) { - Workflow workflow = objectMapper.convertValue(binding.getResourceSnapshot(), Workflow.class); - workflow.setId(firstNonNull(workflow.getId(), binding.getTargetId())); - return workflow; - } - return workflowService.getPublishedById(binding.getTargetId()); - } - - private PluginItem snapshotOrCurrentPlugin(AgentToolBinding binding) { - if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) { - PluginItem pluginItem = objectMapper.convertValue(binding.getResourceSnapshot(), PluginItem.class); - pluginItem.setId(firstNonNull(pluginItem.getId(), binding.getTargetId())); - return pluginItem; - } - return pluginItemService.getById(binding.getTargetId()); - } - - private Mcp snapshotOrCurrentMcp(AgentToolBinding binding) { - if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) { - Mcp mcp = objectMapper.convertValue(binding.getResourceSnapshot(), Mcp.class); - mcp.setId(firstNonNull(mcp.getId(), binding.getTargetId())); - return mcp; - } - return mcpService.getById(binding.getTargetId()); - } - - private Map.Entry> firstMcpServer(Mcp mcp) { - Map config = parseMcpConfig(mcp); - Map servers = mapValue(config, "mcpServers"); - if (servers.isEmpty()) { - throw new BusinessException("MCP 配置 JSON 中没有找到任何 MCP 服务名称"); - } - Map.Entry first = servers.entrySet().iterator().next(); - if (!(first.getValue() instanceof Map rawServer)) { - throw new BusinessException("MCP 服务配置必须是对象:" + first.getKey()); - } - Map serverConfig = new LinkedHashMap<>(); - rawServer.forEach((key, value) -> serverConfig.put(String.valueOf(key), value)); - return Map.entry(first.getKey(), serverConfig); - } - - private Map parseMcpConfig(Mcp mcp) { - String configJson = mcp == null ? null : mcp.getConfigJson(); - if (configJson == null || configJson.isBlank()) { - throw new BusinessException("MCP 配置 JSON 不能为空"); - } - try { - return objectMapper.readValue(configJson, new com.fasterxml.jackson.core.type.TypeReference<>() {}); - } catch (Exception e) { - throw new BusinessException("MCP 配置 JSON 格式错误"); - } - } - - private McpTransportType parseMcpTransportType(Mcp mcp, Map serverConfig) { - String transport = firstNonBlank( - mcp == null ? null : mcp.getTransportType(), - stringValue(serverConfig, "transport", null) - ); - return McpTransportType.from(transport); - } - - private String mcpRuntimeName(Mcp mcp) { - BigInteger id = mcp == null ? null : mcp.getId(); - return "mcp_" + safeToolNameSegment(id == null ? "unknown" : String.valueOf(id)); - } - - private String mcpRuntimeToolPrefix(BigInteger mcpId) { - return "mcp_" + safeToolNameSegment(String.valueOf(mcpId)) + "_"; - } - - private String safeToolNameSegment(String value) { - String normalized = String.valueOf(value == null ? "" : value).trim() - .replaceAll("[^A-Za-z0-9_-]", "_") - .replaceAll("_+", "_"); - if (normalized.isBlank()) { - return "tool"; - } - return normalized; - } - - private List stringListValue(Map map, String key) { - Object value = map == null ? null : map.get(key); - if (value == null) { - return new ArrayList<>(); - } - if (value instanceof Collection collection) { - List result = new ArrayList<>(); - for (Object item : collection) { - if (item != null) { - result.add(String.valueOf(item)); - } - } - return result; - } - throw new BusinessException("Agent 配置字段必须是数组:" + key); - } - - private Duration durationValue(Map map, String key) { - Object value = map == null ? null : map.get(key); - if (value == null) { - return null; - } - if (value instanceof Number number) { - return Duration.ofSeconds(number.longValue()); - } - String text = String.valueOf(value).trim(); - if (text.isEmpty()) { - return null; - } - try { - return Duration.parse(text); - } catch (Exception ignored) { - try { - return Duration.ofSeconds(Long.parseLong(text)); - } catch (NumberFormatException e) { - throw new BusinessException("Agent 配置字段必须是秒数或 Duration:" + key); - } - } - } - - private List resolveMcpInputs(List values) { - if (values == null || values.isEmpty()) { - return new ArrayList<>(); - } - List result = new ArrayList<>(values.size()); - for (String value : values) { - result.add(resolveMcpInput(value)); - } - return result; - } - - private Map resolveMcpInputMap(Map values) { - if (values == null || values.isEmpty()) { - return new LinkedHashMap<>(); - } - Map result = new LinkedHashMap<>(); - values.forEach((key, value) -> result.put(key, resolveMcpInput(value))); - return result; - } - - private String resolveMcpInput(String value) { - if (value == null || value.isBlank()) { - return value; - } - Matcher matcher = MCP_INPUT_PATTERN.matcher(value); - StringBuffer resolved = new StringBuffer(); - while (matcher.find()) { - String inputKey = matcher.group(1); - String resolvedValue = System.getProperty("mcp.input." + inputKey); - if (resolvedValue == null || resolvedValue.isBlank()) { - throw new BusinessException("MCP 输入变量未解析:" + inputKey); - } - matcher.appendReplacement(resolved, Matcher.quoteReplacement(resolvedValue)); - } - matcher.appendTail(resolved); - return resolved.toString(); - } - private DocumentCollection snapshotOrPublishedKnowledge(AgentKnowledgeBinding binding) { if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) { DocumentCollection knowledge = objectMapper.convertValue(binding.getResourceSnapshot(), DocumentCollection.class); @@ -683,74 +736,6 @@ public class AgentRuntimeCompiler { return value == null ? null : String.valueOf(value); } - private Map toSchema(Parameter[] parameters) { - Map schema = new LinkedHashMap<>(); - Map properties = new LinkedHashMap<>(); - List required = new ArrayList<>(); - if (parameters != null) { - for (Parameter parameter : parameters) { - properties.put(parameter.getName(), parameterSchema(parameter)); - if (parameter.isRequired()) { - required.add(parameter.getName()); - } - } - } - schema.put("type", "object"); - schema.put("properties", properties); - schema.put("required", required); - return schema; - } - - private Map parameterSchema(Parameter parameter) { - Map schema = new LinkedHashMap<>(); - schema.put("type", parameter.getType() == null ? "string" : parameter.getType()); - putOptionalString(schema, "description", parameter.getDescription()); - if (parameter.getChildren() != null && !parameter.getChildren().isEmpty()) { - Map children = new LinkedHashMap<>(); - for (Parameter child : parameter.getChildren()) { - if (child != null && child.getName() != null && !child.getName().isBlank()) { - children.put(child.getName(), parameterSchema(child)); - } - } - if ("array".equalsIgnoreCase(parameter.getType())) { - schema.put("items", firstArrayItemSchema(parameter.getChildren())); - } else { - schema.put("properties", children); - } - } - return schema; - } - - private Map firstArrayItemSchema(List children) { - return children.stream() - .filter(Objects::nonNull) - .findFirst() - .map(this::parameterSchema) - .orElse(Map.of("type", "string")); - } - - /** - * 写入非空字符串字段,避免向模型 function schema 输出 null。 - * - * @param target 目标 schema - * @param key 字段名 - * @param value 字段值 - */ - private void putOptionalString(Map target, String key, String value) { - if (value != null && !value.isBlank()) { - target.put(key, value); - } - } - - /** - * 将工具描述规整为模型协议可接受的字符串。 - * - * @param description 原始描述 - * @return 非 null 描述 - */ - private String safeDescription(String description) { - return description == null ? "" : description; - } private AgentMemoryType memoryTypeValue(Map map, String key) { String value = stringValue(map, key, AgentMemoryType.AUTO_CONTEXT.name()); diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentToolHitlPayload.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentToolHitlPayload.java index deb52c2e..41767cb4 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentToolHitlPayload.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentToolHitlPayload.java @@ -9,7 +9,7 @@ import java.util.Map; public class AgentToolHitlPayload { private String requestId; - private String resumeToken; + private String approvalId; private String sessionId; private String agentId; private String toolCallId; @@ -39,21 +39,21 @@ public class AgentToolHitlPayload { } /** - * 获取恢复令牌。 + * 获取公开审批 ID。 * - * @return 恢复令牌 + * @return 不暴露内部恢复令牌的审批 ID */ - public String getResumeToken() { - return resumeToken; + public String getApprovalId() { + return approvalId; } /** - * 设置恢复令牌。 + * 设置公开审批 ID。 * - * @param resumeToken 恢复令牌 + * @param approvalId 公开审批 ID */ - public void setResumeToken(String resumeToken) { - this.resumeToken = resumeToken; + public void setApprovalId(String approvalId) { + this.approvalId = approvalId; } /** diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/agui/AgentAguiHitlResolveRequest.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/agui/AgentAguiHitlResolveRequest.java new file mode 100644 index 00000000..087b9809 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/agui/AgentAguiHitlResolveRequest.java @@ -0,0 +1,41 @@ +package tech.easyflow.agent.runtime.agui; + +/** + * AG-UI 自定义 HITL 兼容桥的审批请求。 + */ +public class AgentAguiHitlResolveRequest { + + private String approvalId; + private String decision; + private String reason; + + /** @return 公开审批 ID */ + public String getApprovalId() { + return approvalId; + } + + /** @param approvalId 公开审批 ID */ + public void setApprovalId(String approvalId) { + this.approvalId = approvalId; + } + + /** @return APPROVE 或 REJECT */ + public String getDecision() { + return decision; + } + + /** @param decision APPROVE 或 REJECT */ + public void setDecision(String decision) { + this.decision = decision; + } + + /** @return 拒绝原因 */ + public String getReason() { + return reason; + } + + /** @param reason 拒绝原因 */ + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/agui/AgentAguiRunInputMapper.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/agui/AgentAguiRunInputMapper.java new file mode 100644 index 00000000..bebaf54e --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/agui/AgentAguiRunInputMapper.java @@ -0,0 +1,400 @@ +package tech.easyflow.agent.runtime.agui; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.agui.model.AguiMessage; +import io.agentscope.core.agui.model.RunAgentInput; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.entity.AgentKnowledgeBinding; +import tech.easyflow.agent.entity.AgentSkillBinding; +import tech.easyflow.agent.entity.AgentToolBinding; +import tech.easyflow.agent.runtime.AgentChatCapability; +import tech.easyflow.agent.runtime.AgentChatRequest; +import tech.easyflow.agent.runtime.AgentDraftChatRequest; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 将受控 AG-UI RunAgentInput 映射为现有 Agent 业务请求。 + * + *

客户端历史、工具、上下文和 state 均不进入 Runtime 权限或会话恢复逻辑。

+ */ +@Component +public class AgentAguiRunInputMapper { + + private static final Logger LOG = LoggerFactory.getLogger(AgentAguiRunInputMapper.class); + private static final int MAX_ATTACHMENTS_PER_TYPE = 32; + private static final int MAX_BINDINGS_PER_TYPE = 256; + private static final int MAX_SKILL_BINDINGS = 20; + private static final int MAX_CAPABILITIES = 32; + private static final int MAX_CAPABILITY_RESOURCE_IDS = 256; + private static final int MAX_FORWARDED_PROPS_BYTES = 1_048_576; + private static final int MAX_IDENTIFIER_LENGTH = 128; + private static final int MAX_PROMPT_LENGTH = 65_536; + private static final Set FORMAL_EASYFLOW_KEYS = Set.of("input"); + private static final Set DRAFT_EASYFLOW_KEYS = Set.of("draft", "input"); + private static final Set INPUT_KEYS = Set.of( + "capabilities", "documentUploadIds", "imageUploadIds"); + private static final Set DRAFT_KEYS = Set.of( + "agent", "knowledgeBindings", "toolBindings", "skillBindings"); + private static final Set SKILL_BINDING_KEYS = Set.of("skillId", "sortNo"); + + private final ObjectMapper objectMapper; + + /** + * 创建输入映射器。 + * + * @param objectMapper Jackson 映射器 + */ + public AgentAguiRunInputMapper(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * 映射正式聊天请求。 + * + * @param agentId URL 中的可信 Agent ID + * @param input AG-UI 运行输入 + * @return 现有正式聊天请求 + */ + public AgentChatRequest toFormalRequest(BigInteger agentId, RunAgentInput input) { + ValidatedInput validated = validate(input, false); + AgentChatRequest request = new AgentChatRequest(); + request.setAgentId(agentId); + request.setSessionId(parseFormalThreadId(validated.threadId())); + request.setPrompt(validated.userMessage().getContent()); + Map inputProps = nestedMap(easyflowProps(input), "input"); + request.setImageUploadIds(stringList( + inputProps.get("imageUploadIds"), "imageUploadIds", MAX_ATTACHMENTS_PER_TYPE)); + request.setDocumentUploadIds(stringList( + inputProps.get("documentUploadIds"), "documentUploadIds", MAX_ATTACHMENTS_PER_TYPE)); + List capabilities = convertList( + inputProps.get("capabilities"), AgentChatCapability.class, + "capabilities", MAX_CAPABILITIES); + validateCapabilities(capabilities); + request.setCapabilities(capabilities); + return request; + } + + /** + * 映射草稿试用请求。 + * + * @param input AG-UI 运行输入 + * @return 现有草稿试用请求 + */ + public AgentDraftChatRequest toDraftRequest(RunAgentInput input) { + ValidatedInput validated = validate(input, true); + Map easyflow = easyflowProps(input); + Map inputProps = nestedMap(easyflow, "input"); + Map draftProps = nestedMap(easyflow, "draft"); + AgentDraftChatRequest request = new AgentDraftChatRequest(); + request.setSessionId(validated.threadId()); + request.setPrompt(validated.userMessage().getContent()); + request.setImageUploadIds(stringList( + inputProps.get("imageUploadIds"), "imageUploadIds", MAX_ATTACHMENTS_PER_TYPE)); + request.setDocumentUploadIds(stringList( + inputProps.get("documentUploadIds"), "documentUploadIds", MAX_ATTACHMENTS_PER_TYPE)); + request.setAgent(convertRequired(draftProps.get("agent"), Agent.class, "Agent 草稿不能为空")); + request.setToolBindings(convertList( + draftProps.get("toolBindings"), AgentToolBinding.class, + "toolBindings", MAX_BINDINGS_PER_TYPE)); + request.setKnowledgeBindings(convertList( + draftProps.get("knowledgeBindings"), AgentKnowledgeBinding.class, + "knowledgeBindings", MAX_BINDINGS_PER_TYPE)); + request.setSkillBindings(convertSkillBindings(draftProps.get("skillBindings"))); + return request; + } + + /** + * 获取 AG-UI wire 上下文。 + * + * @param input AG-UI 运行输入 + * @return wire 上下文 + */ + public AgentAguiWireContext wireContext(RunAgentInput input) { + ValidatedInput validated = validateCommon(input); + return new AgentAguiWireContext( + validated.threadId(), + input.getRunId(), + validated.userMessage().getId(), + validated.userMessage().getContent()); + } + + private ValidatedInput validate(RunAgentInput input, boolean draft) { + ValidatedInput validated = validateCommon(input); + validateForwardedProps(input, draft); + return validated; + } + + private ValidatedInput validateCommon(RunAgentInput input) { + if (input == null) { + throw new BusinessException("AG-UI 运行输入不能为空"); + } + requireIdentifier(input.getThreadId(), "threadId"); + requireIdentifier(input.getRunId(), "runId"); + if (input.getMessages() == null || input.getMessages().size() != 1) { + throw new BusinessException("AG-UI 入口每轮只接受一条用户消息"); + } + if (input.getTools() != null && !input.getTools().isEmpty()) { + throw new BusinessException("当前 Agent 入口不接受客户端工具"); + } + if (input.getContext() != null && !input.getContext().isEmpty()) { + throw new BusinessException("当前 Agent 入口不接受客户端上下文"); + } + if (input.getState() != null && !input.getState().isEmpty()) { + throw new BusinessException("当前 Agent 入口不接受客户端 state"); + } + AguiMessage userMessage = input.getMessages().get(0); + if (userMessage == null || userMessage.getContent() == null) { + throw new BusinessException("AG-UI 输入缺少本轮用户消息"); + } + if (!userMessage.isUserMessage()) { + throw new BusinessException("AG-UI 入口只接受用户消息"); + } + requireIdentifier(userMessage.getId(), "messageId"); + if (userMessage.hasToolCalls() + || (userMessage.getToolCallId() != null && !userMessage.getToolCallId().isBlank())) { + throw new BusinessException("AG-UI 用户消息不能携带工具调用"); + } + if (userMessage.getContent().length() > MAX_PROMPT_LENGTH) { + throw new BusinessException("Agent 输入内容过长"); + } + return new ValidatedInput(input.getThreadId(), userMessage); + } + + private void validateForwardedProps(RunAgentInput input, boolean draft) { + Map forwardedProps = input.getForwardedProps() == null + ? Map.of() + : input.getForwardedProps(); + rejectUnknownKeys(forwardedProps, Set.of("easyflow"), "forwardedProps"); + validateSerializedSize(forwardedProps); + Map easyflow = easyflowProps(input); + rejectUnknownKeys(easyflow, draft ? DRAFT_EASYFLOW_KEYS : FORMAL_EASYFLOW_KEYS, "easyflow"); + Map inputProps = nestedMap(easyflow, "input"); + rejectUnknownKeys(inputProps, INPUT_KEYS, "easyflow.input"); + if (draft) { + Map draftProps = nestedMap(easyflow, "draft"); + rejectUnknownKeys(draftProps, DRAFT_KEYS, "easyflow.draft"); + } + } + + private void validateSerializedSize(Map forwardedProps) { + try { + if (objectMapper.writeValueAsBytes(forwardedProps).length > MAX_FORWARDED_PROPS_BYTES) { + throw new BusinessException("AG-UI forwardedProps 内容过大"); + } + } catch (JsonProcessingException exception) { + throw new BusinessException("AG-UI forwardedProps 格式不合法"); + } + } + + private BigInteger parseFormalThreadId(String threadId) { + try { + BigInteger value = new BigInteger(threadId); + if (value.signum() <= 0) { + throw new NumberFormatException("non-positive"); + } + return value; + } catch (NumberFormatException exception) { + throw new BusinessException("正式 Agent threadId 必须是有效会话 ID"); + } + } + + @SuppressWarnings("unchecked") + private Map easyflowProps(RunAgentInput input) { + Object value = input.getForwardedProps() == null + ? null + : input.getForwardedProps().get("easyflow"); + if (value == null) { + return Map.of(); + } + if (!(value instanceof Map map)) { + throw new BusinessException("AG-UI easyflow 扩展格式不合法"); + } + return (Map) map; + } + + @SuppressWarnings("unchecked") + private Map nestedMap(Map source, String key) { + Object value = source.get(key); + if (value == null) { + return Map.of(); + } + if (!(value instanceof Map map)) { + throw new BusinessException("AG-UI " + key + " 扩展格式不合法"); + } + return (Map) map; + } + + private List stringList(Object value, String name, int maximumSize) { + if (value == null) { + return List.of(); + } + if (!(value instanceof List list) || list.size() > maximumSize) { + throw new BusinessException("AG-UI " + name + " 数量不合法"); + } + List result = new ArrayList<>(list.size()); + for (Object item : list) { + if (!(item instanceof String text) || text.isBlank() + || text.length() > MAX_IDENTIFIER_LENGTH) { + throw new BusinessException("AG-UI " + name + " 内容不合法"); + } + result.add(text); + } + return result; + } + + private List convertList(Object value, + Class targetType, + String name, + int maximumSize) { + if (value == null) { + return List.of(); + } + if (!(value instanceof List list) || list.size() > maximumSize) { + throw new BusinessException("AG-UI " + name + " 数量不合法"); + } + List result = new ArrayList<>(list.size()); + try { + for (Object item : list) { + result.add(objectMapper.convertValue(item, targetType)); + } + } catch (IllegalArgumentException exception) { + throw new BusinessException("AG-UI " + name + " 内容不合法"); + } + return result; + } + + /** + * 将客户端 Skill 引用转换为最小领域绑定,服务端快照与摘要字段不会进入 Runtime。 + * + * @param value 客户端 Skill 引用列表 + * @return 仅包含 Skill ID 与排序号的绑定 + */ + private List convertSkillBindings(Object value) { + if (value == null) { + return List.of(); + } + if (!(value instanceof List list) || list.size() > MAX_SKILL_BINDINGS) { + throw new BusinessException("AG-UI skillBindings 数量不合法"); + } + List result = new ArrayList<>(list.size()); + for (Object item : list) { + if (!(item instanceof Map raw)) { + throw new BusinessException("AG-UI skillBindings 内容不合法"); + } + Map binding = stringKeyMap(raw, "skillBindings"); + rejectUnknownKeys(binding, SKILL_BINDING_KEYS, "easyflow.draft.skillBindings"); + AgentSkillBinding converted = new AgentSkillBinding(); + converted.setSkillId(positiveBigInteger(binding.get("skillId"), "skillId")); + converted.setSortNo(optionalInteger(binding.get("sortNo"), "sortNo")); + result.add(converted); + } + return result; + } + + /** + * 将任意 Map 规范为字符串键 Map。 + * + * @param source 原始 Map + * @param name 字段名称 + * @return 字符串键 Map + */ + private Map stringKeyMap(Map source, String name) { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : source.entrySet()) { + if (!(entry.getKey() instanceof String key)) { + throw new BusinessException("AG-UI " + name + " 内容不合法"); + } + result.put(key, entry.getValue()); + } + return result; + } + + /** + * 解析正整数 ID。 + * + * @param value 原始值 + * @param name 字段名称 + * @return 正整数 ID + */ + private BigInteger positiveBigInteger(Object value, String name) { + if (value == null) { + throw new BusinessException("AG-UI " + name + " 不能为空"); + } + try { + BigInteger result = new BigInteger(String.valueOf(value)); + if (result.signum() <= 0) { + throw new NumberFormatException("non-positive"); + } + return result; + } catch (NumberFormatException exception) { + throw new BusinessException("AG-UI " + name + " 不合法"); + } + } + + /** + * 解析可选整数。 + * + * @param value 原始值 + * @param name 字段名称 + * @return 整数或 null + */ + private Integer optionalInteger(Object value, String name) { + if (value == null) { + return null; + } + try { + return Integer.valueOf(String.valueOf(value)); + } catch (NumberFormatException exception) { + throw new BusinessException("AG-UI " + name + " 不合法"); + } + } + + private void validateCapabilities(List capabilities) { + for (AgentChatCapability capability : capabilities) { + if (capability == null || capability.getType() == null + || capability.getType().isBlank() || capability.getType().length() > 64 + || capability.getResourceIds().size() > MAX_CAPABILITY_RESOURCE_IDS) { + throw new BusinessException("AG-UI capabilities 内容不合法"); + } + } + } + + private T convertRequired(Object value, Class targetType, String message) { + if (value == null) { + throw new BusinessException(message); + } + try { + return objectMapper.convertValue(value, targetType); + } catch (IllegalArgumentException exception) { + throw new BusinessException(message); + } + } + + private void requireIdentifier(String value, String name) { + if (value == null || value.isBlank() || value.length() > MAX_IDENTIFIER_LENGTH + || !value.matches("[A-Za-z0-9._:-]+")) { + throw new BusinessException("AG-UI " + name + " 不合法"); + } + } + + private void rejectUnknownKeys(Map source, Set allowedKeys, String name) { + if (!allowedKeys.containsAll(source.keySet())) { + LOG.debug("Reject unsupported AG-UI keys, namespace={}, keys={}", name, source.keySet()); + throw new BusinessException("AG-UI " + name + " 包含不支持的字段"); + } + } + + private record ValidatedInput(String threadId, AguiMessage userMessage) { + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/agui/AgentAguiWireContext.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/agui/AgentAguiWireContext.java new file mode 100644 index 00000000..ff8f033a --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/agui/AgentAguiWireContext.java @@ -0,0 +1,16 @@ +package tech.easyflow.agent.runtime.agui; + +/** + * 单次 AG-UI 连接的客户端 wire 标识。 + * + * @param threadId 客户端 thread ID + * @param runId 客户端 run ID,仅用于协议输出 + * @param userMessageId 客户端本轮用户消息 ID + * @param userMessageContent 客户端本轮用户消息正文 + */ +public record AgentAguiWireContext( + String threadId, + String runId, + String userMessageId, + String userMessageContent) { +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactChatSessionExtension.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactChatSessionExtension.java new file mode 100644 index 00000000..bf767209 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactChatSessionExtension.java @@ -0,0 +1,69 @@ +package tech.easyflow.agent.runtime.artifact; + +import org.springframework.stereotype.Component; +import tech.easyflow.agent.runtime.AgentRuntimeStateCleanupService; +import tech.easyflow.chatlog.domain.dto.ChatMessageRecord; +import tech.easyflow.chatlog.domain.dto.ChatSessionSummary; +import tech.easyflow.chatlog.service.ChatSessionExtension; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.math.BigInteger; +import java.util.List; +import java.util.Objects; + +/** + * Agent 会话的 Artifact 生命周期与历史安全投影扩展。 + */ +@Component +public class AgentArtifactChatSessionExtension implements ChatSessionExtension { + + private static final String AGENT_ASSISTANT_CODE = "AGENT"; + + private final AgentRuntimeStateCleanupService runtimeStateCleanupService; + private final AgentArtifactService artifactService; + + /** + * 创建 Agent 会话扩展。 + * + * @param runtimeStateCleanupService Agent 运行态清理服务 + * @param artifactService Artifact 服务 + */ + public AgentArtifactChatSessionExtension(AgentRuntimeStateCleanupService runtimeStateCleanupService, + AgentArtifactService artifactService) { + this.runtimeStateCleanupService = runtimeStateCleanupService; + this.artifactService = artifactService; + } + + @Override + public boolean supports(ChatSessionSummary summary) { + return summary != null && AGENT_ASSISTANT_CODE.equals(summary.getAssistantCode()); + } + + @Override + public void beforeDelete(ChatSessionSummary summary, BigInteger userId, BigInteger operatorId) { + requireIdentity(summary, userId); + runtimeStateCleanupService.clearChatSession(summary.getId(), userId); + } + + @Override + public void afterDelete(ChatSessionSummary summary, BigInteger userId, BigInteger operatorId) { + requireIdentity(summary, userId); + artifactService.markSessionDeletePending( + summary.getTenantId(), userId, summary.getAssistantId(), summary.getId()); + } + + @Override + public void projectMessages(ChatSessionSummary summary, List records) { + requireIdentity(summary, summary.getUserId()); + artifactService.projectHistoryArtifacts(records, + summary.getTenantId(), summary.getUserId(), summary.getAssistantId(), summary.getId()); + } + + private void requireIdentity(ChatSessionSummary summary, BigInteger userId) { + if (summary == null || summary.getId() == null || summary.getTenantId() == null + || summary.getAssistantId() == null || summary.getUserId() == null + || !Objects.equals(summary.getUserId(), userId)) { + throw new BusinessException("Agent 会话归属不完整"); + } + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactCleanupScheduler.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactCleanupScheduler.java new file mode 100644 index 00000000..1bb55f98 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactCleanupScheduler.java @@ -0,0 +1,136 @@ +package tech.easyflow.agent.runtime.artifact; + +import com.mybatisflex.core.query.QueryWrapper; +import com.mybatisflex.core.tenant.TenantManager; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import tech.easyflow.agent.entity.AgentArtifact; +import tech.easyflow.agent.mapper.AgentArtifactMapper; + +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 有界领取并补偿清理过期、待删除和删除失败 Artifact。 + */ +@Component +public class AgentArtifactCleanupScheduler { + + private static final int BATCH_SIZE = 100; + private static final String PUBLISH_TIMEOUT_ERROR = "ARTIFACT_PUBLISH_TIMEOUT"; + private static final String SESSION_UNAVAILABLE_ERROR = "ARTIFACT_SESSION_UNAVAILABLE"; + + private final AgentArtifactMapper mapper; + private final AgentArtifactService artifactService; + + /** + * 创建清理任务。 + * + * @param mapper Artifact Mapper + * @param artifactService Artifact 服务 + */ + public AgentArtifactCleanupScheduler(AgentArtifactMapper mapper, + AgentArtifactService artifactService) { + this.mapper = mapper; + this.artifactService = artifactService; + } + + /** + * 周期性清理,单次最多处理一百条,避免全表扫描和长时间占用调度线程。 + */ + @Scheduled(fixedDelayString = "${easyflow.agent.workspace.cleanup-interval:30m}") + public void cleanup() { + TenantManager.withoutTenantCondition(() -> { + cleanupWithoutTenantCondition(); + return null; + }); + } + + /** + * 在关闭 ORM 当前租户条件的作用域中执行全租户清理。 + */ + private void cleanupWithoutTenantCondition() { + Date now = new Date(); + List expired = mapper.selectListByQuery(QueryWrapper.create() + .eq(AgentArtifact::getChatMode, AgentArtifactService.MODE_DRAFT) + .eq(AgentArtifact::getStatus, AgentArtifactStatus.AVAILABLE.name()) + .le(AgentArtifact::getExpiresAt, now) + .orderBy(AgentArtifact::getId, true) + .limit(BATCH_SIZE)); + for (AgentArtifact artifact : expired) { + AgentArtifact update = new AgentArtifact(); + update.setStatus(AgentArtifactStatus.DELETE_PENDING.name()); + update.setNextRetryAt(now); + update.setModified(now); + mapper.updateByQuery(update, QueryWrapper.create() + .eq(AgentArtifact::getId, artifact.getId()) + .eq(AgentArtifact::getStatus, AgentArtifactStatus.AVAILABLE.name())); + } + + Map candidates = new LinkedHashMap<>(); + List abandonedPublishing = mapper.selectListByQuery(QueryWrapper.create() + .eq(AgentArtifact::getStatus, AgentArtifactStatus.PUBLISHING.name()) + .le(AgentArtifact::getNextRetryAt, now) + .orderBy(AgentArtifact::getId, true) + .limit(BATCH_SIZE)); + for (AgentArtifact artifact : abandonedPublishing) { + AgentArtifact update = new AgentArtifact(); + update.setStatus(AgentArtifactStatus.DELETE_PENDING.name()); + update.setNextRetryAt(now); + update.setLastErrorCode(PUBLISH_TIMEOUT_ERROR); + update.setModified(now); + int changed = mapper.updateByQuery(update, QueryWrapper.create() + .eq(AgentArtifact::getId, artifact.getId()) + .eq(AgentArtifact::getStatus, AgentArtifactStatus.PUBLISHING.name()) + .le(AgentArtifact::getNextRetryAt, now)); + if (changed == 1) { + artifact.setStatus(AgentArtifactStatus.DELETE_PENDING.name()); + artifact.setNextRetryAt(now); + artifact.setLastErrorCode(PUBLISH_TIMEOUT_ERROR); + candidates.put(artifact.getId(), artifact); + } + } + if (candidates.size() < BATCH_SIZE) { + List orphans = mapper.selectOrphanedFormalArtifacts(BATCH_SIZE - candidates.size()); + for (AgentArtifact artifact : orphans) { + AgentArtifact update = new AgentArtifact(); + update.setStatus(AgentArtifactStatus.DELETE_PENDING.name()); + update.setNextRetryAt(now); + update.setLastErrorCode(SESSION_UNAVAILABLE_ERROR); + update.setModified(now); + int changed = mapper.updateByQuery(update, QueryWrapper.create() + .eq(AgentArtifact::getId, artifact.getId()) + .eq(AgentArtifact::getStatus, artifact.getStatus())); + if (changed == 1) { + artifact.setStatus(AgentArtifactStatus.DELETE_PENDING.name()); + artifact.setNextRetryAt(now); + artifact.setLastErrorCode(SESSION_UNAVAILABLE_ERROR); + candidates.put(artifact.getId(), artifact); + } + } + } + if (candidates.size() < BATCH_SIZE) { + add(candidates, mapper.selectListByQuery(QueryWrapper.create() + .eq(AgentArtifact::getStatus, AgentArtifactStatus.DELETE_PENDING.name()) + .orderBy(AgentArtifact::getId, true) + .limit(BATCH_SIZE - candidates.size()))); + } + if (candidates.size() < BATCH_SIZE) { + add(candidates, mapper.selectListByQuery(QueryWrapper.create() + .eq(AgentArtifact::getStatus, AgentArtifactStatus.DELETE_FAILED.name()) + .le(AgentArtifact::getNextRetryAt, now) + .orderBy(AgentArtifact::getId, true) + .limit(BATCH_SIZE - candidates.size()))); + } + new ArrayList<>(candidates.values()).forEach(artifactService::deleteObject); + } + + private void add(Map target, List artifacts) { + for (AgentArtifact artifact : artifacts) { + target.putIfAbsent(artifact.getId(), artifact); + } + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactObjectStorage.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactObjectStorage.java new file mode 100644 index 00000000..28834f31 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactObjectStorage.java @@ -0,0 +1,173 @@ +package tech.easyflow.agent.runtime.artifact; + +import io.minio.GetObjectArgs; +import io.minio.PutObjectArgs; +import io.minio.RemoveObjectArgs; +import io.minio.StatObjectArgs; +import io.minio.StatObjectResponse; +import io.minio.errors.ErrorResponseException; +import org.dromara.x.file.storage.core.FileStorageService; +import org.dromara.x.file.storage.core.platform.MinioFileStorage; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.io.InputStream; + +/** + * 复用 x-file-storage 中固定私有平台的 Agent Artifact 对象存储适配器。 + */ +@Component +public class AgentArtifactObjectStorage { + + /** 固定私有产物平台,不允许配置回退。 */ + public static final String PLATFORM = "minio-agent-artifacts"; + + private final FileStorageService fileStorageService; + + /** + * 创建对象存储适配器。 + * + * @param fileStorageService x-file-storage 聚合服务 + */ + public AgentArtifactObjectStorage(FileStorageService fileStorageService) { + this.fileStorageService = fileStorageService; + } + + /** + * 应用就绪时校验固定私有平台,缺失或配置公开域名时 fail-fast。 + */ + @EventListener(ApplicationReadyEvent.class) + public void validatePlatform() { + MinioFileStorage storage = storage(); + if (StringUtils.hasText(storage.getDomain())) { + throw new IllegalStateException("Agent Artifact 存储必须使用无公开域名的私有 MinIO 平台"); + } + try { + boolean exists = storage.getClient().bucketExists( + io.minio.BucketExistsArgs.builder().bucket(storage.getBucketName()).build()); + if (!exists) { + throw new IllegalStateException("Agent Artifact 私有 MinIO bucket 不存在"); + } + } catch (IllegalStateException error) { + throw error; + } catch (Exception error) { + throw new IllegalStateException("校验 Agent Artifact 私有 MinIO bucket 失败", error); + } + } + + /** + * 流式写入对象。 + * + * @param objectKey 业务对象键 + * @param input 输入流,由调用方关闭 + * @param size 已校验字节数 + * @param mimeType MIME 类型 + * @return 对象 ETag + */ + public String put(String objectKey, InputStream input, long size, String mimeType) { + try { + MinioFileStorage storage = storage(); + return storage.getClient().putObject(PutObjectArgs.builder() + .bucket(storage.getBucketName()) + .object(fullKey(storage, objectKey)) + .stream(input, size, -1) + .contentType(mimeType) + .build()).etag(); + } catch (Exception error) { + throw new AgentArtifactOperationException( + "ARTIFACT_STORAGE_UNAVAILABLE", "产物存储暂时不可用", true, error); + } + } + + /** + * 查询私有对象实际元数据。 + * + * @param objectKey 业务对象键 + * @return 对象实际大小与 ETag + */ + public StoredObjectMetadata stat(String objectKey) { + try { + MinioFileStorage storage = storage(); + StatObjectResponse response = storage.getClient().statObject(StatObjectArgs.builder() + .bucket(storage.getBucketName()) + .object(fullKey(storage, objectKey)) + .build()); + return new StoredObjectMetadata(response.size(), response.etag()); + } catch (Exception error) { + throw new AgentArtifactOperationException( + "ARTIFACT_STORAGE_UNAVAILABLE", "读取产物对象元数据失败", true, error); + } + } + + /** + * 打开私有对象读取流。 + * + * @param objectKey 业务对象键 + * @return MinIO 输入流,由调用方关闭 + * @throws IOException 对象读取失败 + */ + public InputStream open(String objectKey) throws IOException { + try { + MinioFileStorage storage = storage(); + return storage.getClient().getObject(GetObjectArgs.builder() + .bucket(storage.getBucketName()) + .object(fullKey(storage, objectKey)) + .build()); + } catch (Exception error) { + throw new IOException("读取 Agent Artifact 对象失败", error); + } + } + + /** + * 幂等删除一个私有对象。 + * + * @param objectKey 业务对象键 + */ + public void delete(String objectKey) { + try { + MinioFileStorage storage = storage(); + storage.getClient().removeObject(RemoveObjectArgs.builder() + .bucket(storage.getBucketName()) + .object(fullKey(storage, objectKey)) + .build()); + } catch (ErrorResponseException error) { + String code = error.errorResponse() == null ? null : error.errorResponse().code(); + if ("NoSuchKey".equals(code) || "NoSuchObject".equals(code)) { + return; + } + throw new AgentArtifactOperationException( + "ARTIFACT_STORAGE_UNAVAILABLE", "产物对象删除失败", true, error); + } catch (Exception error) { + throw new AgentArtifactOperationException( + "ARTIFACT_STORAGE_UNAVAILABLE", "产物对象删除失败", true, error); + } + } + + private MinioFileStorage storage() { + MinioFileStorage storage = fileStorageService.getFileStorage(PLATFORM); + if (storage == null) { + throw new IllegalStateException("缺少固定 x-file-storage 平台: " + PLATFORM); + } + return storage; + } + + private String fullKey(MinioFileStorage storage, String objectKey) { + String basePath = storage.getBasePath(); + if (!StringUtils.hasText(basePath)) { + return objectKey; + } + return basePath.replaceAll("/+$", "") + "/" + objectKey.replaceAll("^/+", ""); + } + + /** + * 私有对象存储返回的可信元数据。 + * + * @param size 实际对象字节数 + * @param etag 对象 ETag + */ + public record StoredObjectMetadata(long size, String etag) { + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactOperationException.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactOperationException.java new file mode 100644 index 00000000..613c21ce --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactOperationException.java @@ -0,0 +1,42 @@ +package tech.easyflow.agent.runtime.artifact; + +/** + * Artifact Tool 可安全返回给模型的稳定业务异常。 + */ +public class AgentArtifactOperationException extends RuntimeException { + + private final String code; + private final boolean retryable; + + /** + * 创建 Artifact 业务异常。 + * + * @param code 稳定错误码 + * @param message 脱敏错误消息 + * @param retryable 是否可重试 + */ + public AgentArtifactOperationException(String code, String message, boolean retryable) { + super(message); + this.code = code; + this.retryable = retryable; + } + + /** + * 创建保留内部原因的 Artifact 业务异常。 + * + * @param code 稳定错误码 + * @param message 脱敏错误消息 + * @param retryable 是否可重试 + * @param cause 内部异常原因,仅用于服务端日志 + */ + public AgentArtifactOperationException(String code, String message, boolean retryable, Throwable cause) { + super(message, cause); + this.code = code; + this.retryable = retryable; + } + + /** @return 稳定错误码 */ + public String getCode() { return code; } + /** @return 是否可重试 */ + public boolean isRetryable() { return retryable; } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactService.java new file mode 100644 index 00000000..99088015 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactService.java @@ -0,0 +1,996 @@ +package tech.easyflow.agent.runtime.artifact; + +import com.easyagents.agent.runtime.tool.AgentToolContext; +import com.mybatisflex.core.query.QueryWrapper; +import com.mybatisflex.core.update.UpdateChain; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; +import tech.easyflow.agent.config.AgentWorkspaceProperties; +import tech.easyflow.agent.entity.AgentArtifact; +import tech.easyflow.agent.mapper.AgentArtifactMapper; +import tech.easyflow.agent.runtime.workspace.AgentWorkspaceResolver; +import tech.easyflow.chatlog.domain.dto.ChatMessageRecord; +import tech.easyflow.chatlog.domain.dto.ChatSessionSummary; +import tech.easyflow.chatlog.service.ChatSessionQueryService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.core.runtime.ChatRuntimeExtKeys; + +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.channels.SeekableByteChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.charset.StandardCharsets; +import java.nio.file.StandardOpenOption; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.Date; +import java.util.Enumeration; +import java.util.HexFormat; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * Agent Artifact 发布、归属校验和生命周期服务。 + */ +@Service +public class AgentArtifactService { + + /** 草稿产物模式。 */ + public static final String MODE_DRAFT = "DRAFT"; + /** 正式聊天产物模式。 */ + public static final String MODE_FORMAL = "FORMAL"; + + private static final Logger LOG = LoggerFactory.getLogger(AgentArtifactService.class); + private static final int MAX_FILE_NAME_LENGTH = 255; + private static final int MAX_ZIP_ENTRIES = 256; + private static final int MAX_ZIP_ENTRY_NAME_LENGTH = 1024; + private static final long MAX_ZIP_CENTRAL_DIRECTORY_SIZE = 1024L * 1024; + private static final long MAX_ZIP_DECLARED_SIZE = 512L * 1024 * 1024; + private static final long MAX_ZIP_COMPRESSION_RATIO = 200L; + private static final int ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014B50; + private static final int ZIP_CENTRAL_DIRECTORY_HEADER_SIZE = 46; + private static final int ZIP_EOCD_SIGNATURE = 0x06054B50; + private static final int ZIP_EOCD_MIN_SIZE = 22; + private static final int ZIP_EOCD_MAX_SIZE = 65_557; + private static final int ZIP_UINT16_MAX = 0xFFFF; + private static final long ZIP_UINT32_MAX = 0xFFFF_FFFFL; + private static final long PUBLISH_RECOVERY_TIMEOUT_SECONDS = 10 * 60L; + private static final String UNAVAILABLE_STATUS = "UNAVAILABLE"; + + private final AgentArtifactMapper mapper; + private final AgentArtifactObjectStorage objectStorage; + private final AgentWorkspaceResolver workspaceResolver; + private final AgentWorkspaceProperties workspaceProperties; + private ChatSessionQueryService chatSessionQueryService; + + /** + * 创建 Artifact 服务。 + * + * @param mapper 状态账本 Mapper + * @param objectStorage 私有对象存储 + * @param workspaceResolver 工作区解析器 + * @param workspaceProperties 工作区限制 + */ + public AgentArtifactService(AgentArtifactMapper mapper, + AgentArtifactObjectStorage objectStorage, + AgentWorkspaceResolver workspaceResolver, + AgentWorkspaceProperties workspaceProperties) { + this.mapper = mapper; + this.objectStorage = objectStorage; + this.workspaceResolver = workspaceResolver; + this.workspaceProperties = workspaceProperties; + } + + /** + * 延迟注入会话查询服务,避免会话投影扩展初始化形成依赖环。 + * + * @param chatSessionQueryService 会话查询服务 + */ + @Autowired + @Lazy + public void setChatSessionQueryService(ChatSessionQueryService chatSessionQueryService) { + this.chatSessionQueryService = chatSessionQueryService; + } + + /** + * 将当前会话工作区中的普通文件发布为私有 Artifact。 + * + * @param workspace 当前会话绝对工作区 + * @param relativePath 工作区相对文件路径 + * @param requestedFileName 可选展示文件名 + * @param mode DRAFT 或 FORMAL + * @param context 可信 Tool 调用上下文 + * @return 安全产物视图 + */ + public AgentArtifactView publish(Path workspace, + String relativePath, + String requestedFileName, + String mode, + AgentToolContext context) { + ToolIdentity identity = requireIdentity(context, mode); + Path file = workspaceResolver.resolveExistingFile(workspace, relativePath); + long size = fileSize(file); + if (size > workspaceProperties.getMaxSingleFileSize().toBytes()) { + throw new AgentArtifactOperationException( + "WORKSPACE_QUOTA_EXCEEDED", "文件超过允许发布的单文件大小", false); + } + String fileName = safeFileName(requestedFileName, file.getFileName().toString()); + String mimeType = mimeType(file); + String artifactId = opaqueId(); + String objectKey = "artifacts/%s/%s/%s/content%s".formatted( + identity.tenantId(), identity.agentId(), artifactId, safeExtension(fileName)); + AgentArtifact artifact = publishingRecord( + artifactId, fileName, mimeType, size, objectKey, identity, context); + mapper.insert(artifact); + + boolean uploadAttempted = false; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + String etag; + try (InputStream raw = Files.newInputStream(file); + DigestInputStream input = new DigestInputStream(raw, digest)) { + uploadAttempted = true; + etag = objectStorage.put(objectKey, input, size, mimeType); + } + String sha256 = HexFormat.of().formatHex(digest.digest()); + verifyStoredObject(objectKey, size, sha256); + boolean changed = UpdateChain.of(new AgentArtifact(), mapper) + .set(AgentArtifact::getSha256, sha256) + .set(AgentArtifact::getStorageEtag, etag) + .set(AgentArtifact::getStatus, AgentArtifactStatus.AVAILABLE.name()) + .set(AgentArtifact::getNextRetryAt, null) + .set(AgentArtifact::getLastErrorCode, null) + .set(AgentArtifact::getModified, new Date()) + .set(AgentArtifact::getModifiedBy, identity.userId()) + .eq(AgentArtifact::getId, artifact.getId()) + .eq(AgentArtifact::getStatus, AgentArtifactStatus.PUBLISHING.name()) + .update(); + if (!changed) { + throw new AgentArtifactOperationException( + "ARTIFACT_PUBLISH_FAILED", "提交产物发布状态失败", true); + } + artifact.setSha256(sha256); + artifact.setStorageEtag(etag); + artifact.setStatus(AgentArtifactStatus.AVAILABLE.name()); + workspaceResolver.touch(workspace); + return toView(artifact); + } catch (AgentArtifactOperationException error) { + compensatePublishFailure(artifact, uploadAttempted, error.getCode(), error); + throw error; + } catch (NoSuchAlgorithmException | IOException error) { + compensatePublishFailure(artifact, uploadAttempted, "ARTIFACT_PUBLISH_FAILED", error); + throw new AgentArtifactOperationException( + "ARTIFACT_PUBLISH_FAILED", "读取并发布工作区文件失败", true); + } catch (RuntimeException error) { + compensatePublishFailure(artifact, uploadAttempted, "ARTIFACT_PUBLISH_FAILED", error); + throw new AgentArtifactOperationException( + "ARTIFACT_PUBLISH_FAILED", "产物发布失败", true); + } + } + + /** + * 校验当前登录用户并返回可下载账本。 + * + * @param artifactId 对外 Artifact ID + * @param account 当前账号 + * @param expectedAgentId 当前页面 Agent ID + * @param expectedMode 当前页面聊天模式 + * @param expectedSessionId 当前页面正式会话 ID + * @param expectedRuntimeSessionId 当前页面草稿 Runtime 会话 ID + * @return 可下载记录 + */ + public AgentArtifact requireDownload(String artifactId, + LoginAccount account, + BigInteger expectedAgentId, + String expectedMode, + BigInteger expectedSessionId, + String expectedRuntimeSessionId) { + if (!StringUtils.hasText(artifactId) || account == null + || account.getId() == null || account.getTenantId() == null + || expectedAgentId == null || expectedAgentId.signum() <= 0 + || !StringUtils.hasText(expectedMode)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "产物不存在"); + } + AgentArtifact artifact = mapper.selectOneByQuery(QueryWrapper.create() + .eq(AgentArtifact::getTenantId, account.getTenantId()) + .eq(AgentArtifact::getArtifactId, artifactId)); + if (artifact == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "产物不存在"); + } + if (!account.getId().equals(artifact.getOwnerUserId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权下载该产物"); + } + validateDownloadScope( + artifact, expectedAgentId, expectedMode, expectedSessionId, expectedRuntimeSessionId); + if (artifact.getExpiresAt() != null && artifact.getExpiresAt().before(new Date())) { + markDeletePending(artifact); + throw new ResponseStatusException(HttpStatus.GONE, "产物已过期"); + } + if (!AgentArtifactStatus.AVAILABLE.name().equals(artifact.getStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "产物当前不可下载"); + } + return artifact; + } + + /** + * 打开已鉴权账本对应的对象流。 + * + * @param artifact 已鉴权记录 + * @return 对象输入流 + * @throws IOException 对象读取失败 + */ + public InputStream openDownload(AgentArtifact artifact) throws IOException { + if (artifact == null || !AgentArtifactObjectStorage.PLATFORM.equals(artifact.getStoragePlatform())) { + throw new IOException("Artifact 存储平台不匹配"); + } + return objectStorage.open(artifact.getObjectKey()); + } + + /** + * 用当前 Artifact 账本批量覆盖会话历史中的安全产物投影。 + * + * @param messages 同一正式会话的一页或全部消息 + * @param tenantId 当前登录租户 ID + * @param ownerUserId 当前登录用户 ID + * @param chatSessionId 已鉴权的聊天会话 ID + * @throws IllegalArgumentException 可信归属不完整时抛出 + */ + public void projectHistoryArtifacts(List messages, + BigInteger tenantId, + BigInteger ownerUserId, + BigInteger agentId, + BigInteger chatSessionId) { + requirePositive(tenantId, "tenantId"); + requirePositive(ownerUserId, "ownerUserId"); + requirePositive(agentId, "agentId"); + requirePositive(chatSessionId, "chatSessionId"); + if (messages == null || messages.isEmpty()) { + return; + } + Set roundIds = collectHistoryRoundIds(messages, chatSessionId); + if (roundIds.isEmpty()) { + return; + } + List artifacts = mapper.selectListByQuery(QueryWrapper.create() + .eq(AgentArtifact::getTenantId, tenantId) + .eq(AgentArtifact::getOwnerUserId, ownerUserId) + .eq(AgentArtifact::getAgentId, agentId) + .eq(AgentArtifact::getChatMode, MODE_FORMAL) + .eq(AgentArtifact::getChatSessionId, chatSessionId) + .in(AgentArtifact::getRoundId, roundIds) + .orderBy(AgentArtifact::getId, true)); + Map ledgerById = new LinkedHashMap<>(); + Map> ledgerByRound = new LinkedHashMap<>(); + for (AgentArtifact artifact : artifacts) { + if (artifact != null && StringUtils.hasText(artifact.getArtifactId())) { + ledgerById.put(artifact.getArtifactId(), artifact); + ledgerByRound.computeIfAbsent(artifact.getRoundId(), ignored -> new ArrayList<>()).add(artifact); + } + } + for (ChatMessageRecord message : messages) { + projectMessageArtifacts(message, chatSessionId, ledgerById, ledgerByRound); + } + } + + /** + * 将正式聊天会话的全部产物标记为待删除。 + * + * @param chatSessionId 聊天会话 ID + */ + public void markSessionDeletePending(BigInteger tenantId, + BigInteger ownerUserId, + BigInteger agentId, + BigInteger chatSessionId) { + requirePositive(tenantId, "tenantId"); + requirePositive(ownerUserId, "ownerUserId"); + requirePositive(agentId, "agentId"); + requirePositive(chatSessionId, "chatSessionId"); + AgentArtifact update = new AgentArtifact(); + update.setStatus(AgentArtifactStatus.DELETE_PENDING.name()); + update.setNextRetryAt(new Date()); + update.setModified(new Date()); + mapper.updateByQuery(update, QueryWrapper.create() + .eq(AgentArtifact::getTenantId, tenantId) + .eq(AgentArtifact::getOwnerUserId, ownerUserId) + .eq(AgentArtifact::getAgentId, agentId) + .eq(AgentArtifact::getChatMode, MODE_FORMAL) + .eq(AgentArtifact::getChatSessionId, chatSessionId) + .in(AgentArtifact::getStatus, + AgentArtifactStatus.PUBLISHING.name(), + AgentArtifactStatus.AVAILABLE.name(), + AgentArtifactStatus.FAILED.name(), + AgentArtifactStatus.DELETE_FAILED.name())); + } + + /** + * 将指定草稿 Runtime 会话的产物标记为待删除。 + * + * @param runtimeSessionId 草稿会话 ID + * @param tenantId 租户 ID + * @param ownerUserId 所有者用户 ID + */ + public void markDraftSessionDeletePending(String runtimeSessionId, + BigInteger tenantId, + BigInteger ownerUserId) { + if (!StringUtils.hasText(runtimeSessionId) || tenantId == null || ownerUserId == null) { + return; + } + AgentArtifact update = new AgentArtifact(); + update.setStatus(AgentArtifactStatus.DELETE_PENDING.name()); + update.setNextRetryAt(new Date()); + update.setModified(new Date()); + mapper.updateByQuery(update, QueryWrapper.create() + .eq(AgentArtifact::getTenantId, tenantId) + .eq(AgentArtifact::getChatMode, MODE_DRAFT) + .eq(AgentArtifact::getRuntimeSessionId, runtimeSessionId) + .eq(AgentArtifact::getOwnerUserId, ownerUserId) + .in(AgentArtifact::getStatus, + AgentArtifactStatus.PUBLISHING.name(), + AgentArtifactStatus.AVAILABLE.name(), + AgentArtifactStatus.FAILED.name(), + AgentArtifactStatus.DELETE_FAILED.name())); + } + + /** + * 删除一条待清理对象并更新终态。 + * + * @param artifact 待清理记录 + */ + public void deleteObject(AgentArtifact artifact) { + if (artifact == null) { + return; + } + try { + objectStorage.delete(artifact.getObjectKey()); + AgentArtifact update = new AgentArtifact(); + update.setStatus(AgentArtifactStatus.DELETED.name()); + update.setNextRetryAt(null); + update.setLastErrorCode(null); + update.setModified(new Date()); + mapper.updateByQuery(update, QueryWrapper.create().eq(AgentArtifact::getId, artifact.getId())); + } catch (RuntimeException error) { + int retries = artifact.getRetryCount() == null ? 1 : artifact.getRetryCount() + 1; + AgentArtifact update = new AgentArtifact(); + update.setStatus(AgentArtifactStatus.DELETE_FAILED.name()); + update.setRetryCount(retries); + update.setNextRetryAt(Date.from(Instant.now().plusSeconds(Math.min(3_600L, 60L << Math.min(retries, 5))))); + update.setLastErrorCode("ARTIFACT_STORAGE_UNAVAILABLE"); + update.setModified(new Date()); + mapper.updateByQuery(update, QueryWrapper.create().eq(AgentArtifact::getId, artifact.getId())); + LOG.error("Agent Artifact object cleanup failed, artifactId={}", artifact.getArtifactId(), error); + } + } + + /** + * 转换数据库记录为安全视图。 + * + * @param artifact 账本记录 + * @return 安全视图 + */ + public AgentArtifactView toView(AgentArtifact artifact) { + String downloadUrl = AgentArtifactStatus.AVAILABLE.name().equals(artifact.getStatus()) + ? downloadUrl(artifact) + : null; + return new AgentArtifactView( + 1, artifact.getArtifactId(), artifact.getFileName(), artifact.getMimeType(), + artifact.getSizeBytes() == null ? 0L : artifact.getSizeBytes(), artifact.getSha256(), + downloadUrl, artifact.getStatus()); + } + + private ToolIdentity requireIdentity(AgentToolContext context, String mode) { + if (context == null || context.getRuntimeContext() == null) { + throw new AgentArtifactOperationException("ARTIFACT_ACCESS_DENIED", "产物调用上下文缺失", false); + } + try { + BigInteger tenantId = positiveId(context.getRuntimeContext().getTenantId()); + BigInteger userId = positiveId(context.getRuntimeContext().getUserId()); + BigInteger agentId = positiveId(context.getAgentId()); + String sessionId = context.getSessionId(); + if (!StringUtils.hasText(sessionId) || !StringUtils.hasText(context.getRequestId()) + || !StringUtils.hasText(context.getToolCallId())) { + throw new IllegalArgumentException(); + } + String safeMode = MODE_DRAFT.equals(mode) ? MODE_DRAFT : MODE_FORMAL; + BigInteger chatSessionId = MODE_FORMAL.equals(safeMode) ? positiveId(sessionId) : null; + BigInteger roundId = MODE_FORMAL.equals(safeMode) + ? positiveId(String.valueOf(context.getRuntimeContext().getMetadata() + .get(ChatRuntimeExtKeys.CURRENT_ROUND_ID))) : null; + Integer variantIndex = MODE_FORMAL.equals(safeMode) + ? positiveInteger(context.getRuntimeContext().getMetadata() + .get(ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX)) : null; + return new ToolIdentity( + tenantId, userId, agentId, sessionId, chatSessionId, roundId, variantIndex, safeMode); + } catch (RuntimeException error) { + throw new AgentArtifactOperationException("ARTIFACT_ACCESS_DENIED", "产物调用归属不完整", false); + } + } + + private AgentArtifact publishingRecord(String artifactId, + String fileName, + String mimeType, + long size, + String objectKey, + ToolIdentity identity, + AgentToolContext context) { + Date now = new Date(); + AgentArtifact artifact = new AgentArtifact(); + artifact.setArtifactId(artifactId); + artifact.setTenantId(identity.tenantId()); + artifact.setAgentId(identity.agentId()); + artifact.setOwnerUserId(identity.userId()); + artifact.setChatMode(identity.mode()); + artifact.setChatSessionId(identity.chatSessionId()); + artifact.setRoundId(identity.roundId()); + artifact.setVariantIndex(identity.variantIndex()); + artifact.setRuntimeSessionId(identity.runtimeSessionId()); + artifact.setRequestId(context.getRequestId()); + artifact.setToolCallId(context.getToolCallId()); + artifact.setFileName(fileName); + artifact.setMimeType(mimeType); + artifact.setSizeBytes(size); + artifact.setStoragePlatform(AgentArtifactObjectStorage.PLATFORM); + artifact.setObjectKey(objectKey); + artifact.setStatus(AgentArtifactStatus.PUBLISHING.name()); + artifact.setNextRetryAt(Date.from(Instant.now().plusSeconds(PUBLISH_RECOVERY_TIMEOUT_SECONDS))); + artifact.setExpiresAt(MODE_DRAFT.equals(identity.mode()) + ? Date.from(Instant.now().plusSeconds(24 * 60 * 60L)) : null); + artifact.setRetryCount(0); + artifact.setCreated(now); + artifact.setCreatedBy(identity.userId()); + artifact.setModified(now); + artifact.setModifiedBy(identity.userId()); + artifact.setIsDeleted(0); + return artifact; + } + + private void validateDownloadScope(AgentArtifact artifact, + BigInteger expectedAgentId, + String expectedMode, + BigInteger expectedSessionId, + String expectedRuntimeSessionId) { + if (!Objects.equals(artifact.getAgentId(), expectedAgentId) + || !artifact.getChatMode().equalsIgnoreCase(expectedMode)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "产物不属于当前 Agent 会话"); + } + if (MODE_FORMAL.equals(artifact.getChatMode())) { + if (expectedSessionId == null || expectedSessionId.signum() <= 0 + || StringUtils.hasText(expectedRuntimeSessionId)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "产物不属于当前正式会话"); + } + if (!Objects.equals(artifact.getChatSessionId(), expectedSessionId) + || chatSessionQueryService == null || artifact.getChatSessionId() == null) { + throw new ResponseStatusException(HttpStatus.GONE, "产物所属会话已失效"); + } + ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(artifact.getChatSessionId()); + boolean valid = summary != null + && !Integer.valueOf(1).equals(summary.getIsDeleted()) + && "AGENT".equals(summary.getAssistantCode()) + && Objects.equals(summary.getTenantId(), artifact.getTenantId()) + && Objects.equals(summary.getUserId(), artifact.getOwnerUserId()) + && Objects.equals(summary.getAssistantId(), artifact.getAgentId()); + if (!valid) { + throw new ResponseStatusException(HttpStatus.GONE, "产物所属会话已失效"); + } + return; + } + boolean validDraft = MODE_DRAFT.equals(artifact.getChatMode()) + && expectedSessionId == null + && artifact.getAgentId() != null && artifact.getAgentId().signum() > 0 + && StringUtils.hasText(artifact.getRuntimeSessionId()) + && Objects.equals(artifact.getRuntimeSessionId(), expectedRuntimeSessionId) + && artifact.getChatSessionId() == null; + if (!validDraft) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "产物归属记录不完整"); + } + } + + private String downloadUrl(AgentArtifact artifact) { + String base = "/api/v1/agent/artifacts/%s/content?agentId=%s&mode=%s".formatted( + artifact.getArtifactId(), artifact.getAgentId(), artifact.getChatMode()); + if (MODE_FORMAL.equals(artifact.getChatMode())) { + return base + "&sessionId=" + artifact.getChatSessionId(); + } + return base + "&runtimeSessionId=" + artifact.getRuntimeSessionId(); + } + + private Set collectHistoryRoundIds(List messages, + BigInteger chatSessionId) { + Set roundIds = new LinkedHashSet<>(); + for (ChatMessageRecord message : messages) { + if (message != null && Objects.equals(message.getSessionId(), chatSessionId) + && message.getRoundId() != null) { + roundIds.add(message.getRoundId()); + } + } + return roundIds; + } + + private void projectMessageArtifacts(ChatMessageRecord message, + BigInteger chatSessionId, + Map ledgerById, + Map> ledgerByRound) { + if (message == null || !Objects.equals(message.getSessionId(), chatSessionId)) { + return; + } + Map originalPayload = message.getContentPayload(); + Object rawArtifacts = originalPayload == null ? null : originalPayload.get("artifacts"); + List list = rawArtifacts instanceof List values ? values : List.of(); + boolean assistantMessage = "assistant".equalsIgnoreCase(message.getSenderRole()); + List variantArtifacts = ledgerByRound.getOrDefault(message.getRoundId(), List.of()) + .stream() + .filter(ledger -> Objects.equals(ledger.getVariantIndex(), message.getVariantIndex())) + .toList(); + if (list.isEmpty() && (!assistantMessage || variantArtifacts.isEmpty())) { + return; + } + List> projected = new ArrayList<>(list.size()); + Set projectedIds = new LinkedHashSet<>(); + for (Object item : list) { + if (!(item instanceof Map oldView)) { + continue; + } + String artifactId = safeString(oldView.get("artifactId")); + AgentArtifact ledger = ledgerById.get(artifactId); + boolean sameRound = ledger != null + && Objects.equals(ledger.getRoundId(), message.getRoundId()) + && Objects.equals(ledger.getVariantIndex(), message.getVariantIndex()) + && Objects.equals(message.getSessionId(), chatSessionId); + projected.add(sameRound ? toView(ledger).toMap() : unavailableView(oldView, artifactId)); + if (StringUtils.hasText(artifactId)) { + projectedIds.add(artifactId); + } + } + if (assistantMessage) { + for (AgentArtifact ledger : variantArtifacts) { + if (projectedIds.add(ledger.getArtifactId())) { + projected.add(toView(ledger).toMap()); + } + } + } + Map payload = originalPayload == null + ? new LinkedHashMap<>() : new LinkedHashMap<>(originalPayload); + payload.put("artifacts", projected); + message.setContentPayload(payload); + } + + private Map unavailableView(Map oldView, String artifactId) { + Object sizeValue = oldView.get("size"); + long size = sizeValue instanceof Number number ? Math.max(0L, number.longValue()) : 0L; + return new AgentArtifactView( + 1, + artifactId, + safeString(oldView.get("fileName")), + safeString(oldView.get("mimeType")), + size, + safeString(oldView.get("sha256")), + null, + UNAVAILABLE_STATUS).toMap(); + } + + private String safeString(Object value) { + return value instanceof String text ? text : null; + } + + private void requirePositive(BigInteger value, String field) { + if (value == null || value.signum() <= 0) { + throw new IllegalArgumentException(field + " must be positive"); + } + } + + private void compensatePublishFailure(AgentArtifact artifact, + boolean uploadAttempted, + String code, + Throwable error) { + boolean cleanupFailed = false; + if (uploadAttempted) { + try { + objectStorage.delete(artifact.getObjectKey()); + } catch (RuntimeException cleanupError) { + cleanupFailed = true; + error.addSuppressed(cleanupError); + } + } + AgentArtifact update = new AgentArtifact(); + update.setStatus(cleanupFailed + ? AgentArtifactStatus.DELETE_FAILED.name() : AgentArtifactStatus.FAILED.name()); + update.setLastErrorCode(code); + update.setRetryCount(cleanupFailed ? 1 : 0); + update.setNextRetryAt(cleanupFailed ? new Date() : null); + update.setModified(new Date()); + QueryWrapper condition = QueryWrapper.create() + .eq(AgentArtifact::getId, artifact.getId()); + if (cleanupFailed) { + condition.in(AgentArtifact::getStatus, + AgentArtifactStatus.PUBLISHING.name(), + AgentArtifactStatus.DELETE_PENDING.name()); + } else { + // 会话删除已把记录置为 DELETE_PENDING 时,保留删除意图交给调度器幂等收口。 + condition.eq(AgentArtifact::getStatus, AgentArtifactStatus.PUBLISHING.name()); + } + mapper.updateByQuery(update, condition); + LOG.error("Agent Artifact publish failed, artifactId={}", artifact.getArtifactId(), error); + } + + private void markDeletePending(AgentArtifact artifact) { + AgentArtifact update = new AgentArtifact(); + update.setStatus(AgentArtifactStatus.DELETE_PENDING.name()); + update.setNextRetryAt(new Date()); + update.setModified(new Date()); + mapper.updateByQuery(update, QueryWrapper.create() + .eq(AgentArtifact::getId, artifact.getId()) + .eq(AgentArtifact::getStatus, AgentArtifactStatus.AVAILABLE.name())); + } + + private long fileSize(Path file) { + try { + return Files.size(file); + } catch (IOException error) { + throw new AgentArtifactOperationException( + "WORKSPACE_FILE_NOT_FOUND", "读取工作区文件大小失败", true, error); + } + } + + private String mimeType(Path file) { + byte[] header = new byte[512]; + int length; + try (InputStream input = Files.newInputStream(file)) { + length = input.read(header); + } catch (IOException error) { + throw new AgentArtifactOperationException( + "WORKSPACE_FILE_NOT_FOUND", "读取工作区文件类型失败", true, error); + } + if (length < 0) { + return "application/octet-stream"; + } + if (startsWith(header, length, new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A})) { + return "image/png"; + } + if (startsWith(header, length, new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF})) { + return "image/jpeg"; + } + if (startsWith(header, length, "GIF87a".getBytes(StandardCharsets.US_ASCII)) + || startsWith(header, length, "GIF89a".getBytes(StandardCharsets.US_ASCII))) { + return "image/gif"; + } + if (startsWith(header, length, "%PDF-".getBytes(StandardCharsets.US_ASCII))) { + return "application/pdf"; + } + if (startsWith(header, length, new byte[]{'P', 'K', 0x03, 0x04}) + || startsWith(header, length, new byte[]{'P', 'K', 0x05, 0x06}) + || startsWith(header, length, new byte[]{'P', 'K', 0x07, 0x08})) { + return officeOpenXmlMime(file); + } + if (isSafeUtf8Text(header, length)) { + return "text/plain"; + } + return "application/octet-stream"; + } + + private String officeOpenXmlMime(Path file) { + if (!hasBoundedClassicZipDirectory(file)) { + return "application/zip"; + } + boolean contentTypes = false; + String documentType = null; + long declaredSize = 0L; + try (ZipFile zipFile = new ZipFile(file.toFile())) { + if (zipFile.size() > MAX_ZIP_ENTRIES) { + return "application/zip"; + } + Enumeration entries = zipFile.entries(); + int inspected = 0; + while (entries.hasMoreElements() && inspected++ < MAX_ZIP_ENTRIES) { + ZipEntry entry = entries.nextElement(); + if (isSuspiciousZipEntry(entry, declaredSize)) { + return "application/zip"; + } + declaredSize += Math.max(0L, entry.getSize()); + String name = entry.getName(); + contentTypes |= "[Content_Types].xml".equals(name); + if (name.startsWith("word/")) { + documentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + } else if (name.startsWith("xl/")) { + documentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + } else if (name.startsWith("ppt/")) { + documentType = "application/vnd.openxmlformats-officedocument.presentationml.presentation"; + } + } + } catch (IOException ignored) { + return "application/zip"; + } + return contentTypes && documentType != null ? documentType : "application/zip"; + } + + /** + * 在构造 {@link ZipFile} 前以常量内存校验经典 ZIP 的中央目录边界和实际条目数。 + * + * @param file 待识别 ZIP 文件 + * @return 中央目录可安全交给 ZipFile 解析时为 true + */ + private boolean hasBoundedClassicZipDirectory(Path file) { + try (SeekableByteChannel channel = Files.newByteChannel(file, StandardOpenOption.READ)) { + long archiveSize = channel.size(); + if (archiveSize < ZIP_EOCD_MIN_SIZE) { + return false; + } + int tailSize = (int) Math.min(archiveSize, ZIP_EOCD_MAX_SIZE); + long tailOffset = archiveSize - tailSize; + ByteBuffer tail = readZipAt(channel, tailOffset, tailSize); + int eocdIndex = findZipEocd(tail); + if (eocdIndex < 0) { + return false; + } + int diskNumber = unsignedZipShort(tail, eocdIndex + 4); + int directoryDisk = unsignedZipShort(tail, eocdIndex + 6); + int entriesOnDisk = unsignedZipShort(tail, eocdIndex + 8); + int totalEntries = unsignedZipShort(tail, eocdIndex + 10); + long directorySize = unsignedZipInt(tail, eocdIndex + 12); + long directoryOffset = unsignedZipInt(tail, eocdIndex + 16); + if (diskNumber == ZIP_UINT16_MAX || directoryDisk == ZIP_UINT16_MAX + || entriesOnDisk == ZIP_UINT16_MAX || totalEntries == ZIP_UINT16_MAX + || directorySize == ZIP_UINT32_MAX || directoryOffset == ZIP_UINT32_MAX + || diskNumber != 0 || directoryDisk != 0 || entriesOnDisk != totalEntries + || totalEntries > MAX_ZIP_ENTRIES + || directorySize > MAX_ZIP_CENTRAL_DIRECTORY_SIZE) { + return false; + } + long eocdOffset = tailOffset + eocdIndex; + long directoryEnd = Math.addExact(directoryOffset, directorySize); + if (directoryEnd != eocdOffset || directoryEnd > archiveSize) { + return false; + } + return hasExpectedCentralDirectoryEntries( + channel, directoryOffset, directoryEnd, totalEntries); + } catch (IOException | ArithmeticException ignored) { + return false; + } + } + + /** + * 有界扫描中央目录头,防止伪造较小 EOCD 条目数绕过预检。 + * + * @param channel ZIP 文件通道 + * @param position 中央目录起点 + * @param end 中央目录终点 + * @param expectedEntries EOCD 声明条目数 + * @return 实际结构和数量一致时为 true + * @throws IOException 读取失败或文件截断时抛出 + */ + private boolean hasExpectedCentralDirectoryEntries(SeekableByteChannel channel, + long position, + long end, + int expectedEntries) throws IOException { + int actualEntries = 0; + while (position < end) { + if (end - position < ZIP_CENTRAL_DIRECTORY_HEADER_SIZE) { + return false; + } + ByteBuffer header = readZipAt(channel, position, ZIP_CENTRAL_DIRECTORY_HEADER_SIZE); + if (header.getInt(0) != ZIP_CENTRAL_DIRECTORY_SIGNATURE) { + return false; + } + long variableSize = (long) unsignedZipShort(header, 28) + + unsignedZipShort(header, 30) + + unsignedZipShort(header, 32); + position = Math.addExact(position, + Math.addExact((long) ZIP_CENTRAL_DIRECTORY_HEADER_SIZE, variableSize)); + if (position > end || ++actualEntries > MAX_ZIP_ENTRIES) { + return false; + } + } + return position == end && actualEntries == expectedEntries; + } + + /** + * 在文件尾缓冲区中定位与注释长度一致的 EOCD。 + * + * @param tail ZIP 文件尾缓冲区 + * @return EOCD 相对偏移,未找到时返回 -1 + */ + private int findZipEocd(ByteBuffer tail) { + for (int index = tail.limit() - ZIP_EOCD_MIN_SIZE; index >= 0; index--) { + if (tail.getInt(index) == ZIP_EOCD_SIGNATURE) { + int commentLength = unsignedZipShort(tail, index + 20); + if (index + ZIP_EOCD_MIN_SIZE + commentLength == tail.limit()) { + return index; + } + } + } + return -1; + } + + /** + * 从通道指定位置完整读取固定长度的小端序数据。 + * + * @param channel ZIP 文件通道 + * @param position 起始偏移 + * @param length 读取长度 + * @return 已翻转的小端序缓冲区 + * @throws IOException 读取失败或文件截断时抛出 + */ + private ByteBuffer readZipAt(SeekableByteChannel channel, long position, int length) throws IOException { + if (position < 0L || length < 0 || position > channel.size() - length) { + throw new IOException("ZIP record exceeds archive bounds"); + } + ByteBuffer buffer = ByteBuffer.allocate(length).order(ByteOrder.LITTLE_ENDIAN); + channel.position(position); + while (buffer.hasRemaining()) { + if (channel.read(buffer) <= 0) { + throw new IOException("ZIP record is truncated"); + } + } + buffer.flip(); + return buffer; + } + + /** + * 读取小端序无符号 16 位整数。 + * + * @param buffer 来源缓冲区 + * @param offset 字段偏移 + * @return 无符号整数值 + */ + private int unsignedZipShort(ByteBuffer buffer, int offset) { + return Short.toUnsignedInt(buffer.getShort(offset)); + } + + /** + * 读取小端序无符号 32 位整数。 + * + * @param buffer 来源缓冲区 + * @param offset 字段偏移 + * @return 无符号长整数值 + */ + private long unsignedZipInt(ByteBuffer buffer, int offset) { + return Integer.toUnsignedLong(buffer.getInt(offset)); + } + + /** + * 仅依据 central directory 元数据识别可能造成过量展开的 ZIP 条目。 + * + * @param entry ZIP 条目元数据 + * @param accumulatedSize 已累计声明展开大小 + * @return 条目超出结构探测安全边界时为 true + */ + private boolean isSuspiciousZipEntry(ZipEntry entry, long accumulatedSize) { + String name = entry.getName(); + long size = entry.getSize(); + long compressedSize = entry.getCompressedSize(); + if (name == null || name.length() > MAX_ZIP_ENTRY_NAME_LENGTH + || size < 0L || compressedSize < 0L + || size > MAX_ZIP_DECLARED_SIZE - accumulatedSize) { + return true; + } + if (size == 0L) { + return false; + } + return compressedSize == 0L + || (double) size / (double) compressedSize > MAX_ZIP_COMPRESSION_RATIO; + } + + private void verifyStoredObject(String objectKey, long expectedSize, String expectedSha256) throws IOException { + AgentArtifactObjectStorage.StoredObjectMetadata metadata = objectStorage.stat(objectKey); + if (metadata == null || metadata.size() != expectedSize) { + throw new AgentArtifactOperationException( + "ARTIFACT_STORAGE_VERIFY_FAILED", "产物对象大小校验失败", true); + } + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException("SHA-256 算法不可用", error); + } + long actualSize = 0L; + byte[] buffer = new byte[8192]; + try (InputStream raw = objectStorage.open(objectKey); + DigestInputStream input = new DigestInputStream(raw, digest)) { + int read; + while ((read = input.read(buffer)) != -1) { + if (read > 0) { + actualSize += read; + } + } + } + String actualSha256 = HexFormat.of().formatHex(digest.digest()); + if (actualSize != expectedSize || !expectedSha256.equals(actualSha256)) { + throw new AgentArtifactOperationException( + "ARTIFACT_STORAGE_VERIFY_FAILED", "产物对象内容校验失败", true); + } + } + + private boolean startsWith(byte[] source, int length, byte[] prefix) { + if (length < prefix.length) { + return false; + } + for (int index = 0; index < prefix.length; index++) { + if (source[index] != prefix[index]) { + return false; + } + } + return true; + } + + private boolean isSafeUtf8Text(byte[] source, int length) { + for (int index = 0; index < length; index++) { + int value = source[index] & 0xFF; + if (value == 0 || value < 0x09 || value > 0x0D && value < 0x20) { + return false; + } + } + String decoded = new String(source, 0, length, StandardCharsets.UTF_8); + return !decoded.contains("\uFFFD"); + } + + private String safeFileName(String requested, String fallback) { + String value = StringUtils.hasText(requested) ? requested.trim() : fallback; + value = value.replaceAll("[\\r\\n\\u0000-\\u001f\\u007f]", "_"); + if (value.contains("/") || value.contains("\\\\") || ".".equals(value) || "..".equals(value)) { + throw new AgentArtifactOperationException("ARTIFACT_PUBLISH_FAILED", "产物文件名不合法", false); + } + if (value.length() > MAX_FILE_NAME_LENGTH) { + value = value.substring(0, MAX_FILE_NAME_LENGTH); + } + return value; + } + + private String safeExtension(String fileName) { + int dot = fileName.lastIndexOf('.'); + if (dot < 0 || dot == fileName.length() - 1) { + return ""; + } + String extension = fileName.substring(dot).toLowerCase(Locale.ROOT); + return extension.matches("\\.[a-z0-9]{1,16}") ? extension : ""; + } + + private BigInteger positiveId(String value) { + BigInteger id = new BigInteger(value); + if (id.signum() <= 0) { + throw new IllegalArgumentException(); + } + return id; + } + + private Integer positiveInteger(Object value) { + int number = value instanceof Number numeric + ? numeric.intValue() : Integer.parseInt(String.valueOf(value)); + if (number <= 0) { + throw new IllegalArgumentException(); + } + return number; + } + + private String opaqueId() { + return UUID.randomUUID().toString().replace("-", ""); + } + + private record ToolIdentity(BigInteger tenantId, + BigInteger userId, + BigInteger agentId, + String runtimeSessionId, + BigInteger chatSessionId, + BigInteger roundId, + Integer variantIndex, + String mode) { + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactStatus.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactStatus.java new file mode 100644 index 00000000..9c2b6382 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactStatus.java @@ -0,0 +1,19 @@ +package tech.easyflow.agent.runtime.artifact; + +/** + * Agent Artifact 跨数据库与对象存储的状态。 + */ +public enum AgentArtifactStatus { + /** 正在上传。 */ + PUBLISHING, + /** 可下载。 */ + AVAILABLE, + /** 发布失败。 */ + FAILED, + /** 等待删除。 */ + DELETE_PENDING, + /** 删除失败且等待重试。 */ + DELETE_FAILED, + /** 已删除。 */ + DELETED +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactView.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactView.java new file mode 100644 index 00000000..2c42acc0 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/artifact/AgentArtifactView.java @@ -0,0 +1,44 @@ +package tech.easyflow.agent.runtime.artifact; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Artifact Tool、AG-UI 与管理端共用的安全视图。 + * + * @param schemaVersion 结构版本 + * @param artifactId 稳定产物 ID + * @param fileName 安全文件名 + * @param mimeType MIME 类型 + * @param size 文件字节数 + * @param sha256 文件 SHA-256 + * @param downloadUrl 鉴权下载地址 + * @param status 可公开状态 + */ +public record AgentArtifactView(int schemaVersion, + String artifactId, + String fileName, + String mimeType, + long size, + String sha256, + String downloadUrl, + String status) { + + /** + * 转换为稳定字段顺序的安全 Map。 + * + * @return 不含对象存储定位信息的 Map + */ + public Map toMap() { + Map result = new LinkedHashMap<>(); + result.put("schemaVersion", schemaVersion); + result.put("artifactId", artifactId); + result.put("fileName", fileName); + result.put("mimeType", mimeType); + result.put("size", size); + result.put("sha256", sha256); + result.put("downloadUrl", downloadUrl); + result.put("status", status); + return result; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/asynctool/PluginAsyncSubTools.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/asynctool/PluginAsyncSubTools.java index 378decec..de72704f 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/asynctool/PluginAsyncSubTools.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/asynctool/PluginAsyncSubTools.java @@ -4,6 +4,7 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import tech.easyflow.agent.enums.AgentToolType; import tech.easyflow.agent.runtime.tool.AgentToolExecutionResult; import tech.easyflow.agent.runtime.tool.PluginToolExecutor; +import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.PluginItem; import java.util.Map; @@ -14,6 +15,7 @@ import java.util.Map; public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools { private final PluginItem pluginItem; + private final Plugin plugin; private final String toolName; private final String displayName; private final PluginToolExecutor pluginToolExecutor; @@ -22,6 +24,7 @@ public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools { * 创建 Plugin 异步工具子能力。 * * @param pluginItem 插件工具快照 + * @param plugin 父插件调用配置快照 * @param toolName runtime 工具名 * @param displayName 用户可见名称 * @param pluginToolExecutor Plugin 执行器 @@ -29,6 +32,7 @@ public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools { * @param taskExecutor 后台执行器 */ public PluginAsyncSubTools(PluginItem pluginItem, + Plugin plugin, String toolName, String displayName, PluginToolExecutor pluginToolExecutor, @@ -36,6 +40,7 @@ public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools { ThreadPoolTaskExecutor taskExecutor) { super(taskStore, taskExecutor); this.pluginItem = pluginItem; + this.plugin = plugin; this.toolName = toolName; this.displayName = displayName; this.pluginToolExecutor = pluginToolExecutor; @@ -78,6 +83,6 @@ public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools { */ @Override protected AgentToolExecutionResult executeBusiness(Map arguments) { - return pluginToolExecutor.execute(pluginItem, arguments); + return pluginToolExecutor.execute(pluginItem, plugin, arguments); } } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/event/MySqlAgentRunEventRecorder.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/event/MySqlAgentRunEventRecorder.java index 17e0e0b7..eec6c771 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/event/MySqlAgentRunEventRecorder.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/event/MySqlAgentRunEventRecorder.java @@ -58,6 +58,7 @@ public class MySqlAgentRunEventRecorder implements AgentRunEventRecorder { private boolean shouldPersist(AgentRuntimeEventType type) { return type != AgentRuntimeEventType.MESSAGE_DELTA && type != AgentRuntimeEventType.REASONING_DELTA + && type != AgentRuntimeEventType.SKILL_STEP && type != AgentRuntimeEventType.STARTED && type != AgentRuntimeEventType.COMPLETED; } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingServiceImpl.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingServiceImpl.java index 5236b887..e0c438e5 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingServiceImpl.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingServiceImpl.java @@ -65,7 +65,8 @@ public class AgentHitlPendingServiceImpl implements AgentHitlPendingService { pending.setRequestId(requestId); pending.setToolCallId(firstText(event.getToolCallId(), stringValue(event.getPayload().get("toolCallId")))); pending.setToolName(stringValue(event.getPayload().get("toolName"))); - pending.setToolInputJson(mapValue(firstNonNull(event.getPayload().get("toolInput"), event.getPayload().get("input")))); + pending.setToolInputJson(ToolApprovalInputProjection.project( + firstNonNull(event.getPayload().get("toolInput"), event.getPayload().get("input")))); pending.setStatus(AgentHitlPendingStatus.PENDING.name()); pending.setExpiresAt(resolveExpiresAt(event)); pending.setMetadataJson(metadata(event)); @@ -272,17 +273,7 @@ public class AgentHitlPendingServiceImpl implements AgentHitlPendingService { if (approvalMetadata instanceof Map map) { map.forEach((key, value) -> metadata.put(String.valueOf(key), value)); } - return metadata; - } - - @SuppressWarnings("unchecked") - private Map mapValue(Object value) { - if (value instanceof Map map) { - Map result = new LinkedHashMap<>(); - map.forEach((key, item) -> result.put(String.valueOf(key), item)); - return result; - } - return new LinkedHashMap<>(); + return ToolApprovalInputProjection.project(metadata); } private Date dateValue(Object value) { diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/ToolApprovalInputProjection.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/ToolApprovalInputProjection.java new file mode 100644 index 00000000..49e15336 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/hitl/ToolApprovalInputProjection.java @@ -0,0 +1,99 @@ +package tech.easyflow.agent.runtime.hitl; + +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * 将工具审批输入转换为可展示、可持久化的脱敏副本。 + */ +public final class ToolApprovalInputProjection { + + private static final String REDACTED = "[已隐藏]"; + private static final Pattern SENSITIVE_KEY = Pattern.compile( + ".*(token|secret|password|passwd|apikey|authorization|auth|cookie|credential|privatekey|accesskey|header|environment|env).*", + Pattern.CASE_INSENSITIVE); + private static final Pattern SENSITIVE_QUERY = Pattern.compile( + "(?i)([?&](?:token|secret|password|passwd|api[_-]?key|authorization|access[_-]?key)=)[^&#\\s]*"); + private static final Pattern URL_USER_INFO = Pattern.compile( + "(?i)([a-z][a-z0-9+.-]*://)[^/@\\s]+:[^/@\\s]+@"); + + private ToolApprovalInputProjection() { + } + + /** + * 投影工具输入,保留普通业务参数并递归遮蔽敏感字段。 + * + * @param value 原始工具输入 + * @return 不修改原对象的脱敏 Map;输入不是 Map 时返回空 Map + */ + public static Map project(Object value) { + if (!(value instanceof Map source)) { + return Map.of(); + } + return projectMap(source); + } + + /** + * 递归投影 Map。 + * + * @param source 原始 Map + * @return 保持字段顺序的脱敏 Map + */ + private static Map projectMap(Map source) { + Map projected = new LinkedHashMap<>(); + source.forEach((rawKey, rawValue) -> { + String key = String.valueOf(rawKey); + projected.put(key, isSensitiveKey(key) ? REDACTED : projectValue(rawValue)); + }); + return projected; + } + + /** + * 递归投影集合、数组、Map 与字符串值。 + * + * @param value 原始值 + * @return 脱敏副本 + */ + private static Object projectValue(Object value) { + if (value instanceof Map map) { + return projectMap(map); + } + if (value instanceof Collection collection) { + List projected = new ArrayList<>(collection.size()); + collection.forEach(item -> projected.add(projectValue(item))); + return projected; + } + if (value != null && value.getClass().isArray()) { + int length = Array.getLength(value); + List projected = new ArrayList<>(length); + for (int index = 0; index < length; index++) { + projected.add(projectValue(Array.get(value, index))); + } + return projected; + } + if (value instanceof String text) { + String withoutUserInfo = URL_USER_INFO.matcher(text).replaceAll("$1" + REDACTED + "@"); + return SENSITIVE_QUERY.matcher(withoutUserInfo).replaceAll("$1" + REDACTED); + } + return value; + } + + /** + * 判断字段名是否表达凭据、认证头或环境配置。 + * + * @param key 原始字段名 + * @return 需要整值遮蔽时为 true + */ + private static boolean isSensitiveKey(String key) { + String normalized = key == null ? "" : key + .replaceAll("[^A-Za-z0-9]", "") + .toLowerCase(Locale.ROOT); + return SENSITIVE_KEY.matcher(normalized).matches(); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/lock/AgentRunLock.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/lock/AgentRunLock.java index 2cf51641..663df35d 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/lock/AgentRunLock.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/lock/AgentRunLock.java @@ -16,6 +16,17 @@ public interface AgentRunLock { */ Handle acquire(BigInteger agentId, String sessionId); + /** + * 无等待尝试获取指定 Agent 会话的运行锁。 + * + * @param agentId Agent ID + * @param sessionId 运行时会话 ID + * @return 获取成功时返回锁句柄,锁已被占用时返回 null + */ + default Handle tryAcquire(BigInteger agentId, String sessionId) { + return null; + } + /** * Agent 运行锁句柄。 */ diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/lock/RedisAgentRunLock.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/lock/RedisAgentRunLock.java index 7ad9ae81..96136398 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/lock/RedisAgentRunLock.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/lock/RedisAgentRunLock.java @@ -6,6 +6,7 @@ import tech.easyflow.common.cache.RedisLockExecutor; import tech.easyflow.common.web.exceptions.BusinessException; import java.math.BigInteger; +import java.time.Duration; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -52,6 +53,13 @@ public class RedisAgentRunLock implements AgentRunLock { } } + @Override + public Handle tryAcquire(BigInteger agentId, String sessionId) { + RedisLockExecutor.LockHandle handle = redisLockExecutor.tryAcquire( + lockKey(agentId, sessionId), Duration.ZERO, properties.getLockLeaseTimeout()); + return handle == null ? null : new RedisHandle(handle, scheduleRenew(handle)); + } + private ScheduledFuture scheduleRenew(RedisLockExecutor.LockHandle handle) { long intervalMillis = Math.max(1000L, properties.getLockRenewInterval().toMillis()); return RENEW_EXECUTOR.scheduleAtFixedRate(handle::renew, intervalMillis, intervalMillis, TimeUnit.MILLISECONDS); diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/output/AgentRunOutput.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/output/AgentRunOutput.java new file mode 100644 index 00000000..32e9170c --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/output/AgentRunOutput.java @@ -0,0 +1,75 @@ +package tech.easyflow.agent.runtime.output; + +import com.easyagents.agent.runtime.event.AgentRuntimeEvent; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import tech.easyflow.core.chat.protocol.ChatDomain; +import tech.easyflow.core.chat.protocol.ChatType; + +/** + * Agent 单次运行的协议无关输出边界。 + */ +public interface AgentRunOutput { + + /** + * 获取底层 SSE 连接。 + * + * @return SSE Emitter + */ + SseEmitter emitter(); + + /** + * 接收一条规范化运行时事件。 + * + * @param event 运行时事件 + * @return 发送成功时为 true + */ + boolean emitRuntimeEvent(AgentRuntimeEvent event); + + /** + * 发送现有展示语义事件。 + * + * @param domain 事件域 + * @param type 展示事件类型 + * @param payload 展示载荷 + * @return 发送成功时为 true + */ + boolean emitViewEvent(ChatDomain domain, ChatType type, Object payload); + + /** + * 判断当前协议输出是否已经收到可用于成功收口的运行时终态。 + * + *

旧协议允许自然 EOF 兼容收口;要求显式终态的协议实现应覆盖此方法。

+ * + * @return 可以按成功状态持久化并结束时为 true + */ + default boolean canFinishSuccessfully() { + return true; + } + + /** + * 发送协议终态并关闭连接。 + * + * @param finalText 服务端权威最终正文,可为空 + * @return 发送成功时为 true + */ + boolean finish(String finalText); + + /** + * 正常关闭连接。 + */ + void complete(); + + /** + * 以异常关闭连接。 + * + * @param error 异常 + */ + void completeWithError(Throwable error); + + /** + * 判断连接是否关闭。 + * + * @return 已关闭时为 true + */ + boolean isClosed(); +} 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 new file mode 100644 index 00000000..388dee7f --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/output/AguiAgentRunOutput.java @@ -0,0 +1,583 @@ +package tech.easyflow.agent.runtime.output; + +import com.easyagents.agent.runtime.event.AgentRuntimeEvent; +import com.easyagents.agent.runtime.event.AgentRuntimeEventType; +import com.easyagents.agui.AguiExtendedEvent; +import com.easyagents.agui.AguiProtocolEventEncoder; +import com.easyagents.agui.AguiRuntimeEventProjector; +import io.agentscope.core.agui.event.AguiEvent; +import io.agentscope.core.agui.model.AguiMessage; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import tech.easyflow.agent.runtime.hitl.ToolApprovalInputProjection; +import tech.easyflow.core.chat.protocol.ChatDomain; +import tech.easyflow.core.chat.protocol.ChatType; +import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * 将同一 Agent 业务运行投影为原生 AG-UI SSE 的输出实现。 + * + *

所有公开发送方法串行化,避免运行线程与 HITL 恢复线程交错破坏事件顺序。

+ */ +public final class AguiAgentRunOutput implements AgentRunOutput { + + private static final String ASSISTANT_ROLE = "assistant"; + private static final String REASONING_ROLE = "reasoning"; + + private final String threadId; + private final String runId; + private final String clientUserMessageId; + private final String clientUserMessageContent; + private final ChatSseEmitter delegate; + private final AguiRuntimeEventProjector projector; + private final AguiProtocolEventEncoder encoder = new AguiProtocolEventEncoder(); + + private long customSequence; + private long assistantMessageSequence; + private long reasoningMessageSequence; + private String assistantMessageId; + private String lastAssistantMessageId; + private String reasoningMessageId; + private final StringBuilder assistantText = new StringBuilder(); + private final Map> activeSkillInvocations = new LinkedHashMap<>(); + private AgentRuntimeEvent pendingCompletedEvent; + + /** + * 创建 AG-UI 输出。 + * + * @param threadId 客户端 thread ID + * @param runId 客户端 run ID + * @param clientUserMessageId 本轮客户端用户消息 ID + */ + public AguiAgentRunOutput(String threadId, String runId, String clientUserMessageId) { + this(threadId, runId, clientUserMessageId, null, new ChatSseEmitter()); + } + + /** + * 创建包含本轮用户消息快照信息的 AG-UI 输出。 + * + * @param threadId 客户端 thread ID + * @param runId 客户端 run ID + * @param clientUserMessageId 本轮客户端用户消息 ID + * @param clientUserMessageContent 本轮客户端用户消息正文 + */ + public AguiAgentRunOutput( + String threadId, + String runId, + String clientUserMessageId, + String clientUserMessageContent) { + this(threadId, runId, clientUserMessageId, clientUserMessageContent, new ChatSseEmitter()); + } + + /** + * 使用指定 SSE 发射器创建 AG-UI 输出,供受控装配和测试使用。 + * + * @param threadId 客户端 thread ID + * @param runId 客户端 run ID + * @param clientUserMessageId 本轮客户端用户消息 ID + * @param delegate SSE 发射器 + */ + public AguiAgentRunOutput( + String threadId, + String runId, + String clientUserMessageId, + ChatSseEmitter delegate) { + this(threadId, runId, clientUserMessageId, null, delegate); + } + + /** + * 使用指定 SSE 发射器和用户消息快照信息创建 AG-UI 输出。 + * + * @param threadId 客户端 thread ID + * @param runId 客户端 run ID + * @param clientUserMessageId 本轮客户端用户消息 ID + * @param clientUserMessageContent 本轮客户端用户消息正文 + * @param delegate SSE 发射器 + */ + public AguiAgentRunOutput( + String threadId, + String runId, + String clientUserMessageId, + String clientUserMessageContent, + ChatSseEmitter delegate) { + this.threadId = requireText(threadId, "threadId"); + this.runId = requireText(runId, "runId"); + this.clientUserMessageId = clientUserMessageId; + this.clientUserMessageContent = clientUserMessageContent; + this.delegate = java.util.Objects.requireNonNull(delegate, "delegate cannot be null"); + this.projector = new AguiRuntimeEventProjector(threadId, runId); + } + + @Override + public SseEmitter emitter() { + return delegate.getEmitter(); + } + + @Override + public synchronized boolean emitRuntimeEvent(AgentRuntimeEvent event) { + if (event == null || event.getEventType() == null || delegate.isClosed()) { + return !delegate.isClosed(); + } + AgentRuntimeEventType type = event.getEventType(); + if (type == AgentRuntimeEventType.MESSAGE_DELTA + || type == AgentRuntimeEventType.REASONING_STARTED + || type == AgentRuntimeEventType.REASONING_DELTA + || type == AgentRuntimeEventType.REASONING_COMPLETED) { + // EasyFlow 的跨 delta 归一化结果通过 emitViewEvent 输出,避免重复和标签泄漏。 + return true; + } + if (type == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) { + return emitToolApproval(event); + } + if (type == AgentRuntimeEventType.SKILL_CALL + || type == AgentRuntimeEventType.SKILL_RESULT + || type == AgentRuntimeEventType.SKILL_FAILED) { + return emitSkillInvocation(event); + } + if (type == AgentRuntimeEventType.SKILL_STEP) { + return true; + } + if (isAsyncToolEvent(type)) { + return true; + } + if (type == AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL) { + return true; + } + if (type == AgentRuntimeEventType.MEMORY_COMPRESSION_STARTED + || type == AgentRuntimeEventType.MEMORY_COMPRESSION_COMPLETED) { + return true; + } + if (type == AgentRuntimeEventType.SUSPENDED) { + return true; + } + if (type == AgentRuntimeEventType.COMPLETED) { + pendingCompletedEvent = event; + return true; + } + if (type == AgentRuntimeEventType.CANCELLED) { + if (!closeActiveSkillInvocations("CANCELLED")) { + return false; + } + } else if (type == AgentRuntimeEventType.FAILED) { + if (!closeActiveSkillInvocations("INCOMPLETE")) { + return false; + } + } + if (type == AgentRuntimeEventType.TOOL_CALL + || type == AgentRuntimeEventType.FAILED + || type == AgentRuntimeEventType.CANCELLED) { + if (!closeOpenMessages()) { + return false; + } + } + for (Object protocolEvent : projector.project(event)) { + if (!send(protocolEvent)) { + return false; + } + } + if (type == AgentRuntimeEventType.TOOL_CALL) { + return emitToolMetadata(event); + } + return true; + } + + @Override + public synchronized boolean emitViewEvent(ChatDomain domain, ChatType type, Object payload) { + if (delegate.isClosed()) { + return false; + } + if (type == ChatType.MESSAGE) { + return emitAssistantDelta(stringValue(payload, "delta")); + } + if (type == ChatType.THINKING) { + return emitReasoningDelta(firstText( + stringValue(payload, "delta"), stringValue(payload, "reasoning"))); + } + if (type == ChatType.INPUT_ACCEPTED) { + Map value = copyMap(payload); + value.put("clientMessageId", clientUserMessageId); + value.put("serverMessageId", value.get("messageId")); + return emitCustom("easyflow.input.accepted", value, clientUserMessageId); + } + if (type == ChatType.CITATIONS) { + return emitCustom("easyflow.knowledge.citations", copyMap(payload), lastAssistantMessageId); + } + if ((type == ChatType.TOOL_CALL || type == ChatType.TOOL_RESULT) + && Boolean.TRUE.equals(copyMap(payload).get("asyncTool"))) { + return emitCustom("easyflow.async_tool.status", + selectPayload(payload, + "asyncTool", "asyncToolName", "input", "label", "name", "output", + "phase", "result", "sourceToolCallId", "status", "statusKey", "summary", + "taskId", "text", "toolCallId", "toolDisplayName", "toolInput", "toolName"), + null); + } + if (type == ChatType.STATUS) { + String statusKey = stringValue(payload, "statusKey"); + if ("artifact-published".equals(statusKey)) { + return emitCustom("easyflow.artifact.published", + selectPayload(payload, "schemaVersion", "artifactId", "fileName", "mimeType", + "size", "sha256", "downloadUrl", "status"), + lastAssistantMessageId); + } + if ("knowledge-retrieval".equals(statusKey)) { + return emitCustom("easyflow.knowledge.retrieval_status", + selectPayload(payload, "label", "status", "statusKey"), null); + } + if ("memory-compression".equals(statusKey)) { + return emitCustom("easyflow.runtime.context_status", + selectPayload(payload, "compressed", "label", "phase", "status", "statusKey"), null); + } + } + if (type == ChatType.FORM_CANCEL) { + return emitCustom("easyflow.hitl.tool_approval_resolved", copyMap(payload), null); + } + if (type == ChatType.ERROR) { + return fail(firstText(stringValue(payload, "message"), "Agent runtime failed."), "AGENT_RUNTIME_FAILED"); + } + // 工具标准事件和业务状态已从 AgentRuntimeEvent 投影;其余 Legacy 展示事件不进入 AG-UI wire。 + return true; + } + + @Override + public synchronized boolean canFinishSuccessfully() { + return pendingCompletedEvent != null; + } + + @Override + public synchronized boolean finish(String finalText) { + if (delegate.isClosed()) { + return false; + } + if (!reconcileFinalText(finalText)) { + return false; + } + if (pendingCompletedEvent != null && !projector.isTerminated()) { + if (!closeActiveSkillInvocations("INCOMPLETE")) { + return false; + } + if (!closeOpenMessages()) { + return false; + } + for (Object protocolEvent : projector.project(pendingCompletedEvent)) { + if (!send(protocolEvent)) { + return false; + } + } + pendingCompletedEvent = null; + } + if (!projector.isTerminated()) { + if (!fail("Agent stream ended without a terminal event.", "MISSING_TERMINAL_EVENT")) { + return false; + } + } + delegate.complete(); + return true; + } + + @Override + public synchronized void complete() { + delegate.complete(); + } + + @Override + public synchronized void completeWithError(Throwable error) { + if (!projector.isTerminated() && !delegate.isClosed()) { + closeActiveSkillInvocations("INCOMPLETE"); + fail(error == null || error.getMessage() == null + ? "Agent runtime failed." + : error.getMessage(), "AGENT_RUNTIME_FAILED"); + } + delegate.complete(); + } + + @Override + public boolean isClosed() { + return delegate.isClosed(); + } + + private boolean emitAssistantDelta(String delta) { + if (delta == null || delta.isEmpty()) { + return true; + } + if (!ensureRunStarted()) { + return false; + } + if (reasoningMessageId != null && !closeReasoning()) { + return false; + } + if (assistantMessageId == null) { + assistantMessageId = runId + "-assistant-" + (++assistantMessageSequence); + lastAssistantMessageId = assistantMessageId; + if (!send(new AguiEvent.TextMessageStart( + threadId, runId, assistantMessageId, ASSISTANT_ROLE))) { + return false; + } + } + assistantText.append(delta); + return send(new AguiEvent.TextMessageContent(threadId, runId, assistantMessageId, delta)); + } + + private boolean emitReasoningDelta(String delta) { + if (delta == null || delta.isEmpty()) { + return true; + } + if (!ensureRunStarted()) { + return false; + } + if (assistantMessageId != null && !closeAssistant()) { + return false; + } + if (reasoningMessageId == null) { + reasoningMessageId = runId + "-reasoning-" + (++reasoningMessageSequence); + if (!send(new AguiEvent.ReasoningMessageStart( + threadId, runId, reasoningMessageId, REASONING_ROLE))) { + return false; + } + } + return send(new AguiEvent.ReasoningMessageContent(threadId, runId, reasoningMessageId, delta)); + } + + private boolean emitToolApproval(AgentRuntimeEvent event) { + Map source = event.getPayload() == null ? Map.of() : event.getPayload(); + Map value = new LinkedHashMap<>(); + value.put("approvalId", event.getMetadata().get("approvalId")); + value.put("toolCallId", firstText(event.getToolCallId(), stringValue(source, "toolCallId"))); + value.put("toolName", stringValue(source, "toolName")); + value.put("toolDisplayName", firstText( + stringValue(source, "toolDisplayName"), stringValue(source, "toolName"))); + value.put("input", ToolApprovalInputProjection.project( + firstNonNull(source.get("toolInput"), source.get("input")))); + value.put("expiresAt", source.get("expiresAt")); + return emitCustom("easyflow.hitl.tool_approval_required", value, event.getMessageId()); + } + + private boolean emitSkillInvocation(AgentRuntimeEvent event) { + Map value = selectPayload(event.getPayload(), + "statusKey", "status", "skillId", "skillName", "skillDisplayName", + "toolCallId", "message"); + String statusKey = stringValue(value, "statusKey"); + if (statusKey == null) { + return true; + } + String status = stringValue(value, "status"); + if ("RUNNING".equals(status)) { + activeSkillInvocations.put(statusKey, new LinkedHashMap<>(value)); + } else { + activeSkillInvocations.remove(statusKey); + } + return emitCustom("easyflow.skill.invocation_status", value, event.getMessageId()); + } + + private boolean closeActiveSkillInvocations(String status) { + if (activeSkillInvocations.isEmpty()) { + return true; + } + List> pending = new ArrayList<>(activeSkillInvocations.values()); + activeSkillInvocations.clear(); + for (Map value : pending) { + Map terminal = new LinkedHashMap<>(value); + terminal.put("status", status); + terminal.remove("message"); + if (!emitCustom("easyflow.skill.invocation_status", terminal, null)) { + return false; + } + } + return true; + } + + private boolean emitToolMetadata(AgentRuntimeEvent event) { + Map source = event.getPayload() == null ? Map.of() : event.getPayload(); + String toolCallId = firstText(event.getToolCallId(), stringValue(source, "toolCallId")); + String toolName = firstText(stringValue(source, "toolName"), stringValue(source, "name")); + String toolDisplayName = stringValue(source, "toolDisplayName"); + if (toolCallId == null || toolDisplayName == null + || toolDisplayName.equals(toolName) || isHiddenToolName(toolName)) { + return true; + } + Map value = new LinkedHashMap<>(); + value.put("toolCallId", toolCallId); + value.put("toolName", toolName); + value.put("toolDisplayName", toolDisplayName); + return emitCustom("easyflow.tool.metadata", value, event.getMessageId()); + } + + private boolean emitCustom(String name, Map payload, String messageId) { + if (!ensureRunStarted()) { + return false; + } + Map value = new LinkedHashMap<>(); + if (payload != null) { + value.putAll(payload); + } + // 协议保留字段由服务端最终写入,避免业务 payload 覆盖运行边界信息。 + value.put("schemaVersion", 1); + value.put("id", "evt_" + UUID.randomUUID()); + value.put("runId", runId); + value.put("threadId", threadId); + value.put("messageId", messageId); + value.put("sequence", ++customSequence); + value.put("timestamp", Instant.now().toString()); + return send(new AguiEvent.Custom(threadId, runId, name, value)); + } + + private boolean fail(String message, String code) { + if (projector.isTerminated()) { + return true; + } + if (!closeOpenMessages()) { + return false; + } + AgentRuntimeEvent failed = AgentRuntimeEvent.of(AgentRuntimeEventType.FAILED); + failed.getPayload().put("message", message); + String resolvedCode = code == null || code.isBlank() ? "AGENT_RUNTIME_FAILED" : code; + for (Object protocolEvent : projector.project(failed)) { + Object output = protocolEvent instanceof AguiExtendedEvent.RunError runError + ? new AguiExtendedEvent.RunError( + runError.threadId(), runError.runId(), runError.message(), resolvedCode) + : protocolEvent; + if (!send(output)) { + return false; + } + } + return true; + } + + private boolean closeOpenMessages() { + return closeReasoning() && closeAssistant(); + } + + private boolean closeReasoning() { + if (reasoningMessageId == null) { + return true; + } + String messageId = reasoningMessageId; + reasoningMessageId = null; + return send(new AguiEvent.ReasoningMessageEnd(threadId, runId, messageId)); + } + + private boolean closeAssistant() { + if (assistantMessageId == null) { + return true; + } + String messageId = assistantMessageId; + assistantMessageId = null; + return send(new AguiEvent.TextMessageEnd(threadId, runId, messageId)); + } + + private boolean reconcileFinalText(String finalText) { + if (finalText == null || finalText.contentEquals(assistantText)) { + return true; + } + String streamedText = assistantText.toString(); + if (finalText.startsWith(streamedText)) { + return emitAssistantDelta(finalText.substring(streamedText.length())); + } + if (!closeOpenMessages()) { + return false; + } + String resolvedAssistantMessageId = lastAssistantMessageId; + if (resolvedAssistantMessageId == null) { + resolvedAssistantMessageId = runId + "-assistant-" + (++assistantMessageSequence); + lastAssistantMessageId = resolvedAssistantMessageId; + } + List messages = new ArrayList<>(2); + if (clientUserMessageId != null && clientUserMessageContent != null) { + messages.add(AguiMessage.userMessage(clientUserMessageId, clientUserMessageContent)); + } + messages.add(AguiMessage.assistantMessage(resolvedAssistantMessageId, finalText)); + assistantText.setLength(0); + assistantText.append(finalText); + return send(new AguiExtendedEvent.MessagesSnapshot(threadId, runId, messages)); + } + + private boolean send(Object event) { + return delegate.sendData(encoder.encodeToJson(event)); + } + + private boolean ensureRunStarted() { + if (projector.isTerminated()) { + return false; + } + AgentRuntimeEvent started = AgentRuntimeEvent.of(AgentRuntimeEventType.STARTED); + for (Object protocolEvent : projector.project(started)) { + if (!send(protocolEvent)) { + return false; + } + } + return true; + } + + private static boolean isAsyncToolEvent(AgentRuntimeEventType type) { + return type == AgentRuntimeEventType.ASYNC_TOOL_SUBMITTED + || type == AgentRuntimeEventType.ASYNC_TOOL_OBSERVED + || type == AgentRuntimeEventType.ASYNC_TOOL_RESULT + || type == AgentRuntimeEventType.ASYNC_TOOL_CANCELLED + || type == AgentRuntimeEventType.ASYNC_TOOL_LISTED + || type == AgentRuntimeEventType.ASYNC_TOOL_FAILED; + } + + private static boolean isHiddenToolName(String toolName) { + return "retrieve_knowledge".equalsIgnoreCase(toolName) + || "context_reload".equalsIgnoreCase(toolName) + || "__fragment__".equalsIgnoreCase(toolName); + } + + @SuppressWarnings("unchecked") + private static Map copyMap(Object payload) { + return payload instanceof Map map + ? new LinkedHashMap<>((Map) map) + : new LinkedHashMap<>(); + } + + /** + * 选取允许进入 AG-UI CUSTOM 的公开字段。 + * + * @param payload 服务层展示载荷 + * @param allowedKeys 允许字段 + * @return 公开载荷 + */ + private static Map selectPayload(Object payload, String... allowedKeys) { + Map source = copyMap(payload); + Map selected = new LinkedHashMap<>(); + for (String key : allowedKeys) { + if (source.containsKey(key)) { + selected.put(key, source.get(key)); + } + } + return selected; + } + + private static String stringValue(Object payload, String key) { + if (!(payload instanceof Map map)) { + return null; + } + Object value = map.get(key); + return value instanceof String text ? text : null; + } + + private static Object firstNonNull(Object first, Object second) { + return first == null ? second : first; + } + + private static String firstText(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + private static String requireText(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " cannot be blank"); + } + return value; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/output/LegacyAgentRunOutput.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/output/LegacyAgentRunOutput.java new file mode 100644 index 00000000..6449a018 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/output/LegacyAgentRunOutput.java @@ -0,0 +1,80 @@ +package tech.easyflow.agent.runtime.output; + +import com.easyagents.agent.runtime.event.AgentRuntimeEvent; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import tech.easyflow.core.chat.protocol.ChatDomain; +import tech.easyflow.core.chat.protocol.ChatEnvelope; +import tech.easyflow.core.chat.protocol.ChatType; +import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter; + +import java.util.Map; +import java.util.Objects; + +/** + * 保持现有 EasyFlow ChatEnvelope 行为的运行输出。 + */ +public final class LegacyAgentRunOutput implements AgentRunOutput { + + private final ChatSseEmitter delegate; + + /** + * 创建 Legacy 输出。 + */ + public LegacyAgentRunOutput() { + this(new ChatSseEmitter()); + } + + /** + * 使用指定 SSE 发射器创建 Legacy 输出。 + * + * @param delegate SSE 发射器 + */ + public LegacyAgentRunOutput(ChatSseEmitter delegate) { + this.delegate = Objects.requireNonNull(delegate, "delegate cannot be null"); + } + + @Override + public SseEmitter emitter() { + return delegate.getEmitter(); + } + + @Override + public boolean emitRuntimeEvent(AgentRuntimeEvent event) { + return !delegate.isClosed(); + } + + @Override + public boolean emitViewEvent(ChatDomain domain, ChatType type, Object payload) { + ChatEnvelope envelope = new ChatEnvelope<>(); + envelope.setDomain(domain); + envelope.setType(type); + envelope.setPayload(payload); + return delegate.send(envelope); + } + + @Override + public boolean finish(String finalText) { + ChatEnvelope> envelope = new ChatEnvelope<>(); + envelope.setDomain(ChatDomain.SYSTEM); + envelope.setType(ChatType.DONE); + if (finalText != null) { + envelope.setPayload(Map.of("finalText", finalText)); + } + return delegate.sendDone(envelope); + } + + @Override + public void complete() { + delegate.complete(); + } + + @Override + public void completeWithError(Throwable error) { + delegate.completeWithError(error); + } + + @Override + public boolean isClosed() { + return delegate.isClosed(); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/skill/AgentSkillRuntimeCompilation.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/skill/AgentSkillRuntimeCompilation.java new file mode 100644 index 00000000..18a40330 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/skill/AgentSkillRuntimeCompilation.java @@ -0,0 +1,45 @@ +package tech.easyflow.agent.runtime.skill; + +import com.easyagents.agent.runtime.mcp.McpSpec; +import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec; +import com.easyagents.agent.runtime.tool.AgentToolInvoker; +import com.easyagents.agent.runtime.tool.AgentToolSpec; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Agent Skill 运行时编译结果。 + */ +public class AgentSkillRuntimeCompilation { + + private AgentSkillBoxSpec skillBoxSpec; + private List toolSpecs = new ArrayList<>(); + private List mcpSpecs = new ArrayList<>(); + private Map toolInvokers = new LinkedHashMap<>(); + + /** @return SkillBox 声明 */ + public AgentSkillBoxSpec getSkillBoxSpec() { return skillBoxSpec; } + /** @param skillBoxSpec SkillBox 声明 */ + public void setSkillBoxSpec(AgentSkillBoxSpec skillBoxSpec) { this.skillBoxSpec = skillBoxSpec; } + /** @return 静态 Tool 声明 */ + public List getToolSpecs() { return toolSpecs; } + /** @param toolSpecs 静态 Tool 声明 */ + public void setToolSpecs(List toolSpecs) { + this.toolSpecs = toolSpecs == null ? new ArrayList<>() : new ArrayList<>(toolSpecs); + } + /** @return MCP 声明 */ + public List getMcpSpecs() { return mcpSpecs; } + /** @param mcpSpecs MCP 声明 */ + public void setMcpSpecs(List mcpSpecs) { + this.mcpSpecs = mcpSpecs == null ? new ArrayList<>() : new ArrayList<>(mcpSpecs); + } + /** @return Tool 调用器 */ + public Map getToolInvokers() { return toolInvokers; } + /** @param toolInvokers Tool 调用器 */ + public void setToolInvokers(Map toolInvokers) { + this.toolInvokers = toolInvokers == null ? new LinkedHashMap<>() : new LinkedHashMap<>(toolInvokers); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/skill/AgentSkillRuntimeCompiler.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/skill/AgentSkillRuntimeCompiler.java new file mode 100644 index 00000000..b7a5a35e --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/skill/AgentSkillRuntimeCompiler.java @@ -0,0 +1,338 @@ +package tech.easyflow.agent.runtime.skill; + +import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest; +import com.easyagents.agent.runtime.mcp.McpSpec; +import com.easyagents.agent.runtime.mcp.McpToolManifestEntry; +import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec; +import com.easyagents.agent.runtime.skill.AgentSkillSpec; +import com.easyagents.agent.runtime.tool.AgentToolSpec; +import com.easyagents.skill.util.SkillHashes; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.entity.AgentSkillBinding; +import tech.easyflow.agent.entity.AgentToolBinding; +import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompilation; +import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * 将 Agent 内部冻结的 Skill 投影编译为一个 SkillBox 及其延迟激活 Tool。 + */ +@Component +public class AgentSkillRuntimeCompiler { + + private static final TypeReference> MCP_MANIFEST_TYPE = new TypeReference<>() { }; + private static final TypeReference> STRING_MAP_TYPE = new TypeReference<>() { }; + + private final AgentSkillRuntimeProjector runtimeProjector; + private final AgentToolRuntimeCompiler toolRuntimeCompiler; + private final ObjectMapper objectMapper; + + /** + * 创建 Skill 运行时编译器。 + * + * @param runtimeProjector Skill 冻结投影器 + * @param toolRuntimeCompiler 公共 Tool 编译器 + * @param objectMapper JSON 映射器 + */ + public AgentSkillRuntimeCompiler(AgentSkillRuntimeProjector runtimeProjector, + AgentToolRuntimeCompiler toolRuntimeCompiler, + ObjectMapper objectMapper) { + this.runtimeProjector = runtimeProjector; + this.toolRuntimeCompiler = toolRuntimeCompiler; + this.objectMapper = objectMapper; + } + + /** + * 编译 Agent 的全部 Skill。 + * + *

正式 Agent 直接消费冻结投影;草稿试用只有引用时才读取当前已发布 Skill 构建临时投影。

+ * + * @param agent Agent 运行定义 + * @return Skill 运行时编译结果 + */ + public AgentSkillRuntimeCompilation compile(Agent agent) { + AgentSkillRuntimeCompilation result = new AgentSkillRuntimeCompilation(); + List bindings = agent == null ? null : agent.getSkillBindings(); + if (bindings == null || bindings.isEmpty()) { + return result; + } + if (!hasCompleteSnapshots(bindings)) { + throw new BusinessException("Agent Skill 运行快照缺失,请重新保存或发布 Agent"); + } + List effectiveBindings = bindings; + runtimeProjector.assertFrozenBindings(effectiveBindings); + + AgentSkillBoxSpec box = new AgentSkillBoxSpec(); + box.setSkillBoxId("skill-box"); + List skills = new ArrayList<>(); + Map> toolBindings = new LinkedHashMap<>(); + List toolSpecs = new ArrayList<>(); + List mcpSpecs = new ArrayList<>(); + Map invokers = new LinkedHashMap<>(); + Map targetOwners = directTargetOwners(agent); + Set runtimeNames = new HashSet<>(); + + for (AgentSkillBinding binding : effectiveBindings) { + Map snapshot = binding.getResourceSnapshot(); + String skillId = requiredText(snapshot, "skillId", "Skill 运行快照缺少 ID"); + String displayName = firstText(text(snapshot.get("displayName")), text(snapshot.get("name"))); + AgentSkillSpec skillSpec = toSkillSpec(snapshot, skillId, displayName); + skills.add(skillSpec); + + List syntheticBindings = new ArrayList<>(); + Map> mcpSnapshots = new LinkedHashMap<>(); + for (Map item : bindingSnapshots(snapshot)) { + AgentToolBinding synthetic = toSyntheticBinding(skillId, displayName, item); + assertUniqueTarget(targetOwners, synthetic, displayName); + syntheticBindings.add(synthetic); + if ("MCP".equals(synthetic.getToolType())) { + mcpSnapshots.put(synthetic.getTargetId(), item); + } + } + + AgentToolRuntimeCompilation compiled = toolRuntimeCompiler.compileBindings(syntheticBindings); + List ownedNames = new ArrayList<>(); + for (AgentToolSpec spec : compiled.getToolSpecs()) { + assertRuntimeName(runtimeNames, spec.getName()); + attachSkillMetadata(spec, skillId, displayName); + toolSpecs.add(spec); + ownedNames.add(spec.getName()); + } + compiled.getToolInvokers().forEach((name, invoker) -> { + if (invokers.putIfAbsent(name, invoker) != null) { + throw new BusinessException("Agent Skill Tool 运行名冲突:" + name); + } + }); + for (McpSpec spec : compiled.getMcpSpecs()) { + BigInteger targetId = new BigInteger(String.valueOf(spec.getMetadata().get("mcpId"))); + Map item = mcpSnapshots.get(targetId); + configureSkillMcp(spec, item, skillId, displayName, runtimeNames, ownedNames); + mcpSpecs.add(spec); + } + toolBindings.put(skillId, ownedNames); + } + box.setSkills(skills); + box.setToolBindings(toolBindings); + result.setSkillBoxSpec(box); + result.setToolSpecs(toolSpecs); + result.setMcpSpecs(mcpSpecs); + result.setToolInvokers(invokers); + return result; + } + + /** + * 将冻结投影转成 AgentSkillSpec。 + */ + private AgentSkillSpec toSkillSpec(Map snapshot, String skillId, String displayName) { + AgentSkillSpec spec = new AgentSkillSpec(); + spec.setSkillId(skillId); + spec.setName(requiredText(snapshot, "name", "Skill 运行快照缺少名称")); + spec.setDescription(requiredText(snapshot, "description", "Skill 运行快照缺少描述")); + spec.setSkillContent(requiredText(snapshot, "skillContent", "Skill 运行快照缺少指令")); + spec.setSource(requiredText(snapshot, "source", "Skill 运行快照缺少来源")); + Object resources = snapshot.get("resources"); + spec.setResources(resources instanceof Map ? objectMapper.convertValue(resources, STRING_MAP_TYPE) : Map.of()); + spec.getMetadata().put("displayName", displayName); + spec.getMetadata().put("skillSnapshotHash", snapshot.get("skillSnapshotHash")); + spec.getMetadata().put("skillRuntimeSnapshotHash", snapshot.get("skillRuntimeSnapshotHash")); + return spec; + } + + /** + * 构建可复用公共 Tool 编译器的服务端绑定。 + */ + private AgentToolBinding toSyntheticBinding(String skillId, + String displayName, + Map item) { + String type = requiredText(item, "toolType", "Skill Tool 快照缺少类型").toUpperCase(Locale.ROOT); + if (!Set.of("WORKFLOW", "PLUGIN", "MCP").contains(type)) { + throw new BusinessException("Skill Tool 快照类型不支持:" + type); + } + BigInteger targetId = bigInteger(item.get("targetId"), "Skill Tool 快照缺少目标 ID"); + AgentToolBinding binding = new AgentToolBinding(); + binding.setToolType(type); + binding.setTargetId(targetId); + binding.setEnabled(true); + binding.setHitlEnabled(Boolean.TRUE.equals(item.get("hitlEnabled"))); + binding.setSortNo(number(item.get("sortNo"), 0)); + Object resource = item.get("resourceSnapshot"); + if (!(resource instanceof Map)) { + throw new BusinessException("Skill Tool 冻结资源快照缺失:" + displayName); + } + binding.setResourceSnapshot(toStringMap(resource)); + if (!"MCP".equals(type)) { + binding.setToolName("skill_" + safeSegment(skillId) + "_" + + type.toLowerCase(Locale.ROOT) + "_" + targetId); + } + return binding; + } + + /** + * 为 Skill MCP 写入冻结白名单、稳定别名和可信归属。 + */ + private void configureSkillMcp(McpSpec spec, + Map item, + String skillId, + String displayName, + Set runtimeNames, + List ownedNames) { + if (item == null) { + throw new BusinessException("Skill MCP 冻结快照缺失:" + displayName); + } + List manifest = objectMapper.convertValue( + item.get("mcpToolManifest"), MCP_MANIFEST_TYPE); + if (manifest == null || manifest.isEmpty()) { + throw new BusinessException("Skill MCP 冻结 Tool 清单为空:" + displayName); + } + String manifestHash = requiredText(item, "mcpToolManifestHash", "Skill MCP 冻结清单 hash 缺失"); + String mcpId = String.valueOf(item.get("targetId")); + Map aliases = new LinkedHashMap<>(); + manifest.stream().sorted(java.util.Comparator.comparing(McpToolManifestEntry::getName)) + .forEach(entry -> { + String rawName = entry.getName(); + String alias = "skill_" + safeSegment(skillId) + "_mcp_" + safeSegment(mcpId) + + "_" + safeSegment(rawName) + "_" + shortHash(rawName); + assertRuntimeName(runtimeNames, alias); + aliases.put(rawName, alias); + ownedNames.add(alias); + }); + spec.setName("skill_" + safeSegment(skillId) + "_mcp_" + safeSegment(mcpId)); + spec.setGroupName(spec.getName()); + spec.setSkillId(skillId); + spec.setFrozenToolManifest(manifest); + spec.setFrozenToolManifestHash(manifestHash); + spec.setEnableTools(manifest.stream().map(McpToolManifestEntry::getName).toList()); + spec.setToolAliases(aliases); + spec.setToolNamePrefix(null); + spec.getMetadata().put("skillId", skillId); + spec.getMetadata().put("skillDisplayName", displayName); + attachApprovalMetadata(spec.getApprovalRequest(), skillId, displayName); + } + + private void attachSkillMetadata(AgentToolSpec spec, String skillId, String displayName) { + spec.getMetadata().put("skillId", skillId); + spec.getMetadata().put("skillDisplayName", displayName); + attachApprovalMetadata(spec.getApprovalRequest(), skillId, displayName); + } + + private void attachApprovalMetadata(AgentToolApprovalRequest request, String skillId, String displayName) { + if (request == null) { + return; + } + request.getMetadata().put("skillId", skillId); + request.getMetadata().put("skillDisplayName", displayName); + } + + private Map directTargetOwners(Agent agent) { + Map owners = new HashMap<>(); + if (agent == null || agent.getToolBindings() == null) { + return owners; + } + for (AgentToolBinding binding : agent.getToolBindings()) { + if (binding == null || !Boolean.TRUE.equals(binding.getEnabled()) || binding.getTargetId() == null) { + continue; + } + owners.put(binding.getToolType().toUpperCase(Locale.ROOT) + ":" + binding.getTargetId(), "Agent 直接工具"); + } + return owners; + } + + private void assertUniqueTarget(Map owners, + AgentToolBinding binding, + String displayName) { + String key = binding.getToolType().toUpperCase(Locale.ROOT) + ":" + binding.getTargetId(); + String existing = owners.putIfAbsent(key, displayName); + if (existing != null) { + throw new BusinessException("工具资源重复:" + existing + " 与 " + displayName + " 引用了 " + key); + } + } + + private List> bindingSnapshots(Map snapshot) { + Object value = snapshot.get("toolBindings"); + if (!(value instanceof List list)) { + return List.of(); + } + List> result = new ArrayList<>(); + for (Object item : list) { + if (!(item instanceof Map)) { + throw new BusinessException("Skill Tool 运行快照格式错误"); + } + result.add(toStringMap(item)); + } + return result; + } + + private boolean hasCompleteSnapshots(List bindings) { + return bindings.stream().allMatch(binding -> binding != null + && binding.getResourceSnapshot() != null + && !binding.getResourceSnapshot().isEmpty()); + } + + private void assertRuntimeName(Set names, String name) { + if (name == null || name.isBlank() || !names.add(name)) { + throw new BusinessException("Agent Skill Tool 运行名冲突:" + name); + } + } + + private Map toStringMap(Object value) { + Map result = new LinkedHashMap<>(); + ((Map) value).forEach((key, item) -> result.put(String.valueOf(key), item)); + return result; + } + + private String requiredText(Map source, String key, String message) { + String value = text(source.get(key)); + if (value == null || value.isBlank()) { + throw new BusinessException(message); + } + return value; + } + + private String text(Object value) { return value == null ? null : String.valueOf(value); } + + private String firstText(String first, String second) { + return first == null || first.isBlank() ? second : first; + } + + private BigInteger bigInteger(Object value, String message) { + if (value == null) { + throw new BusinessException(message); + } + try { + return new BigInteger(String.valueOf(value)); + } catch (NumberFormatException exception) { + throw new BusinessException(message); + } + } + + private Integer number(Object value, int fallback) { + return value instanceof Number number ? number.intValue() : fallback; + } + + private String safeSegment(String value) { + String normalized = String.valueOf(value == null ? "" : value).trim() + .replaceAll("[^A-Za-z0-9_-]", "_").replaceAll("_+", "_"); + if (normalized.length() > 28) { + normalized = normalized.substring(0, 28); + } + return normalized.isBlank() ? "tool" : normalized; + } + + private String shortHash(String value) { + return SkillHashes.sha256Hex(String.valueOf(value).getBytes(StandardCharsets.UTF_8)).substring(0, 8); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/skill/AgentSkillRuntimeProjector.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/skill/AgentSkillRuntimeProjector.java new file mode 100644 index 00000000..e19b2741 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/skill/AgentSkillRuntimeProjector.java @@ -0,0 +1,404 @@ +package tech.easyflow.agent.runtime.skill; + +import com.easyagents.skill.util.SkillHashes; +import com.easyagents.skill.util.SkillPaths; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.entity.AgentSkillBinding; +import tech.easyflow.agent.service.AgentDependencyAccessService; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.service.SkillService; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/** + * 将已发布 Skill 内容与平台 Tool 快照投影为 Agent 内部运行快照。 + */ +@Component +public class AgentSkillRuntimeProjector { + + /** 单个 Agent 的 Skill 原始文本投影上限。 */ + public static final long MAX_TEXT_BYTES = 8L * 1024L * 1024L; + /** 单个 Agent 的 Skill 绑定数量上限。 */ + public static final int MAX_SKILL_COUNT = 20; + + private final AgentDependencyAccessService dependencyAccessService; + private final SkillService skillService; + private final ObjectMapper objectMapper; + + /** + * 创建 Agent Skill 运行投影器。 + * + * @param dependencyAccessService Agent 依赖权限服务 + * @param skillService Skill 服务 + * @param objectMapper JSON 映射器 + */ + public AgentSkillRuntimeProjector(AgentDependencyAccessService dependencyAccessService, + SkillService skillService, + ObjectMapper objectMapper) { + this.dependencyAccessService = dependencyAccessService; + this.skillService = skillService; + this.objectMapper = objectMapper; + } + + /** + * 校验当前绑定并构建 Agent 发布用冻结 Skill 运行投影。 + * + * @param agent Agent + * @param bindings Skill 草稿绑定 + * @return 带内部运行快照及脱敏摘要的绑定副本 + */ + public List projectCurrentBindings(Agent agent, + List bindings) { + if (bindings == null || bindings.isEmpty()) { + return List.of(); + } + if (bindings.size() > MAX_SKILL_COUNT) { + throw new BusinessException(409, 4092, "单个 Agent 最多可绑定 20 个 Skill"); + } + Map skills = loadSkillsInStableLockOrder(agent, bindings); + Set unique = new HashSet<>(); + List projected = new ArrayList<>(); + long totalBytes = 0L; + for (AgentSkillBinding binding : bindings) { + if (binding == null || binding.getSkillId() == null || !unique.add(binding.getSkillId())) { + throw new BusinessException(409, 4092, "同一 Skill 不能重复绑定"); + } + Skill skill = skills.get(binding.getSkillId()); + Projection projection = project(skill); + totalBytes = Math.addExact(totalBytes, projection.textBytes()); + if (totalBytes > MAX_TEXT_BYTES) { + throw new BusinessException(409, 4092, + "Agent Skill 文本投影超过 8 MiB,请减少绑定或精简文本资源"); + } + AgentSkillBinding copy = copyBinding(binding); + copy.setResourceSnapshot(projection.runtimeSnapshot()); + copy.setResourceSummary(projection.summary()); + projected.add(copy); + } + return projected; + } + + /** + * 为详情页构建单个当前 Skill 的脱敏摘要。 + * + * @param skill 已发布 Skill + * @param publishedRuntimeHash Agent 线上冻结的组合 hash,可为空 + * @return 脱敏摘要 + */ + public Map currentSummary(Skill skill, String publishedRuntimeHash) { + Projection projection = project(skill); + Map summary = new LinkedHashMap<>(projection.summary()); + summary.put("hasUpdate", publishedRuntimeHash != null + && !publishedRuntimeHash.equals(summary.get("skillRuntimeSnapshotHash"))); + return summary; + } + + /** + * 校验 Agent 快照中已经冻结的 Skill 文本投影。 + * + * @param bindings 冻结绑定 + */ + public void assertFrozenBindings(List bindings) { + if (bindings == null || bindings.isEmpty()) { + return; + } + if (bindings.size() > MAX_SKILL_COUNT) { + throw new BusinessException("Agent 发布快照中的 Skill 数量超过 20 个"); + } + long totalBytes = 0L; + Set ids = new HashSet<>(); + for (AgentSkillBinding binding : bindings) { + Map snapshot = binding == null ? null : binding.getResourceSnapshot(); + if (snapshot == null || snapshot.isEmpty()) { + throw new BusinessException("Agent Skill 运行快照为空"); + } + String skillId = text(snapshot.get("skillId")); + if (skillId == null || !ids.add(skillId)) { + throw new BusinessException("Agent Skill 运行快照包含重复或空 Skill ID"); + } + String declaredHash = text(snapshot.get("skillRuntimeSnapshotHash")); + Map canonical = new LinkedHashMap<>(snapshot); + canonical.remove("skillRuntimeSnapshotHash"); + if (declaredHash == null || !declaredHash.equals(hash(canonical))) { + throw new BusinessException("Agent Skill 运行快照 hash 校验失败:" + skillId); + } + totalBytes = Math.addExact(totalBytes, frozenTextBytes(snapshot)); + if (totalBytes > MAX_TEXT_BYTES) { + throw new BusinessException("Agent 发布快照中的 Skill 文本投影超过 8 MiB"); + } + } + } + + /** + * 按 Skill ID 锁顺序加载并复核权限,降低并发死锁概率。 + * + * @param agent Agent + * @param bindings Skill 绑定 + * @return Skill ID 到实体的映射 + */ + private Map loadSkillsInStableLockOrder(Agent agent, + List bindings) { + List ids = bindings.stream() + .filter(binding -> binding != null && binding.getSkillId() != null) + .map(AgentSkillBinding::getSkillId) + .distinct() + .sorted() + .toList(); + Map result = new LinkedHashMap<>(); + for (BigInteger id : ids) { + result.put(id, dependencyAccessService.requireSkill(agent, id)); + } + return result; + } + + /** + * 构建单个 Skill 运行投影。 + * + * @param skill 已发布 Skill + * @return 运行投影与摘要 + */ + private Projection project(Skill skill) { + Map content = skill.getPublishedSnapshotJson(); + skillService.assertPublishedAggregateHash(skill); + String skillContent = text(content.get("skillContent")); + Map textResources = new TreeMap<>(); + int binaryCount = 0; + long textBytes = utf8Length(skillContent); + Set paths = new HashSet<>(); + Object rawResources = content.get("resources"); + if (rawResources instanceof List resources) { + for (Object raw : resources) { + if (!(raw instanceof Map item)) { + throw new BusinessException("Skill 发布快照资源格式错误:" + skill.getName()); + } + String path = normalizePath(text(item.get("path"))); + if (!paths.add(path.toLowerCase(java.util.Locale.ROOT))) { + throw new BusinessException("Skill 发布快照资源路径重复:" + path); + } + if (!Boolean.TRUE.equals(item.get("text"))) { + binaryCount++; + continue; + } + String value = text(item.get("textContent")); + if (value == null) { + throw new BusinessException("Skill 文本资源正文缺失:" + path); + } + textResources.put(path, value); + textBytes = Math.addExact(textBytes, utf8Length(value)); + } + } + Map toolSnapshot = skill.getPublishedToolBindingsJson() == null + ? Map.of() : skill.getPublishedToolBindingsJson(); + String contentHash = text(content.get("snapshotHash")); + String toolHash = text(toolSnapshot.get("snapshotHash")); + // 新版发布快照冻结展示字段;历史快照显式回退当前行以保持兼容。 + String displayName = firstText(text(content.get("displayName")), + firstText(skill.getDisplayName(), skill.getName())); + String visibilityScope = firstText(text(content.get("visibilityScope")), + skill.getVisibilityScope()); + + Map runtime = new LinkedHashMap<>(); + runtime.put("schemaVersion", 1); + runtime.put("skillId", skill.getId().toString()); + runtime.put("name", content.get("name")); + runtime.put("displayName", displayName); + runtime.put("description", content.get("description")); + runtime.put("skillContent", skillContent); + runtime.put("packageHash", content.get("packageHash")); + runtime.put("skillSnapshotHash", contentHash); + runtime.put("toolBindingsHash", toolHash == null ? "" : toolHash); + runtime.put("resources", textResources); + runtime.put("toolBindings", toolBindings(toolSnapshot)); + runtime.put("source", "easyflow://skill/" + skill.getId()); + String runtimeHash = hash(runtime); + runtime.put("skillRuntimeSnapshotHash", runtimeHash); + + Map summary = new LinkedHashMap<>(); + summary.put("skillId", skill.getId()); + summary.put("displayName", displayName); + summary.put("description", content.get("description")); + summary.put("visibilityScope", visibilityScope); + summary.put("skillSnapshotHash", contentHash); + summary.put("toolBindingsHash", toolHash == null ? "" : toolHash); + summary.put("skillRuntimeSnapshotHash", runtimeHash); + summary.put("textBytes", textBytes); + summary.put("textResourceCount", textResources.size()); + summary.put("binaryExcludedCount", binaryCount); + summary.put("toolCount", toolCount(toolSnapshot)); + return new Projection(runtime, summary, textBytes); + } + + /** + * 从平台 Tool 快照提取冻结绑定数组。 + * + * @param toolSnapshot 平台 Tool 快照 + * @return Tool 绑定数组 + */ + private List toolBindings(Map toolSnapshot) { + Object value = toolSnapshot.get("bindings"); + return value instanceof List list ? list : List.of(); + } + + /** + * 汇总实际 Tool 数。 + * + * @param toolSnapshot 平台 Tool 快照 + * @return Tool 数量 + */ + private int toolCount(Map toolSnapshot) { + int count = 0; + for (Object raw : toolBindings(toolSnapshot)) { + if (raw instanceof Map item && item.get("toolCount") instanceof Number number) { + count += number.intValue(); + } + } + return count; + } + + /** + * 计算冻结快照文本字节数。 + * + * @param snapshot Skill 运行快照 + * @return UTF-8 字节数 + */ + private long frozenTextBytes(Map snapshot) { + long total = utf8Length(text(snapshot.get("skillContent"))); + Object resources = snapshot.get("resources"); + if (resources instanceof Map map) { + for (Object value : map.values()) { + total = Math.addExact(total, utf8Length(text(value))); + } + } + return total; + } + + /** + * 创建无内部快照副作用的绑定副本。 + * + * @param source 原绑定 + * @return 绑定副本 + */ + private AgentSkillBinding copyBinding(AgentSkillBinding source) { + AgentSkillBinding copy = new AgentSkillBinding(); + copy.setId(source.getId()); + copy.setTenantId(source.getTenantId()); + copy.setAgentId(source.getAgentId()); + copy.setSkillId(source.getSkillId()); + copy.setSortNo(source.getSortNo()); + copy.setCreated(source.getCreated()); + copy.setCreatedBy(source.getCreatedBy()); + copy.setModified(source.getModified()); + copy.setModifiedBy(source.getModifiedBy()); + return copy; + } + + /** + * 计算内容与 Tool 的组合运行 hash。 + * + * @param contentHash 内容快照 hash + * @param toolHash Tool 快照 hash + * @return 组合 SHA-256 + */ + private String hash(Map value) { + try { + return SkillHashes.sha256Hex(objectMapper.writeValueAsBytes(canonicalizeJson(value))); + } catch (JsonProcessingException exception) { + throw new BusinessException("Agent Skill 运行快照序列化失败"); + } + } + + /** + * 将运行快照转换为稳定 JSON 结构,确保发布前 POJO 与落库后的 Map 产生相同 hash。 + * + * @param value 原始快照值 + * @return 按键排序且仅包含 JSON 基础类型的值 + */ + private Object canonicalizeJson(Object value) { + if (value instanceof Map map) { + Map sorted = new TreeMap<>(); + map.forEach((key, item) -> sorted.put(String.valueOf(key), canonicalizeJson(item))); + return sorted; + } + if (value instanceof List list) { + return list.stream().map(this::canonicalizeJson).toList(); + } + if (value == null || value instanceof String || value instanceof Number + || value instanceof Boolean) { + return value; + } + return canonicalizeJson(objectMapper.convertValue(value, Object.class)); + } + + /** + * 规范资源路径。 + * + * @param path 原始路径 + * @return 规范路径 + */ + private String normalizePath(String path) { + if (path == null || path.isBlank()) { + throw new BusinessException("Skill 发布快照资源路径不能为空"); + } + try { + return SkillPaths.normalize(path); + } catch (RuntimeException exception) { + throw new BusinessException("Skill 发布快照资源路径不合法:" + path); + } + } + + /** + * 读取文本。 + * + * @param value 原值 + * @return 文本或 null + */ + private String text(Object value) { + return value == null ? null : String.valueOf(value); + } + + /** + * 获取首个非空文本。 + * + * @param first 首选值 + * @param second 备选值 + * @return 非空文本 + */ + private String firstText(String first, String second) { + return first == null || first.isBlank() ? second : first; + } + + /** + * 计算 UTF-8 字节数。 + * + * @param value 文本 + * @return 字节数 + */ + private long utf8Length(String value) { + return (value == null ? "" : value).getBytes(StandardCharsets.UTF_8).length; + } + + /** + * 单个 Skill 的运行投影结果。 + * + * @param runtimeSnapshot 内部运行快照 + * @param summary 脱敏摘要 + * @param textBytes 文本 UTF-8 字节数 + */ + private record Projection(Map runtimeSnapshot, + Map summary, + long textBytes) { + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/tool/AgentToolRuntimeCompiler.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/tool/AgentToolRuntimeCompiler.java index 4d392b4f..56972c6a 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/tool/AgentToolRuntimeCompiler.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/tool/AgentToolRuntimeCompiler.java @@ -9,6 +9,8 @@ import com.easyagents.agent.runtime.tool.asynctool.AsyncToolSpecExpander; import com.easyagents.core.model.chat.tool.Parameter; import com.easyagents.core.model.chat.tool.Tool; import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.stereotype.Component; import tech.easyflow.agent.entity.Agent; @@ -19,10 +21,12 @@ import tech.easyflow.agent.runtime.asynctool.PluginAsyncSubTools; import tech.easyflow.agent.runtime.asynctool.WorkflowAsyncSubTools; import tech.easyflow.ai.easyagents.tool.ChatToolNameHelper; import tech.easyflow.ai.entity.Mcp; +import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.PluginItem; import tech.easyflow.ai.entity.Workflow; import tech.easyflow.ai.service.McpService; import tech.easyflow.ai.service.PluginItemService; +import tech.easyflow.ai.service.PluginService; import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.common.web.exceptions.BusinessException; @@ -39,6 +43,8 @@ import java.util.regex.Pattern; @Component public class AgentToolRuntimeCompiler { + private static final Logger LOG = LoggerFactory.getLogger(AgentToolRuntimeCompiler.class); + private static final String TOOL_FAILURE_MESSAGE = "工具执行失败,请稍后重试"; private static final Pattern MCP_INPUT_PATTERN = Pattern.compile("\\$\\{input:([A-Za-z0-9_.-]+)}"); private static final Pattern ASYNC_SAFE_NAME = Pattern.compile("^[a-z][a-z0-9_]*$"); @@ -47,6 +53,8 @@ public class AgentToolRuntimeCompiler { @Resource private PluginItemService pluginItemService; @Resource + private PluginService pluginService; + @Resource private McpService mcpService; @Resource private ObjectMapper objectMapper; @@ -66,8 +74,21 @@ public class AgentToolRuntimeCompiler { * @return 工具编译结果 */ public AgentToolRuntimeCompilation compile(Agent agent) { + return compileBindings(agent == null ? null : agent.getToolBindings()); + } + + /** + * 编译一组服务端已规范化的工具绑定。 + * + *

Agent 直接工具和 Skill 冻结工具共用该入口,避免 Workflow、Plugin、MCP + * 的快照解析、调用器与 HITL 规则形成两套实现。

+ * + * @param bindings 工具绑定 + * @return 工具编译结果 + */ + public AgentToolRuntimeCompilation compileBindings(List bindings) { AgentToolRuntimeCompilation compilation = new AgentToolRuntimeCompilation(); - if (agent == null || agent.getToolBindings() == null) { + if (bindings == null) { return compilation; } List specs = new ArrayList<>(); @@ -76,7 +97,7 @@ public class AgentToolRuntimeCompiler { Map mcpSpecMap = new LinkedHashMap<>(); Set compiledToolNames = new LinkedHashSet<>(); AsyncToolSpecExpander asyncExpander = new AsyncToolSpecExpander(); - for (AgentToolBinding binding : agent.getToolBindings()) { + for (AgentToolBinding binding : bindings) { if (!Boolean.TRUE.equals(binding.getEnabled())) { continue; } @@ -139,16 +160,17 @@ public class AgentToolRuntimeCompiler { Workflow workflow = requireWorkflow(binding); Tool tool = workflowToolExecutor.buildTool(workflow); AgentToolSpec spec = toToolSpec(tool, binding); - AgentToolInvoker invoker = (arguments, context) -> invokeSafely(spec.getName(), + AgentToolInvoker invoker = (arguments, context) -> invokeSafely(spec.getName(), binding, context, () -> workflowToolExecutor.execute(workflow, arguments).getResult()); return new CompiledSyncTool(spec, invoker); } if (type == AgentToolType.PLUGIN) { - PluginItem pluginItem = requirePlugin(binding); - Tool tool = pluginToolExecutor.buildTool(pluginItem); + PluginRuntimeResource plugin = requirePlugin(binding); + PluginItem pluginItem = plugin.pluginItem(); + Tool tool = pluginToolExecutor.buildTool(pluginItem, plugin.plugin()); AgentToolSpec spec = toToolSpec(tool, binding); - AgentToolInvoker invoker = (arguments, context) -> invokeSafely(spec.getName(), - () -> pluginToolExecutor.execute(pluginItem, arguments).getResult()); + AgentToolInvoker invoker = (arguments, context) -> invokeSafely(spec.getName(), binding, context, + () -> pluginToolExecutor.execute(pluginItem, plugin.plugin(), arguments).getResult()); return new CompiledSyncTool(spec, invoker); } throw new BusinessException("不支持的 Agent 工具类型:" + type.name()); @@ -166,12 +188,13 @@ public class AgentToolRuntimeCompiler { return spec; } if (type == AgentToolType.PLUGIN) { - PluginItem pluginItem = requirePlugin(binding); - Tool tool = pluginToolExecutor.buildTool(pluginItem); + PluginRuntimeResource plugin = requirePlugin(binding); + PluginItem pluginItem = plugin.pluginItem(); + Tool tool = pluginToolExecutor.buildTool(pluginItem, plugin.plugin()); String asyncName = asyncToolName(tool, binding, "plugin"); String toolDisplayName = displayName(tool, pluginItem.getName()); AsyncToolSpec spec = baseAsyncSpec(asyncName, tool, binding, toolDisplayName); - spec.setSubTools(new PluginAsyncSubTools(pluginItem, asyncName, toolDisplayName, + spec.setSubTools(new PluginAsyncSubTools(pluginItem, plugin.plugin(), asyncName, toolDisplayName, pluginToolExecutor, asyncToolTaskStore, agentAsyncToolExecutor)); return spec; } @@ -195,12 +218,27 @@ public class AgentToolRuntimeCompiler { return spec; } - private AgentToolResult invokeSafely(String toolName, ToolCall call) { + private AgentToolResult invokeSafely(String toolName, + AgentToolBinding binding, + AgentToolContext context, + ToolCall call) { try { Object result = call.invoke(); return AgentToolResult.success(result == null ? "" : String.valueOf(result)); } catch (Exception e) { - return AgentToolResult.failure(e.getMessage() == null ? "工具执行失败" : e.getMessage()); + LOG.error("Agent Tool execution failed: toolName={}, toolType={}, targetId={}, bindingId={}, " + + "agentId={}, sessionId={}, requestId={}, traceId={}, toolCallId={}", + toolName, + binding == null ? null : binding.getToolType(), + binding == null ? null : binding.getTargetId(), + binding == null ? null : binding.getId(), + context == null ? null : context.getAgentId(), + context == null ? null : context.getSessionId(), + context == null ? null : context.getRequestId(), + context == null ? null : context.getTraceId(), + context == null ? null : context.getToolCallId(), + e); + return AgentToolResult.failure(TOOL_FAILURE_MESSAGE); } } @@ -217,12 +255,12 @@ public class AgentToolRuntimeCompiler { return workflow; } - private PluginItem requirePlugin(AgentToolBinding binding) { - PluginItem pluginItem = snapshotOrCurrentPlugin(binding); - if (pluginItem == null) { + private PluginRuntimeResource requirePlugin(AgentToolBinding binding) { + PluginRuntimeResource plugin = snapshotOrCurrentPlugin(binding); + if (plugin == null || plugin.pluginItem() == null || plugin.plugin() == null) { throw new BusinessException("绑定插件不存在"); } - return pluginItem; + return plugin; } private AgentToolSpec toToolSpec(Tool tool, AgentToolBinding binding) { @@ -320,13 +358,20 @@ public class AgentToolRuntimeCompiler { return workflowService.getPublishedById(binding.getTargetId()); } - private PluginItem snapshotOrCurrentPlugin(AgentToolBinding binding) { + private PluginRuntimeResource snapshotOrCurrentPlugin(AgentToolBinding binding) { if (binding.getResourceSnapshot() != null && !binding.getResourceSnapshot().isEmpty()) { - PluginItem pluginItem = objectMapper.convertValue(binding.getResourceSnapshot(), PluginItem.class); + Map snapshot = binding.getResourceSnapshot(); + Object itemValue = snapshot.containsKey("pluginItem") ? snapshot.get("pluginItem") : snapshot; + PluginItem pluginItem = objectMapper.convertValue(itemValue, PluginItem.class); pluginItem.setId(firstNonNull(pluginItem.getId(), binding.getTargetId())); - return pluginItem; + Plugin plugin = snapshot.get("plugin") == null + ? pluginService.getById(pluginItem.getPluginId()) + : objectMapper.convertValue(snapshot.get("plugin"), Plugin.class); + return new PluginRuntimeResource(pluginItem, plugin); } - return pluginItemService.getById(binding.getTargetId()); + PluginItem pluginItem = pluginItemService.getById(binding.getTargetId()); + Plugin plugin = pluginItem == null ? null : pluginService.getById(pluginItem.getPluginId()); + return pluginItem == null ? null : new PluginRuntimeResource(pluginItem, plugin); } private Mcp snapshotOrCurrentMcp(AgentToolBinding binding) { @@ -615,6 +660,10 @@ public class AgentToolRuntimeCompiler { private record CompiledSyncTool(AgentToolSpec spec, AgentToolInvoker invoker) { } + /** 冻结插件工具与父插件调用配置。 */ + private record PluginRuntimeResource(PluginItem pluginItem, Plugin plugin) { + } + private interface ToolCall { /** diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/tool/PluginToolExecutor.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/tool/PluginToolExecutor.java index 34ec06fa..07f72c52 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/tool/PluginToolExecutor.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/tool/PluginToolExecutor.java @@ -2,6 +2,7 @@ package tech.easyflow.agent.runtime.tool; import com.easyagents.core.model.chat.tool.Tool; import org.springframework.stereotype.Service; +import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.PluginItem; import java.util.Map; @@ -22,6 +23,17 @@ public class PluginToolExecutor { return pluginItem.toFunction(); } + /** + * 使用冻结的父插件配置构建工具声明和执行对象。 + * + * @param pluginItem 插件工具快照 + * @param plugin 父插件调用配置快照 + * @return 工具声明来源 + */ + public Tool buildTool(PluginItem pluginItem, Plugin plugin) { + return pluginItem.toFunction(plugin); + } + /** * 执行 Plugin 工具。 * @@ -33,4 +45,19 @@ public class PluginToolExecutor { Object result = buildTool(pluginItem).invoke(arguments == null ? Map.of() : arguments); return new AgentToolExecutionResult(result, null); } + + /** + * 使用冻结父插件配置执行插件工具。 + * + * @param pluginItem 插件工具快照 + * @param plugin 父插件调用配置快照 + * @param arguments 调用参数 + * @return 执行结果 + */ + public AgentToolExecutionResult execute(PluginItem pluginItem, + Plugin plugin, + Map arguments) { + Object result = buildTool(pluginItem, plugin).invoke(arguments == null ? Map.of() : arguments); + return new AgentToolExecutionResult(result, null); + } } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/tool/WorkflowToolExecutor.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/tool/WorkflowToolExecutor.java index 40af284a..ee6df0e9 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/tool/WorkflowToolExecutor.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/tool/WorkflowToolExecutor.java @@ -2,8 +2,10 @@ package tech.easyflow.agent.runtime.tool; import com.easyagents.flow.core.chain.runtime.ChainExecutor; import com.easyagents.core.model.chat.tool.Tool; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import tech.easyflow.ai.easyagents.tool.WorkflowTool; +import tech.easyflow.ai.easyagentsflow.repository.FrozenWorkflowDefinitionRegistry; import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds; import tech.easyflow.ai.entity.Workflow; @@ -16,14 +18,27 @@ import java.util.Map; public class WorkflowToolExecutor { private final ChainExecutor chainExecutor; + private final FrozenWorkflowDefinitionRegistry frozenDefinitionRegistry; /** * 创建 Workflow 工具执行器。 * * @param chainExecutor 工作流执行器 */ - public WorkflowToolExecutor(ChainExecutor chainExecutor) { + @Autowired + public WorkflowToolExecutor(ChainExecutor chainExecutor, + FrozenWorkflowDefinitionRegistry frozenDefinitionRegistry) { this.chainExecutor = chainExecutor; + this.frozenDefinitionRegistry = frozenDefinitionRegistry; + } + + /** + * 创建仅供测试替身继承的执行器。 + * + * @param chainExecutor 工作流执行器 + */ + protected WorkflowToolExecutor(ChainExecutor chainExecutor) { + this(chainExecutor, null); } /** @@ -44,11 +59,16 @@ public class WorkflowToolExecutor { * @return 执行结果 */ public AgentToolExecutionResult execute(Workflow workflow, Map arguments) { - Object result = chainExecutor.execute(definitionId(workflow), arguments == null ? Map.of() : arguments); + Object result = chainExecutor.executeWithoutSuspension( + definitionId(workflow), arguments == null ? Map.of() : arguments); return new AgentToolExecutionResult(result, resolveBusinessExecutionId(result)); } private String definitionId(Workflow workflow) { + if (frozenDefinitionRegistry != null && workflow != null + && workflow.getContent() != null && !workflow.getContent().isBlank()) { + return frozenDefinitionRegistry.register(workflow); + } return PublishedWorkflowDefinitionIds.published(String.valueOf(workflow == null ? null : workflow.getId())); } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/workspace/AgentWorkspaceCleanupService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/workspace/AgentWorkspaceCleanupService.java new file mode 100644 index 00000000..4ff9cbc4 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/workspace/AgentWorkspaceCleanupService.java @@ -0,0 +1,151 @@ +package tech.easyflow.agent.runtime.workspace; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import tech.easyflow.agent.config.AgentWorkspaceProperties; +import tech.easyflow.agent.runtime.AgentRunRegistry; +import tech.easyflow.agent.runtime.lock.AgentRunLock; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.math.BigInteger; +import java.time.Instant; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 清理过期且没有活动运行保护的本机会话工作区。 + */ +@Component +public class AgentWorkspaceCleanupService { + + private static final Logger LOG = LoggerFactory.getLogger(AgentWorkspaceCleanupService.class); + private static final int MAX_DELETIONS_PER_RUN = 50; + + private final AgentWorkspaceResolver resolver; + private final AgentWorkspaceProperties properties; + private final AgentRunRegistry runRegistry; + private final AgentRunLock agentRunLock; + + /** + * 创建工作区清理服务。 + * + * @param resolver 工作区解析器 + * @param properties 工作区配置 + * @param runRegistry 活动运行注册表 + * @param agentRunLock 会话级分布式运行锁 + */ + public AgentWorkspaceCleanupService(AgentWorkspaceResolver resolver, + AgentWorkspaceProperties properties, + AgentRunRegistry runRegistry, + AgentRunLock agentRunLock) { + this.resolver = resolver; + this.properties = properties; + this.runRegistry = runRegistry; + this.agentRunLock = agentRunLock; + } + + /** + * 按固定深度扫描会话目录并执行有界清理。 + */ + @Scheduled(fixedDelayString = "${easyflow.agent.workspace.cleanup-interval:30m}") + public void cleanup() { + Instant threshold = Instant.now().minus(properties.getRetention()); + AtomicInteger deleted = new AtomicInteger(); + try (var tenants = Files.list(resolver.getRealRoot())) { + tenants.filter(this::businessDirectory).forEach(tenant -> scanAgents(tenant, threshold, deleted)); + } catch (IOException error) { + LOG.error("Scan Agent workspace root failed", error); + } + } + + private void scanAgents(Path tenant, Instant threshold, AtomicInteger deleted) { + if (deleted.get() >= MAX_DELETIONS_PER_RUN) { + return; + } + try (var agents = Files.list(tenant)) { + agents.filter(this::businessDirectory).forEach(agent -> scanSessions(agent, threshold, deleted)); + } catch (IOException error) { + LOG.error("Scan Agent workspace tenant directory failed", error); + } + } + + private void scanSessions(Path agent, Instant threshold, AtomicInteger deleted) { + if (deleted.get() >= MAX_DELETIONS_PER_RUN) { + return; + } + try (var sessions = Files.list(agent)) { + sessions.filter(this::businessDirectory).forEach(session -> { + if (deleted.get() >= MAX_DELETIONS_PER_RUN || runRegistry.hasActiveSession(session.getFileName().toString())) { + return; + } + AgentRunLock.Handle lockHandle = tryAcquireSessionLock(agent, session); + if (lockHandle == null) { + return; + } + try (lockHandle) { + if (runRegistry.hasActiveSession(session.getFileName().toString())) { + return; + } + Path activity = resolver.activityFile(session); + Instant lastActive = Files.exists(activity) + ? Files.getLastModifiedTime(activity).toInstant() + : Files.getLastModifiedTime(session).toInstant(); + if (lastActive.isAfter(threshold)) { + return; + } + if (runRegistry.hasActiveSession(session.getFileName().toString())) { + return; + } + deleteTree(session); + Files.deleteIfExists(activity); + deleted.incrementAndGet(); + } catch (IOException error) { + LOG.error("Clean expired Agent workspace failed", error); + } + }); + } catch (IOException error) { + LOG.error("Scan Agent workspace session directory failed", error); + } + } + + private AgentRunLock.Handle tryAcquireSessionLock(Path agent, Path session) { + try { + return agentRunLock.tryAcquire( + new BigInteger(agent.getFileName().toString()), session.getFileName().toString()); + } catch (RuntimeException error) { + LOG.error("Acquire Agent workspace cleanup lock failed", error); + return null; + } + } + + private boolean businessDirectory(Path path) { + return Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS) + && !Files.isSymbolicLink(path) + && !path.equals(resolver.getActivityRoot()); + } + + private void deleteTree(Path root) throws IOException { + Files.walkFileTree(root, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path directory, IOException error) throws IOException { + if (error != null) { + throw error; + } + Files.delete(directory); + return FileVisitResult.CONTINUE; + } + }); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/workspace/AgentWorkspaceResolver.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/workspace/AgentWorkspaceResolver.java new file mode 100644 index 00000000..2be8dc4e --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/workspace/AgentWorkspaceResolver.java @@ -0,0 +1,227 @@ +package tech.easyflow.agent.runtime.workspace; + +import jakarta.annotation.PostConstruct; +import org.springframework.stereotype.Component; +import tech.easyflow.agent.config.AgentWorkspaceProperties; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.io.IOException; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.util.HexFormat; +import java.util.regex.Pattern; + +/** + * 按租户、Agent 和运行会话分配本地工作区,并维护服务端活动标记。 + */ +@Component +public class AgentWorkspaceResolver { + + private static final Pattern SESSION_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,127}"); + private static final String ACTIVITY_DIRECTORY = ".easyflow-activity"; + + private final AgentWorkspaceProperties properties; + private Path realRoot; + private Path activityRoot; + + /** + * 创建工作区解析器。 + * + * @param properties 工作区配置 + */ + public AgentWorkspaceResolver(AgentWorkspaceProperties properties) { + this.properties = properties; + } + + /** + * 初始化并校验工作区根目录。 + */ + @PostConstruct + public void initialize() { + try { + Path configured = Path.of(properties.getRoot()).toAbsolutePath().normalize(); + Files.createDirectories(configured); + if (Files.isSymbolicLink(configured)) { + throw new IllegalStateException("Agent 工作区根目录不能是符号链接"); + } + realRoot = configured.toRealPath(LinkOption.NOFOLLOW_LINKS); + activityRoot = realRoot.resolve(ACTIVITY_DIRECTORY); + if (Files.exists(activityRoot, LinkOption.NOFOLLOW_LINKS) && Files.isSymbolicLink(activityRoot)) { + throw new IllegalStateException("Agent 工作区活动目录不能是符号链接"); + } + Files.createDirectories(activityRoot); + if (Files.isSymbolicLink(activityRoot)) { + throw new IllegalStateException("Agent 工作区活动目录不能是符号链接"); + } + } catch (IOException error) { + throw new IllegalStateException("初始化 Agent 工作区根目录失败", error); + } + } + + /** + * 解析并创建一个隔离的会话工作区。 + * + * @param tenantId 租户 ID + * @param agentId Agent ID + * @param runtimeSessionId Runtime 会话 ID + * @return 经过真实路径校验的绝对工作区 + */ + public Path resolve(BigInteger tenantId, BigInteger agentId, String runtimeSessionId) { + if (tenantId == null || tenantId.signum() <= 0 || agentId == null || agentId.signum() <= 0) { + throw new BusinessException("Agent 工作区租户和 Agent 标识不完整"); + } + if (runtimeSessionId == null || !SESSION_ID.matcher(runtimeSessionId).matches()) { + throw new BusinessException("Agent 工作区会话标识不合法"); + } + Path target = realRoot.resolve(tenantId.toString()).resolve(agentId.toString()) + .resolve(runtimeSessionId).normalize(); + if (!target.startsWith(realRoot)) { + throw new BusinessException("Agent 工作区路径越界"); + } + try { + createSafeDirectories(target); + Path realTarget = target.toRealPath(LinkOption.NOFOLLOW_LINKS); + if (!realTarget.startsWith(realRoot) || Files.isSymbolicLink(realTarget)) { + throw new BusinessException("Agent 工作区路径越界"); + } + touch(realTarget); + return realTarget; + } catch (IOException error) { + throw new BusinessException(500, 500, "创建 Agent 会话工作区失败", error); + } + } + + /** + * 安全解析工作区内一个已经存在的普通文件。 + * + * @param workspace 会话工作区 + * @param relativePath 模型提交的相对路径 + * @return 文件真实绝对路径 + */ + public Path resolveExistingFile(Path workspace, String relativePath) { + if (workspace == null || relativePath == null || relativePath.isBlank() + || relativePath.indexOf('\0') >= 0 || relativePath.startsWith("~")) { + throw new BusinessException("WORKSPACE_PATH_FORBIDDEN: 文件路径必须是工作区相对路径"); + } + Path raw; + try { + raw = Path.of(relativePath); + } catch (InvalidPathException error) { + throw new BusinessException("WORKSPACE_PATH_FORBIDDEN: 文件路径格式不合法"); + } + if (raw.isAbsolute()) { + throw new BusinessException("WORKSPACE_PATH_FORBIDDEN: 禁止绝对路径"); + } + for (Path segment : raw) { + if ("..".equals(segment.toString())) { + throw new BusinessException("WORKSPACE_PATH_FORBIDDEN: 禁止路径回退"); + } + } + try { + Path realWorkspace = workspace.toRealPath(LinkOption.NOFOLLOW_LINKS); + Path candidate = realWorkspace.resolve(raw).normalize(); + if (!candidate.startsWith(realWorkspace) || !Files.exists(candidate, LinkOption.NOFOLLOW_LINKS)) { + throw new BusinessException("WORKSPACE_FILE_NOT_FOUND: 工作区文件不存在"); + } + rejectSymlinkChain(realWorkspace, candidate); + Path realFile = candidate.toRealPath(LinkOption.NOFOLLOW_LINKS); + if (!realFile.startsWith(realWorkspace) || !Files.isRegularFile(realFile, LinkOption.NOFOLLOW_LINKS)) { + throw new BusinessException("WORKSPACE_FILE_TYPE_UNSUPPORTED: 只允许普通文件"); + } + rejectUnixHardlink(realFile); + touch(realWorkspace); + return realFile; + } catch (BusinessException error) { + throw error; + } catch (IOException error) { + throw new BusinessException(500, 500, "解析工作区文件失败", error); + } + } + + /** + * 更新工作区的可信活动时间。 + * + * @param workspace 会话工作区 + */ + public void touch(Path workspace) { + try { + Files.writeString(activityFile(workspace), Instant.now().toString(), + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + } catch (IOException error) { + throw new BusinessException(500, 500, "更新 Agent 工作区活动状态失败", error); + } + } + + /** @return 工作区真实根目录 */ + public Path getRealRoot() { return realRoot; } + /** @return 活动标记目录 */ + public Path getActivityRoot() { return activityRoot; } + + /** + * 取得指定工作区的活动标记文件。 + * + * @param workspace 会话工作区 + * @return 位于模型工作区之外的标记文件 + */ + public Path activityFile(Path workspace) { + Path relative = realRoot.relativize(workspace.toAbsolutePath().normalize()); + String digest = sha256(relative.toString()); + return activityRoot.resolve(digest + ".activity"); + } + + private void createSafeDirectories(Path target) throws IOException { + Path current = realRoot; + for (Path segment : realRoot.relativize(target)) { + current = current.resolve(segment); + if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)) { + if (Files.isSymbolicLink(current) || !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) { + throw new BusinessException("Agent 工作区路径链不是安全目录"); + } + continue; + } + try { + Files.createDirectory(current); + } catch (java.nio.file.FileAlreadyExistsException ignored) { + if (Files.isSymbolicLink(current) || !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) { + throw new BusinessException("Agent 工作区路径链不是安全目录"); + } + } + } + } + + private void rejectSymlinkChain(Path root, Path target) { + Path current = root; + for (Path segment : root.relativize(target)) { + current = current.resolve(segment); + if (Files.isSymbolicLink(current)) { + throw new BusinessException("WORKSPACE_PATH_FORBIDDEN: 路径链包含符号链接"); + } + } + } + + private void rejectUnixHardlink(Path file) throws IOException { + try { + Object value = Files.getAttribute(file, "unix:nlink", LinkOption.NOFOLLOW_LINKS); + if (value instanceof Number number && number.longValue() > 1L) { + throw new BusinessException("WORKSPACE_FILE_TYPE_UNSUPPORTED: 禁止发布硬链接文件"); + } + } catch (UnsupportedOperationException ignored) { + // 非 Unix 文件系统没有 nlink 属性,仍保留普通文件与符号链接校验。 + } + } + + private String sha256(String value) { + try { + return HexFormat.of().formatHex( + java.security.MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } catch (java.security.NoSuchAlgorithmException error) { + throw new IllegalStateException("当前 JVM 不支持 SHA-256", error); + } + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentDependencyAccessService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentDependencyAccessService.java index c319b350..f70aaf1b 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentDependencyAccessService.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentDependencyAccessService.java @@ -26,6 +26,8 @@ import tech.easyflow.system.enums.CategoryResourceType; import tech.easyflow.system.enums.ResourceAction; import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.ResourceAccessService; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.service.SkillService; import java.math.BigInteger; import java.util.Objects; @@ -46,6 +48,7 @@ public class AgentDependencyAccessService { private final AgentCategoryService agentCategoryService; private final CategoryPermissionService categoryPermissionService; private final ResourceAccessService resourceAccessService; + private final SkillService skillService; /** * 创建 Agent 依赖资源校验服务。 @@ -60,6 +63,7 @@ public class AgentDependencyAccessService { * @param agentCategoryService Agent 分类服务 * @param categoryPermissionService 分类权限服务 * @param resourceAccessService 资源权限服务 + * @param skillService Skill 服务 */ public AgentDependencyAccessService(ModelService modelService, WorkflowService workflowService, @@ -70,7 +74,8 @@ public class AgentDependencyAccessService { DocumentCollectionService documentCollectionService, AgentCategoryService agentCategoryService, CategoryPermissionService categoryPermissionService, - ResourceAccessService resourceAccessService) { + ResourceAccessService resourceAccessService, + SkillService skillService) { this.modelService = modelService; this.workflowService = workflowService; this.pluginItemService = pluginItemService; @@ -81,6 +86,7 @@ public class AgentDependencyAccessService { this.agentCategoryService = agentCategoryService; this.categoryPermissionService = categoryPermissionService; this.resourceAccessService = resourceAccessService; + this.skillService = skillService; } /** @@ -135,6 +141,17 @@ public class AgentDependencyAccessService { * @return 插件工具 */ public PluginItem requirePluginItem(Agent agent, BigInteger pluginItemId) { + return requirePluginResource(agent, pluginItemId).pluginItem(); + } + + /** + * 校验并锁定插件工具及其父插件,返回同一事务中的完整调用资源。 + * + * @param agent Agent + * @param pluginItemId 插件工具 ID + * @return 插件项与父插件 + */ + public PluginResource requirePluginResource(Agent agent, BigInteger pluginItemId) { PluginItem current = pluginItemService.getById(pluginItemId); if (current == null || current.getPluginId() == null) { throw new BusinessException("绑定插件不存在"); @@ -153,7 +170,7 @@ public class AgentDependencyAccessService { } assertSameTenant(agent, plugin.getTenantId(), "无权限绑定该插件"); pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), plugin.getId(), "无权限绑定该插件"); - return pluginItem; + return new PluginResource(pluginItem, plugin); } /** @@ -174,6 +191,30 @@ public class AgentDependencyAccessService { return mcp; } + /** + * 校验并锁定 Agent 可使用的已发布 Skill。 + * + *

Skill 发布阶段已经完成底层 Tool 权限与 MCP 清单检测。Agent 保存阶段只消费冻结快照, + * 避免在数据库事务中执行外部 MCP I/O;快照内容及组合 hash 由运行投影器继续校验。

+ * + * @param agent Agent + * @param skillId Skill ID + * @return 已发布 Skill + */ + public Skill requireSkill(Agent agent, BigInteger skillId) { + Skill skill = skillService.getOne(QueryWrapper.create() + .eq(Skill::getId, skillId) + .forUpdate()); + if (skill == null || PublishStatus.from(skill.getPublishStatus()) != PublishStatus.PUBLISHED + || skill.getPublishedSnapshotJson() == null || skill.getPublishedSnapshotJson().isEmpty()) { + throw new BusinessException("绑定 Skill 不存在或未发布"); + } + assertSameTenant(agent, skill.getTenantId(), "无权限绑定该 Skill"); + resourceAccessService.assertAccess( + CategoryResourceType.SKILL, skill, ResourceAction.USE, "无权限绑定该 Skill"); + return skill; + } + /** * 校验并锁定知识库。 * @@ -233,4 +274,13 @@ public class AgentDependencyAccessService { throw new BusinessException(message); } } + + /** + * 插件运行依赖聚合。 + * + * @param pluginItem 插件工具 + * @param plugin 父插件调用配置 + */ + public record PluginResource(PluginItem pluginItem, Plugin plugin) { + } } diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentOptionQueryService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentOptionQueryService.java index c54929e2..2e827a7d 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentOptionQueryService.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentOptionQueryService.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.mybatisflex.core.query.QueryWrapper; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; import tech.easyflow.agent.entity.Agent; import tech.easyflow.agent.security.AgentVisibilityQueryHelper; import tech.easyflow.agent.vo.AgentOptionView; @@ -28,8 +29,12 @@ import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.system.enums.CategoryResourceType; import tech.easyflow.system.enums.ResourceAction; import tech.easyflow.system.service.ResourceAccessService; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.service.SkillService; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; @@ -54,9 +59,11 @@ public class AgentOptionQueryService { private final PluginItemService pluginItemService; private final PluginVisibilityService pluginVisibilityService; private final McpService mcpService; + private final SkillService skillService; private final AgentVisibilityQueryHelper agentVisibilityQueryHelper; private final ResourceAccessService resourceAccessService; private final ObjectMapper objectMapper; + private CategoryPermissionService categoryPermissionService; /** * 创建 Agent 安全选项查询服务。 @@ -69,6 +76,7 @@ public class AgentOptionQueryService { * @param pluginItemService 插件工具服务 * @param pluginVisibilityService 插件可见性服务 * @param mcpService MCP 服务 + * @param skillService Skill 服务 * @param agentVisibilityQueryHelper Agent 可见性查询助手 * @param resourceAccessService 资源访问服务 * @param objectMapper JSON 映射器 @@ -81,6 +89,7 @@ public class AgentOptionQueryService { PluginItemService pluginItemService, PluginVisibilityService pluginVisibilityService, McpService mcpService, + SkillService skillService, AgentVisibilityQueryHelper agentVisibilityQueryHelper, ResourceAccessService resourceAccessService, ObjectMapper objectMapper) { @@ -92,6 +101,7 @@ public class AgentOptionQueryService { this.pluginItemService = pluginItemService; this.pluginVisibilityService = pluginVisibilityService; this.mcpService = mcpService; + this.skillService = skillService; this.agentVisibilityQueryHelper = agentVisibilityQueryHelper; this.resourceAccessService = resourceAccessService; this.objectMapper = objectMapper; @@ -133,12 +143,113 @@ public class AgentOptionQueryService { return new AgentResourceOptionsView( listModelOptions(account), listKnowledgeOptions(account), + listSkillOptions(account), listWorkflowOptions(account), listPluginToolOptions(account), - listMcpOptions(account) + listMcpOptions(account), + new AgentResourceOptionsView.Capabilities( + categoryPermissionService != null && categoryPermissionService.isSuperAdmin(account)) ); } + /** + * 注入平台超级管理员判定服务。 + * + * @param categoryPermissionService 分类权限服务 + */ + @Autowired + public void setCategoryPermissionService(CategoryPermissionService categoryPermissionService) { + this.categoryPermissionService = categoryPermissionService; + } + + /** + * 查询当前账号可使用的已发布 Skill 安全选项。 + * + * @param account 当前登录账号 + * @return Skill 选项 + */ + private List listSkillOptions(LoginAccount account) { + return skillService.list(QueryWrapper.create() + .eq(Skill::getTenantId, account.getTenantId()) + .eq(Skill::getPublishStatus, PublishStatus.PUBLISHED.getCode()) + .orderBy(Skill::getModified, false) + .orderBy(Skill::getDisplayName, true)) + .stream() + .filter(skill -> resourceAccessService.canAccess( + CategoryResourceType.SKILL, skill, ResourceAction.USE)) + .map(this::toSkillOption) + .toList(); + } + + /** + * 将 Skill 发布数据投影为不含正文、资源内容和连接配置的选择项。 + * + * @param skill Skill + * @return 安全选择项 + */ + private AgentResourceOptionsView.SkillOption toSkillOption(Skill skill) { + Map publishedSnapshot = skill.getPublishedSnapshotJson(); + List resources = listValue(publishedSnapshot, "resources"); + int textCount = 0; + int binaryCount = 0; + long textBytes = utf8Length(textValue(publishedSnapshot, "skillContent")); + for (Object raw : resources) { + if (!(raw instanceof Map resource)) { + continue; + } + if (Boolean.TRUE.equals(resource.get("text"))) { + textCount++; + textBytes = Math.addExact(textBytes, + utf8Length(resource.get("textContent") == null + ? null : String.valueOf(resource.get("textContent")))); + } else { + binaryCount++; + } + } + int toolCount = 0; + for (Object raw : listValue(skill.getPublishedToolBindingsJson(), "bindings")) { + if (raw instanceof Map binding && binding.get("toolCount") instanceof Number number) { + toolCount += Math.max(0, number.intValue()); + } + } + return new AgentResourceOptionsView.SkillOption( + skill.getId(), + publishedText(publishedSnapshot, "displayName", skill.getDisplayName()), + publishedText(publishedSnapshot, "description", skill.getDescription()), + publishedText(publishedSnapshot, "visibilityScope", skill.getVisibilityScope()), + skill.getSnapshotHash(), toolCount, textBytes, textCount, binaryCount); + } + + /** + * 读取发布快照中的展示字段,旧快照缺少字段时兼容当前行。 + * + * @param snapshot 发布内容快照 + * @param key 字段名 + * @param legacyFallback 历史快照回退值 + * @return 冻结展示值 + */ + private String publishedText(Map snapshot, String key, String legacyFallback) { + if (snapshot == null || !snapshot.containsKey(key)) { + return legacyFallback; + } + Object value = snapshot.get(key); + return value == null ? null : String.valueOf(value); + } + + private String textValue(Map source, String key) { + Object value = source == null ? null : source.get(key); + return value == null ? null : String.valueOf(value); + } + + private long utf8Length(String value) { + return value == null ? 0L : value.getBytes(StandardCharsets.UTF_8).length; + } + + private List listValue(Map source, String key) { + Object value = source == null ? null : source.get(key); + return value instanceof List list ? list : List.of(); + } + /** * 查询当前账号可用于 Agent 会话的知识库安全选项。 * @@ -228,7 +339,6 @@ public class AgentOptionQueryService { return workflowService.list(QueryWrapper.create() .eq(Workflow::getTenantId, account.getTenantId()) .eq(Workflow::getPublishStatus, PublishStatus.PUBLISHED.getCode()) - .eq(Workflow::getStatus, 1) .orderBy(Workflow::getModified, false)) .stream() .filter(item -> resourceAccessService.canAccess( diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentService.java index ae4f3738..526daa7d 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentService.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentService.java @@ -2,8 +2,12 @@ package tech.easyflow.agent.service; import com.mybatisflex.core.service.IService; import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.entity.AgentKnowledgeBinding; +import tech.easyflow.agent.entity.AgentSkillBinding; +import tech.easyflow.agent.entity.AgentToolBinding; import java.math.BigInteger; +import java.util.List; import java.util.Map; /** @@ -35,6 +39,26 @@ public interface AgentService extends IService { */ Agent updateDraft(Agent agent); + /** + * 在一个事务中保存 Agent 草稿及发生变化的资源绑定。 + * + * @param agent Agent 草稿 + * @param toolBindings 工具绑定 + * @param replaceToolBindings 是否替换工具绑定 + * @param knowledgeBindings 知识库绑定 + * @param replaceKnowledgeBindings 是否替换知识库绑定 + * @param skillBindings Skill 绑定 + * @param replaceSkillBindings 是否替换 Skill 绑定 + * @return 保存后的 Agent 与本次替换的绑定 + */ + Agent saveDraftGraph(Agent agent, + List toolBindings, + boolean replaceToolBindings, + List knowledgeBindings, + boolean replaceKnowledgeBindings, + List skillBindings, + boolean replaceSkillBindings); + /** * 更新 Agent 的可见范围。 * diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentSkillBindingService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentSkillBindingService.java new file mode 100644 index 00000000..a097a223 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/AgentSkillBindingService.java @@ -0,0 +1,38 @@ +package tech.easyflow.agent.service; + +import com.mybatisflex.core.service.IService; +import tech.easyflow.agent.entity.AgentSkillBinding; + +import java.math.BigInteger; +import java.util.List; + +/** + * Agent Skill 绑定服务。 + */ +public interface AgentSkillBindingService extends IService { + + /** + * 原子替换 Agent 的全部 Skill 绑定。 + * + * @param agentId Agent ID + * @param bindings Skill 引用列表 + * @return 规范化后的脱敏绑定摘要 + */ + List replaceBindings(BigInteger agentId, List bindings); + + /** + * 查询 Agent 的 Skill 草稿绑定。 + * + * @param agentId Agent ID + * @return 稳定排序的绑定 + */ + List listBindings(BigInteger agentId); + + /** + * 查询 Agent 的 Skill 脱敏绑定摘要。 + * + * @param agentId Agent ID + * @return 稳定排序的绑定摘要 + */ + List listSummaries(BigInteger agentId); +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentBindingSemanticComparator.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentBindingSemanticComparator.java new file mode 100644 index 00000000..71dbc917 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentBindingSemanticComparator.java @@ -0,0 +1,142 @@ +package tech.easyflow.agent.service.impl; + +import tech.easyflow.agent.entity.AgentKnowledgeBinding; +import tech.easyflow.agent.entity.AgentSkillBinding; +import tech.easyflow.agent.entity.AgentToolBinding; +import tech.easyflow.agent.enums.AgentToolType; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 比较 Agent 绑定的可持久化业务字段,忽略 ID、审计字段与展示摘要。 + */ +final class AgentBindingSemanticComparator { + + private static final String DEFAULT_RETRIEVAL_MODE = "HYBRID"; + + private AgentBindingSemanticComparator() { + } + + /** + * 判断工具绑定整组替换是否会产生业务变化。 + * + * @param current 当前持久化绑定 + * @param requested 客户端请求绑定 + * @return 业务字段完全一致时返回 {@code true} + */ + static boolean sameTools(List current, List requested) { + List left = safeList(current); + List right = safeList(requested); + if (left.size() != right.size()) { + return false; + } + for (int index = 0; index < left.size(); index++) { + AgentToolBinding persisted = left.get(index); + AgentToolBinding incoming = right.get(index); + if (persisted == null || incoming == null + || !Objects.equals(normalizeToolType(persisted.getToolType()), normalizeToolType(incoming.getToolType())) + || !Objects.equals(persisted.getTargetId(), incoming.getTargetId()) + || !Objects.equals(text(persisted.getToolName()), text(incoming.getToolName())) + || !Objects.equals(enabled(persisted.getEnabled()), enabled(incoming.getEnabled())) + || !Objects.equals(Boolean.TRUE.equals(persisted.getHitlEnabled()), + Boolean.TRUE.equals(incoming.getHitlEnabled())) + || !Objects.equals(map(persisted.getHitlConfigJson()), map(incoming.getHitlConfigJson())) + || !Objects.equals(map(persisted.getOptionsJson()), map(incoming.getOptionsJson())) + || !Objects.equals(persisted.getSortNo(), sortNo(incoming.getSortNo(), index))) { + return false; + } + } + return true; + } + + /** + * 判断知识库绑定整组替换是否会产生业务变化。 + * + * @param current 当前持久化绑定 + * @param requested 客户端请求绑定 + * @return 业务字段完全一致时返回 {@code true} + */ + static boolean sameKnowledges(List current, + List requested) { + List left = safeList(current); + List right = safeList(requested); + if (left.size() != right.size()) { + return false; + } + for (int index = 0; index < left.size(); index++) { + AgentKnowledgeBinding persisted = left.get(index); + AgentKnowledgeBinding incoming = right.get(index); + if (persisted == null || incoming == null + || !Objects.equals(persisted.getKnowledgeId(), incoming.getKnowledgeId()) + || !Objects.equals(retrievalMode(persisted.getRetrievalMode()), + retrievalMode(incoming.getRetrievalMode())) + || !Objects.equals(enabled(persisted.getEnabled()), enabled(incoming.getEnabled())) + || !Objects.equals(map(persisted.getOptionsJson()), map(incoming.getOptionsJson())) + || !Objects.equals(persisted.getSortNo(), sortNo(incoming.getSortNo(), index))) { + return false; + } + } + return true; + } + + /** + * 判断 Skill 绑定顺序是否发生变化。 + * + * @param current 当前持久化绑定 + * @param requested 客户端请求绑定 + * @return Skill ID 与稳定顺序完全一致时返回 {@code true} + */ + static boolean sameSkills(List current, List requested) { + List left = safeList(current); + List right = safeList(requested); + if (left.size() != right.size()) { + return false; + } + for (int index = 0; index < left.size(); index++) { + AgentSkillBinding persisted = left.get(index); + AgentSkillBinding incoming = right.get(index); + if (persisted == null || incoming == null + || !Objects.equals(persisted.getSkillId(), incoming.getSkillId()) + || !Objects.equals(persisted.getSortNo(), index)) { + return false; + } + } + return true; + } + + private static String normalizeToolType(String value) { + try { + return AgentToolType.from(value).name(); + } catch (RuntimeException ignored) { + return value; + } + } + + private static String retrievalMode(String value) { + return value == null || value.isBlank() + ? DEFAULT_RETRIEVAL_MODE : value.trim().toUpperCase(java.util.Locale.ROOT); + } + + private static String text(String value) { + return value == null ? "" : value; + } + + private static Boolean enabled(Boolean value) { + return value == null || value; + } + + private static Integer sortNo(Integer value, int index) { + return value == null ? index : value; + } + + private static Map map(Map value) { + return value == null ? Collections.emptyMap() : value; + } + + private static List safeList(List value) { + return value == null ? Collections.emptyList() : value; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentKnowledgeBindingServiceImpl.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentKnowledgeBindingServiceImpl.java index 01550830..de6a56c1 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentKnowledgeBindingServiceImpl.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentKnowledgeBindingServiceImpl.java @@ -57,6 +57,10 @@ public class AgentKnowledgeBindingServiceImpl extends ServiceImpl current = listAll(agentId); + if (AgentBindingSemanticComparator.sameKnowledges(current, bindings)) { + return enabledBindings(current); + } validateBindings(agent, bindings); remove(QueryWrapper.create().where("agent_id = ?", agentId)); if (bindings == null || bindings.isEmpty()) { @@ -66,7 +70,7 @@ public class AgentKnowledgeBindingServiceImpl extends ServiceImpl listAll(BigInteger agentId) { + return list(QueryWrapper.create() + .where("agent_id = ?", agentId) + .orderBy("sort_no asc, id asc")); + } + + /** + * 从已加载或已写入的绑定中筛选启用项,避免替换后再次查询。 + * + * @param bindings 知识库绑定 + * @return 启用绑定 + */ + private List enabledBindings(List bindings) { + return bindings.stream() + .filter(binding -> binding != null && binding.getEnabled() != Boolean.FALSE) + .toList(); + } + /** * 锁定并加载待修改的 Agent。 * diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentResourceBindingProviderImpl.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentResourceBindingProviderImpl.java index 22e7da6d..7b5b9ed2 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentResourceBindingProviderImpl.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentResourceBindingProviderImpl.java @@ -255,7 +255,29 @@ public class AgentResourceBindingProviderImpl implements AgentResourceBindingPro AgentToolType toolType, BigInteger resourceId) { return snapshotListContains(snapshot, "toolBindings", resourceId, toolType.name()) - || snapshotListContains(snapshot, "toolSummaries", resourceId, toolType.name()); + || snapshotListContains(snapshot, "toolSummaries", resourceId, toolType.name()) + || nestedSkillBindingsContain(snapshot, toolType, resourceId); + } + + private boolean nestedSkillBindingsContain(Map snapshot, + AgentToolType toolType, + BigInteger resourceId) { + Object rawBindings = snapshot == null ? null : snapshot.get("skillBindings"); + if (!(rawBindings instanceof List bindings)) { + return false; + } + for (Object raw : bindings) { + if (!(raw instanceof Map binding) + || !(binding.get("resourceSnapshot") instanceof Map resourceSnapshot)) { + continue; + } + Object rawTools = resourceSnapshot.get("toolBindings"); + if (rawTools instanceof List tools + && tools.stream().anyMatch(item -> matchesResourceBinding(item, resourceId, toolType.name()))) { + return true; + } + } + return false; } /** diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentServiceImpl.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentServiceImpl.java index 3b443da3..fe2aeb4a 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentServiceImpl.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentServiceImpl.java @@ -7,18 +7,26 @@ import com.mybatisflex.spring.service.impl.ServiceImpl; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import tech.easyflow.agent.config.AgentInteractionConfigSupport; +import tech.easyflow.agent.config.AgentBuiltinToolsConfigResolver; +import tech.easyflow.agent.config.AgentBuiltinToolsConfig; import tech.easyflow.agent.entity.Agent; import tech.easyflow.agent.entity.AgentKnowledgeBinding; import tech.easyflow.agent.entity.AgentToolBinding; +import tech.easyflow.agent.entity.AgentSkillBinding; import tech.easyflow.agent.mapper.AgentMapper; import tech.easyflow.agent.runtime.AgentRuntimeCompiler; import tech.easyflow.agent.service.AgentDependencyAccessService; import tech.easyflow.agent.service.AgentKnowledgeBindingService; import tech.easyflow.agent.service.AgentService; import tech.easyflow.agent.service.AgentToolBindingService; +import tech.easyflow.agent.service.AgentSkillBindingService; +import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeProjector; import tech.easyflow.agent.support.AgentBindingLockExecutor; import tech.easyflow.ai.entity.*; +import tech.easyflow.ai.easyagentsflow.repository.AgentWorkflowSnapshotFactory; import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.mcp.McpConnectionSnapshotFactory; +import tech.easyflow.ai.plugin.PluginConnectionSnapshotFactory; import tech.easyflow.ai.service.*; import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; @@ -26,7 +34,9 @@ import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.system.enums.CategoryResourceType; import tech.easyflow.system.enums.ResourceAction; import tech.easyflow.system.enums.VisibilityScope; +import tech.easyflow.system.entity.SysLog; import tech.easyflow.system.service.ResourceAccessService; +import tech.easyflow.system.service.SysLogService; import javax.annotation.Resource; import java.math.BigInteger; @@ -43,12 +53,15 @@ public class AgentServiceImpl extends ServiceImpl implements private static final TypeReference> TOOL_BINDING_LIST_TYPE = new TypeReference<>() {}; private static final TypeReference> KNOWLEDGE_BINDING_LIST_TYPE = new TypeReference<>() {}; + private static final TypeReference> SKILL_BINDING_LIST_TYPE = new TypeReference<>() {}; @Resource private AgentToolBindingService agentToolBindingService; @Resource private AgentKnowledgeBindingService agentKnowledgeBindingService; @Resource + private AgentSkillBindingService agentSkillBindingService; + @Resource private ModelService modelService; @Resource private WorkflowService workflowService; @@ -57,6 +70,10 @@ public class AgentServiceImpl extends ServiceImpl implements @Resource private McpService mcpService; @Resource + private McpConnectionSnapshotFactory mcpConnectionSnapshotFactory; + @Resource + private PluginConnectionSnapshotFactory pluginConnectionSnapshotFactory; + @Resource private DocumentCollectionService documentCollectionService; @Resource private ResourceAccessService resourceAccessService; @@ -68,6 +85,14 @@ public class AgentServiceImpl extends ServiceImpl implements private AgentBindingLockExecutor agentBindingLockExecutor; @Resource private AgentRuntimeCompiler agentRuntimeCompiler; + @Resource + private AgentSkillRuntimeProjector agentSkillRuntimeProjector; + @Resource + private AgentWorkflowSnapshotFactory agentWorkflowSnapshotFactory; + @Resource + private AgentBuiltinToolsConfigResolver agentBuiltinToolsConfigResolver; + @Resource + private SysLogService sysLogService; /** * {@inheritDoc} @@ -76,8 +101,11 @@ public class AgentServiceImpl extends ServiceImpl implements public Agent getDetail(BigInteger id) { Agent agent = requireAgent(id); resourceAccessService.assertAccess(CategoryResourceType.AGENT, agent, ResourceAction.READ, "无权限查看该 Agent"); + agent.setExecutionConfigJson( + agentBuiltinToolsConfigResolver.normalizeForDraftRead(agent.getExecutionConfigJson())); agent.setToolBindings(agentToolBindingService.listEnabled(id)); agent.setKnowledgeBindings(agentKnowledgeBindingService.listEnabled(id)); + agent.setSkillBindings(agentSkillBindingService.listSummaries(id)); return agent; } @@ -88,9 +116,15 @@ public class AgentServiceImpl extends ServiceImpl implements @Transactional(rollbackFor = Exception.class) public Agent saveDraft(Agent agent) { applyDraftDefaults(agent); - validateDraft(agent); + validateDraft(agent, null); + boolean shellApprovalDisabled = agentBuiltinToolsConfigResolver + .isShellApprovalDisableTransition(agent.getExecutionConfigJson(), null); save(agent); - return getDetail(agent.getId()); + if (shellApprovalDisabled) { + recordShellApprovalDisabled(agent.getId(), "saveDraft", + AgentBuiltinToolsConfig.newAgentDefaults().shell()); + } + return agent; } /** @@ -107,13 +141,48 @@ public class AgentServiceImpl extends ServiceImpl implements resourceAccessService.assertAccess( CategoryResourceType.AGENT, existing, ResourceAction.MANAGE, "无权限管理该 Agent"); agent.setTenantId(existing.getTenantId()); - validateDraft(agent); + Map existingExecutionConfig = existing.getExecutionConfigJson(); + validateDraft(agent, existingExecutionConfig); + boolean shellApprovalDisabled = agentBuiltinToolsConfigResolver + .isShellApprovalDisableTransition(agent.getExecutionConfigJson(), existingExecutionConfig); + AgentBuiltinToolsConfig.ToolSwitch previousShell = agentBuiltinToolsConfigResolver + .resolveDraftRuntime(existingExecutionConfig).shell(); applyDraftUpdate(existing, agent); updateById(existing); - return getDetail(existing.getId()); + if (shellApprovalDisabled) { + recordShellApprovalDisabled(existing.getId(), "updateDraft", previousShell); + } + return existing; }); } + /** + * {@inheritDoc} + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Agent saveDraftGraph(Agent agent, + List toolBindings, + boolean replaceToolBindings, + List knowledgeBindings, + boolean replaceKnowledgeBindings, + List skillBindings, + boolean replaceSkillBindings) { + Agent saved = agent != null && agent.getId() != null + ? updateDraft(agent) : saveDraft(agent); + BigInteger agentId = saved.getId(); + if (replaceToolBindings) { + saved.setToolBindings(agentToolBindingService.replaceBindings(agentId, toolBindings)); + } + if (replaceKnowledgeBindings) { + saved.setKnowledgeBindings(agentKnowledgeBindingService.replaceBindings(agentId, knowledgeBindings)); + } + if (replaceSkillBindings) { + saved.setSkillBindings(agentSkillBindingService.replaceBindings(agentId, skillBindings)); + } + return saved; + } + /** * {@inheritDoc} */ @@ -174,7 +243,10 @@ public class AgentServiceImpl extends ServiceImpl implements CategoryResourceType.AGENT, detail, ResourceAction.MANAGE, "无权限管理该 Agent"); detail.setToolBindings(agentToolBindingService.listEnabled(agentId)); detail.setKnowledgeBindings(agentKnowledgeBindingService.listEnabled(agentId)); - validateDraft(detail); + detail.setSkillBindings(agentSkillBindingService.listBindings(agentId)); + validateDraft(detail, detail.getExecutionConfigJson()); + List projectedSkillBindings = + agentSkillRuntimeProjector.projectCurrentBindings(detail, detail.getSkillBindings()); Map snapshot = new LinkedHashMap<>(); snapshot.put("id", detail.getId()); snapshot.put("tenantId", detail.getTenantId()); @@ -194,12 +266,15 @@ public class AgentServiceImpl extends ServiceImpl implements snapshot.put("visibilityScope", detail.getVisibilityScope()); snapshot.put("toolBindings", snapshotToolBindings(detail, detail.getToolBindings())); snapshot.put("knowledgeBindings", snapshotKnowledgeBindings(detail, detail.getKnowledgeBindings())); + snapshot.put("skillBindings", projectedSkillBindings); snapshot.put("basicSummary", basicSummary(detail)); snapshot.put("modelSummary", modelSummary(detail.getModelId())); snapshot.put("parameterSummary", parameterSummary(detail)); snapshot.put("promptSummary", promptSummary(detail)); snapshot.put("toolSummaries", toolSummaries(detail.getToolBindings())); snapshot.put("knowledgeSummaries", knowledgeSummaries(detail.getKnowledgeBindings())); + snapshot.put("skillSummaries", projectedSkillBindings.stream() + .map(AgentSkillBinding::getResourceSummary).toList()); snapshot.put("snapshotAt", new Date()); // 发布前完整编译一次,提前暴露工具运行名冲突和运行定义错误。 agentRuntimeCompiler.compile(fromSnapshot(snapshot)); @@ -222,10 +297,14 @@ public class AgentServiceImpl extends ServiceImpl implements agent.setModelId(toBigInteger(snapshot.get("modelId"))); agent.setCategoryId(toBigInteger(snapshot.get("categoryId"))); agent.setPublishStatus(PublishStatus.PUBLISHED.getCode()); + agent.setExecutionConfigJson( + agentBuiltinToolsConfigResolver.normalizeForPublishedRuntime(agent.getExecutionConfigJson())); agent.setInteractionConfigJson(AgentInteractionConfigSupport.normalize(agent.getInteractionConfigJson())); agent.setPublishedSnapshotJson(snapshot); agent.setToolBindings(objectMapper.convertValue(snapshot.get("toolBindings"), TOOL_BINDING_LIST_TYPE)); agent.setKnowledgeBindings(objectMapper.convertValue(snapshot.get("knowledgeBindings"), KNOWLEDGE_BINDING_LIST_TYPE)); + agent.setSkillBindings(snapshot.get("skillBindings") == null ? List.of() + : objectMapper.convertValue(snapshot.get("skillBindings"), SKILL_BINDING_LIST_TYPE)); return agent; } @@ -253,7 +332,7 @@ public class AgentServiceImpl extends ServiceImpl implements return agent; } - private void validateDraft(Agent agent) { + private void validateDraft(Agent agent, Map existingExecutionConfig) { if (agent == null) { throw new BusinessException("Agent 不能为空"); } @@ -264,7 +343,9 @@ public class AgentServiceImpl extends ServiceImpl implements agentDependencyAccessService.validateCategory(agent); agent.setVisibilityScope(VisibilityScope.fromOrDefault(agent.getVisibilityScope(), VisibilityScope.PRIVATE).name()); agent.setInteractionConfigJson(AgentInteractionConfigSupport.normalize(agent.getInteractionConfigJson())); - agent.setExecutionConfigJson(normalizeExecutionConfig(agent.getExecutionConfigJson())); + Map executionConfig = normalizeExecutionConfig(agent.getExecutionConfigJson()); + agent.setExecutionConfigJson(agentBuiltinToolsConfigResolver.normalizeForDraftSave( + executionConfig, existingExecutionConfig, requireCurrentLoginAccount())); } /** @@ -356,6 +437,33 @@ public class AgentServiceImpl extends ServiceImpl implements existing.setModifiedBy(account.getId()); } + /** + * 持久化 Shell 审批关闭这一高风险配置变更的专用审计记录。 + * + * @param agentId Agent ID + * @param actionMethod 触发变更的服务方法 + * @param previousShell 变更前 Shell 配置 + */ + private void recordShellApprovalDisabled(BigInteger agentId, + String actionMethod, + AgentBuiltinToolsConfig.ToolSwitch previousShell) { + LoginAccount account = requireCurrentLoginAccount(); + SysLog log = new SysLog(); + log.setAccountId(account.getId()); + log.setActionName("关闭 Agent Shell 调用审批"); + log.setActionType("SECURITY_CONFIG_CHANGE"); + log.setActionClass(AgentServiceImpl.class.getName()); + log.setActionMethod(actionMethod); + log.setActionUrl("/api/v1/agent/" + ("saveDraft".equals(actionMethod) ? "save" : "update")); + log.setActionBody("{\"agentId\":\"" + agentId + + "\",\"setting\":\"shell\",\"before\":{\"enabled\":" + + previousShell.enabled() + ",\"approvalRequired\":" + previousShell.approvalRequired() + + "},\"after\":{\"enabled\":true,\"approvalRequired\":false}}"); + log.setStatus(1); + log.setCreated(new Date()); + sysLogService.save(log); + } + private Map modelSummary(BigInteger modelId) { Model model = modelService.getModelInstance(modelId); Map summary = new LinkedHashMap<>(); @@ -432,14 +540,19 @@ public class AgentServiceImpl extends ServiceImpl implements private Map toolResourceSnapshot(Agent agent, AgentToolBinding binding) { if ("WORKFLOW".equalsIgnoreCase(binding.getToolType())) { Workflow workflow = agentDependencyAccessService.requireWorkflow(agent, binding.getTargetId()); - return objectMapper.convertValue(workflow, new TypeReference>() {}); + return agentWorkflowSnapshotFactory.snapshot(workflow); } if ("PLUGIN".equalsIgnoreCase(binding.getToolType())) { - PluginItem pluginItem = agentDependencyAccessService.requirePluginItem(agent, binding.getTargetId()); - return objectMapper.convertValue(pluginItem, new TypeReference>() {}); + AgentDependencyAccessService.PluginResource resource = + agentDependencyAccessService.requirePluginResource(agent, binding.getTargetId()); + Map snapshot = new LinkedHashMap<>(); + snapshot.put("pluginItem", objectMapper.convertValue( + resource.pluginItem(), new TypeReference>() {})); + snapshot.put("plugin", pluginConnectionSnapshotFactory.snapshot(resource.plugin())); + return snapshot; } Mcp mcp = agentDependencyAccessService.requireMcp(agent, binding.getTargetId()); - return objectMapper.convertValue(mcp, new TypeReference>() {}); + return mcpConnectionSnapshotFactory.snapshot(mcp); } private List snapshotKnowledgeBindings( diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentSkillBindingServiceImpl.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentSkillBindingServiceImpl.java new file mode 100644 index 00000000..26fa0604 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentSkillBindingServiceImpl.java @@ -0,0 +1,233 @@ +package tech.easyflow.agent.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import com.mybatisflex.spring.service.impl.ServiceImpl; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.entity.AgentSkillBinding; +import tech.easyflow.agent.mapper.AgentMapper; +import tech.easyflow.agent.mapper.AgentSkillBindingMapper; +import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeProjector; +import tech.easyflow.agent.service.AgentSkillBindingService; +import tech.easyflow.agent.support.AgentBindingLockExecutor; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.service.SkillService; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Agent Skill 绑定服务实现。 + */ +@Service +public class AgentSkillBindingServiceImpl + extends ServiceImpl + implements AgentSkillBindingService { + + private final AgentMapper agentMapper; + private final AgentBindingLockExecutor bindingLockExecutor; + private final AgentSkillRuntimeProjector runtimeProjector; + private final SkillService skillService; + private final ResourceAccessService resourceAccessService; + + /** + * 创建 Agent Skill 绑定服务。 + * + * @param agentMapper Agent Mapper + * @param bindingLockExecutor Agent 绑定锁执行器 + * @param runtimeProjector Skill 运行投影器 + * @param skillService Skill 服务 + * @param resourceAccessService 资源权限服务 + */ + public AgentSkillBindingServiceImpl(AgentMapper agentMapper, + AgentBindingLockExecutor bindingLockExecutor, + AgentSkillRuntimeProjector runtimeProjector, + SkillService skillService, + ResourceAccessService resourceAccessService) { + this.agentMapper = agentMapper; + this.bindingLockExecutor = bindingLockExecutor; + this.runtimeProjector = runtimeProjector; + this.skillService = skillService; + this.resourceAccessService = resourceAccessService; + } + + /** {@inheritDoc} */ + @Override + @Transactional(rollbackFor = Exception.class) + public List replaceBindings(BigInteger agentId, + List bindings) { + return bindingLockExecutor.execute(agentId, () -> { + Agent agent = requireAgentForUpdate(agentId); + resourceAccessService.assertAccess( + CategoryResourceType.AGENT, agent, ResourceAction.MANAGE, "无权限管理该 Agent"); + List current = listBindings(agentId); + if (AgentBindingSemanticComparator.sameSkills(current, bindings)) { + return listSummaries(agentId); + } + List normalized = normalize(agent, bindings); + // 在删除旧绑定前完成权限、包完整性、Tool 与 8 MiB 预算校验,失败时保留旧组。 + List projected = runtimeProjector.projectCurrentBindings(agent, normalized); + Map> summaries = new HashMap<>(); + for (AgentSkillBinding binding : projected) { + summaries.put(binding.getSkillId(), binding.getResourceSummary()); + } + normalized.forEach(binding -> binding.setResourceSummary(summaries.get(binding.getSkillId()))); + remove(QueryWrapper.create() + .eq(AgentSkillBinding::getTenantId, agent.getTenantId()) + .eq(AgentSkillBinding::getAgentId, agentId)); + if (!normalized.isEmpty()) { + saveBatch(normalized); + } + return normalized; + }); + } + + /** {@inheritDoc} */ + @Override + public List listBindings(BigInteger agentId) { + if (agentId == null) { + return Collections.emptyList(); + } + return list(QueryWrapper.create() + .eq(AgentSkillBinding::getAgentId, agentId) + .orderBy(AgentSkillBinding::getSortNo, true) + .orderBy(AgentSkillBinding::getId, true)); + } + + /** {@inheritDoc} */ + @Override + public List listSummaries(BigInteger agentId) { + List bindings = listBindings(agentId); + Agent agent = agentMapper.selectOneById(agentId); + Map publishedHashes = publishedRuntimeHashes(agent); + for (AgentSkillBinding binding : bindings) { + Skill skill = skillService.getById(binding.getSkillId()); + if (skill == null) { + binding.setResourceSummary(Map.of( + "skillId", binding.getSkillId(), + "displayName", "已失效技能", + "available", false)); + continue; + } + binding.setResourceSummary(runtimeProjector.currentSummary( + skill, publishedHashes.get(binding.getSkillId()))); + binding.getResourceSummary().put("available", true); + } + return bindings; + } + + /** + * 规范客户端绑定并写入服务端归属、排序和审计字段。 + * + * @param agent Agent + * @param bindings 客户端绑定 + * @return 规范绑定 + */ + private List normalize(Agent agent, List bindings) { + if (bindings == null || bindings.isEmpty()) { + return List.of(); + } + if (bindings.size() > AgentSkillRuntimeProjector.MAX_SKILL_COUNT) { + throw new BusinessException(409, 4092, "单个 Agent 最多可绑定 20 个 Skill"); + } + Set unique = new HashSet<>(); + List result = new ArrayList<>(); + LoginAccount account = requireAccount(); + Date now = new Date(); + for (int index = 0; index < bindings.size(); index++) { + AgentSkillBinding source = bindings.get(index); + if (source == null || source.getSkillId() == null) { + throw new BusinessException("Agent Skill 绑定参数不完整"); + } + if (!unique.add(source.getSkillId())) { + throw new BusinessException(409, 4092, "同一 Skill 不能重复绑定"); + } + AgentSkillBinding binding = new AgentSkillBinding(); + binding.setTenantId(agent.getTenantId()); + binding.setAgentId(agent.getId()); + binding.setSkillId(source.getSkillId()); + binding.setSortNo(index); + binding.setCreated(now); + binding.setCreatedBy(account.getId()); + binding.setModified(now); + binding.setModifiedBy(account.getId()); + result.add(binding); + } + return result; + } + + /** + * 查询并锁定 Agent。 + * + * @param agentId Agent ID + * @return Agent + */ + private Agent requireAgentForUpdate(BigInteger agentId) { + if (agentId == null) { + throw new BusinessException("Agent ID 不能为空"); + } + Agent agent = agentMapper.selectOneByQuery(QueryWrapper.create() + .eq(Agent::getId, agentId) + .forUpdate()); + if (agent == null) { + throw new BusinessException(404, 404, "Agent 不存在"); + } + return agent; + } + + /** + * 获取 Agent 当前线上冻结的 Skill 组合 hash。 + * + * @param agentId Agent ID + * @param skillId Skill ID + * @return 组合 hash 或 null + */ + private Map publishedRuntimeHashes(Agent agent) { + Map hashes = new HashMap<>(); + Map snapshot = agent == null ? null : agent.getPublishedSnapshotJson(); + Object rawBindings = snapshot == null ? null : snapshot.get("skillBindings"); + if (!(rawBindings instanceof List items)) { + return hashes; + } + for (Object raw : items) { + if (!(raw instanceof Map item) || item.get("skillId") == null) { + continue; + } + Object resource = item.get("resourceSnapshot"); + if (resource instanceof Map resourceMap) { + Object hash = resourceMap.get("skillRuntimeSnapshotHash"); + if (hash != null) { + hashes.put(new BigInteger(String.valueOf(item.get("skillId"))), String.valueOf(hash)); + } + } + } + return hashes; + } + + /** + * 获取当前登录账号。 + * + * @return 登录账号 + */ + private LoginAccount requireAccount() { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null) { + throw new BusinessException(401, 401, "当前登录状态失效,请重新登录后再试"); + } + return account; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentSkillReferenceProvider.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentSkillReferenceProvider.java new file mode 100644 index 00000000..3aef7912 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentSkillReferenceProvider.java @@ -0,0 +1,69 @@ +package tech.easyflow.agent.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import org.springframework.stereotype.Component; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.entity.AgentSkillBinding; +import tech.easyflow.agent.service.AgentService; +import tech.easyflow.agent.service.AgentSkillBindingService; +import tech.easyflow.skill.service.SkillReferenceProvider; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Agent 草稿和有效发布快照中的 Skill 引用提供者。 + */ +@Component +public class AgentSkillReferenceProvider implements SkillReferenceProvider { + + private final AgentService agentService; + private final AgentSkillBindingService bindingService; + + /** + * 创建引用提供者。 + * + * @param agentService Agent 服务 + * @param bindingService Agent Skill 绑定服务 + */ + public AgentSkillReferenceProvider(AgentService agentService, + AgentSkillBindingService bindingService) { + this.agentService = agentService; + this.bindingService = bindingService; + } + + /** {@inheritDoc} */ + @Override + public List listReferences(BigInteger skillId) { + Set ids = new LinkedHashSet<>(); + for (AgentSkillBinding binding : bindingService.list(QueryWrapper.create() + .eq(AgentSkillBinding::getSkillId, skillId))) { + ids.add(binding.getAgentId()); + } + for (Agent agent : agentService.list(QueryWrapper.create() + .select(Agent::getId, Agent::getPublishedSnapshotJson) + .isNotNull(Agent::getPublishedSnapshotJson))) { + if (containsSkill(agent.getPublishedSnapshotJson(), skillId)) { + ids.add(agent.getId()); + } + } + List result = new ArrayList<>(); + for (Agent agent : agentService.listByIds(ids)) { + result.add("智能体“" + (agent.getName() == null ? "未命名智能体" : agent.getName()) + "”"); + } + return result; + } + + private boolean containsSkill(Map snapshot, BigInteger skillId) { + Object raw = snapshot == null ? null : snapshot.get("skillBindings"); + if (!(raw instanceof List bindings)) { + return false; + } + return bindings.stream().anyMatch(item -> item instanceof Map binding + && skillId.toString().equals(String.valueOf(binding.get("skillId")))); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentToolBindingServiceImpl.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentToolBindingServiceImpl.java index d3c27566..e6f10db1 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentToolBindingServiceImpl.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/service/impl/AgentToolBindingServiceImpl.java @@ -55,6 +55,10 @@ public class AgentToolBindingServiceImpl extends ServiceImpl current = listAll(agentId); + if (AgentBindingSemanticComparator.sameTools(current, bindings)) { + return enabledBindings(current); + } validateBindings(agent, bindings); remove(QueryWrapper.create().where("agent_id = ?", agentId)); if (bindings == null || bindings.isEmpty()) { @@ -64,7 +68,7 @@ public class AgentToolBindingServiceImpl extends ServiceImpl listAll(BigInteger agentId) { + return list(QueryWrapper.create() + .where("agent_id = ?", agentId) + .orderBy("sort_no asc, id asc")); + } + + /** + * 从已加载或已写入的绑定中筛选启用项,避免替换后再次查询。 + * + * @param bindings 工具绑定 + * @return 启用绑定 + */ + private List enabledBindings(List bindings) { + return bindings.stream() + .filter(binding -> binding != null && binding.getEnabled() != Boolean.FALSE) + .toList(); + } + /** * 锁定并加载待修改的 Agent。 * diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/vo/AgentResourceOptionsView.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/vo/AgentResourceOptionsView.java index 244e20f8..1f37975f 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/vo/AgentResourceOptionsView.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/vo/AgentResourceOptionsView.java @@ -8,18 +8,56 @@ import java.util.List; * * @param models 模型选项 * @param knowledges 知识库选项 + * @param skills Skill 选项 * @param workflows 工作流选项 * @param pluginTools 插件工具选项 * @param mcps MCP 选项 + * @param capabilities 当前账号的 Agent 设计能力 */ public record AgentResourceOptionsView( List models, List knowledges, + List skills, List workflows, List pluginTools, - List mcps + List mcps, + Capabilities capabilities ) { + /** + * Agent 设计器的服务端权限能力。 + * + * @param canDisableShellApproval 是否允许关闭 Shell 调用前审批 + */ + public record Capabilities(boolean canDisableShellApproval) { + } + + /** + * 已发布 Skill 安全选择项。 + * + * @param id Skill ID + * @param displayName 展示名称 + * @param description 用途描述 + * @param visibilityScope 使用范围 + * @param snapshotHash 发布组合快照 hash + * @param toolCount 冻结 Tool 数量 + * @param textBytes Skill 文本投影 UTF-8 字节数 + * @param textResourceCount 文本资源数量 + * @param binaryResourceCount 二进制资源数量 + */ + public record SkillOption( + BigInteger id, + String displayName, + String description, + String visibilityScope, + String snapshotHash, + int toolCount, + long textBytes, + int textResourceCount, + int binaryResourceCount + ) { + } + /** * 模型安全选择项。 * diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/config/AgentBuiltinToolsConfigResolverTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/config/AgentBuiltinToolsConfigResolverTest.java new file mode 100644 index 00000000..f22a82f5 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/config/AgentBuiltinToolsConfigResolverTest.java @@ -0,0 +1,134 @@ +package tech.easyflow.agent.config; + +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.service.CategoryPermissionService; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Agent 内置工具默认值、兼容语义和 Shell 高风险确认测试。 + */ +public class AgentBuiltinToolsConfigResolverTest { + + /** + * 验证新草稿缺失配置时五项启用且仅 Shell 默认审批。 + */ + @Test + public void draftDefaultsShouldEnableFiveToolsAndApproveShellOnly() { + AgentBuiltinToolsConfigResolver resolver = resolver(false); + + AgentBuiltinToolsConfig config = resolver.resolveDraftRuntime(Map.of()); + + Assert.assertTrue(config.read().enabled()); + Assert.assertTrue(config.write().enabled()); + Assert.assertTrue(config.patch().enabled()); + Assert.assertTrue(config.shell().enabled()); + Assert.assertTrue(config.artifactPublish().enabled()); + Assert.assertFalse(config.read().approvalRequired()); + Assert.assertTrue(config.shell().approvalRequired()); + } + + /** + * 验证旧发布快照缺失内置工具配置时保持全部禁用。 + */ + @Test + public void legacyPublishedSnapshotShouldDisableAllBuiltinTools() { + AgentBuiltinToolsConfig config = resolver(false).resolvePublishedRuntime(Map.of()); + + Assert.assertFalse(config.read().enabled()); + Assert.assertFalse(config.write().enabled()); + Assert.assertFalse(config.patch().enabled()); + Assert.assertFalse(config.shell().enabled()); + Assert.assertFalse(config.artifactPublish().enabled()); + } + + /** + * 验证普通账号伪造确认字段仍无法关闭 Shell 审批。 + */ + @Test(expected = BusinessException.class) + public void ordinaryUserShouldNotDisableShellApprovalWithForgedConfirmation() { + resolver(false).normalizeForDraftSave(shellApprovalDisabled(true), Map.of(), new LoginAccount()); + } + + /** + * 验证平台超管确认后可关闭审批,且一次性确认字段不会持久化。 + */ + @Test + public void superAdminShouldDisableShellApprovalAfterConfirmationAndStripSignal() { + Map normalized = resolver(true).normalizeForDraftSave( + shellApprovalDisabled(true), Map.of(), new LoginAccount()); + + @SuppressWarnings("unchecked") + Map builtin = (Map) normalized.get("builtinTools"); + Assert.assertFalse(builtin.containsKey("shellApprovalRiskConfirmed")); + @SuppressWarnings("unchecked") + Map shell = (Map) builtin.get("shell"); + Assert.assertEquals(Boolean.FALSE, shell.get("approvalRequired")); + } + + /** + * 验证显式未知结构版本不会按 v1 静默解释。 + */ + @Test(expected = BusinessException.class) + public void futureSchemaVersionShouldBeRejected() { + resolver(false).resolveDraftRuntime(Map.of( + "builtinTools", Map.of("schemaVersion", 2))); + } + + /** + * 验证缺失版本号继续按 v1 兼容,并能识别真实关闭审批变更。 + */ + @Test + public void missingSchemaVersionShouldUseV1AndDetectApprovalTransition() { + AgentBuiltinToolsConfigResolver resolver = resolver(true); + Map source = shellApprovalDisabled(true); + + Assert.assertTrue(resolver.isShellApprovalDisableTransition(source, Map.of())); + Assert.assertEquals(AgentBuiltinToolsConfig.SCHEMA_VERSION, + resolver.resolveDraftRuntime(source).toMap().get("schemaVersion")); + } + + /** 验证 builtinTools 标量不会静默回退默认值。 */ + @Test(expected = BusinessException.class) + public void scalarBuiltinToolsShouldBeRejected() { + resolver(false).resolveDraftRuntime(Map.of("builtinTools", true)); + } + + /** 验证工具项标量不会静默回退默认值。 */ + @Test(expected = BusinessException.class) + public void scalarToolConfigShouldBeRejected() { + resolver(false).resolveDraftRuntime(Map.of("builtinTools", Map.of("read", true))); + } + + /** 验证布尔字段的字符串形式不会被宽松接受。 */ + @Test(expected = BusinessException.class) + public void stringBooleanShouldBeRejected() { + resolver(false).resolveDraftRuntime(Map.of( + "builtinTools", Map.of("read", Map.of("enabled", "true")))); + } + + /** 验证 schemaVersion 必须使用数值类型。 */ + @Test(expected = BusinessException.class) + public void stringSchemaVersionShouldBeRejected() { + resolver(false).resolveDraftRuntime(Map.of( + "builtinTools", Map.of("schemaVersion", "1"))); + } + + private AgentBuiltinToolsConfigResolver resolver(boolean superAdmin) { + CategoryPermissionService permissions = Mockito.mock(CategoryPermissionService.class); + Mockito.when(permissions.isSuperAdmin(Mockito.any())).thenReturn(superAdmin); + return new AgentBuiltinToolsConfigResolver(permissions); + } + + private Map shellApprovalDisabled(boolean confirmed) { + Map builtin = new LinkedHashMap<>(); + builtin.put("shell", Map.of("enabled", true, "approvalRequired", false)); + builtin.put("shellApprovalRiskConfirmed", confirmed); + return Map.of("builtinTools", builtin); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/distributed/AgentRuntimeCommandConsumerTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/distributed/AgentRuntimeCommandConsumerTest.java index 700a9e72..218a655d 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/distributed/AgentRuntimeCommandConsumerTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/distributed/AgentRuntimeCommandConsumerTest.java @@ -113,6 +113,29 @@ public class AgentRuntimeCommandConsumerTest { Assert.assertEquals("cmd-expire", resultRegistry.lastSuccessCommandId); } + /** + * 验证跨节点 AG-UI 审批命令在 owner 节点走脱敏决议入口。 + * + * @throws Exception 消息序列化异常 + */ + @Test + public void consumerShouldRouteAguiApprovalToResolvedEventPath() throws Exception { + AgentRuntimeProperties properties = new AgentRuntimeProperties(); + properties.setInstanceId("node-a"); + RecordingAgentRunService service = new RecordingAgentRunService(); + RecordingCommandResultRegistry resultRegistry = new RecordingCommandResultRegistry(); + AgentRuntimeCommandConsumer consumer = new AgentRuntimeCommandConsumer( + new ObjectMapper(), properties, new MQProperties(), service, resultRegistry); + AgentRuntimeCommandMessage command = command("cmd-agui", "node-a"); + command.setApprovalId("approval-public"); + + consumer.handle(List.of(message(command))); + + Assert.assertEquals(1, service.aguiApproveCount); + Assert.assertEquals("approval-public", service.lastApprovalId); + Assert.assertEquals(0, service.approveCount); + } + /** * 验证 Agent 集群取消命令只取消目标节点的对应 Agent 运行。 * @@ -163,10 +186,12 @@ public class AgentRuntimeCommandConsumerTest { private static final class RecordingAgentRunService extends AgentRunService { private int approveCount; + private int aguiApproveCount; private int expireCount; private String lastRequestId; private String lastReason; private String lastCancelledAgentId; + private String lastApprovalId; @Override public void approveRuntimeLocal(String requestId, String resumeToken, BigInteger operatorId, String userId) { @@ -174,6 +199,17 @@ public class AgentRuntimeCommandConsumerTest { lastRequestId = requestId; } + @Override + public void approveAguiRuntimeLocal(String requestId, + String resumeToken, + String approvalId, + BigInteger operatorId, + String userId) { + aguiApproveCount++; + lastRequestId = requestId; + lastApprovalId = approvalId; + } + @Override public void expireApprovalLocal(String requestId, String resumeToken, String reason) { expireCount++; diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandlerTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandlerTest.java index c603d0db..8b2db905 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandlerTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/publish/AgentApprovalSubjectHandlerTest.java @@ -111,6 +111,7 @@ public class AgentApprovalSubjectHandlerTest { toolBindingService, knowledgeBindingService, null, + null, immediateLockExecutor(), runRegistry, pendingService, @@ -151,6 +152,7 @@ public class AgentApprovalSubjectHandlerTest { null, null, null, + null, immediateLockExecutor(), mock(AgentRunRegistry.class), mock(AgentHitlPendingService.class), 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 1d8c84df..b1991140 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 @@ -1,7 +1,10 @@ package tech.easyflow.agent.runtime; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import com.easyagents.agent.runtime.AgentInitRequest; import com.easyagents.agent.runtime.AgentRuntime; +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.message.AgentKnowledgeReference; @@ -12,6 +15,7 @@ import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSess import org.junit.Assert; import org.junit.Test; import org.mockito.Mockito; +import org.slf4j.LoggerFactory; import tech.easyflow.agent.entity.AgentHitlPending; import tech.easyflow.agent.entity.Agent; import tech.easyflow.agent.entity.AgentKnowledgeBinding; @@ -25,6 +29,9 @@ import tech.easyflow.agent.runtime.document.AgentDocumentContext; import tech.easyflow.agent.runtime.lock.AgentRunLock; import tech.easyflow.agent.runtime.media.AgentBoundMedia; import tech.easyflow.agent.runtime.media.AgentMediaService; +import tech.easyflow.agent.runtime.output.AgentRunOutput; +import tech.easyflow.agent.runtime.output.LegacyAgentRunOutput; +import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeProjector; import tech.easyflow.chatlog.domain.dto.ChatSessionSummary; import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.web.exceptions.BusinessException; @@ -34,6 +41,7 @@ import tech.easyflow.core.chat.protocol.ChatType; import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter; import tech.easyflow.core.runtime.ChatAssistantAccumulator; import tech.easyflow.core.runtime.ChatRuntimeContext; +import tech.easyflow.core.runtime.ChatRuntimeExtKeys; import tech.easyflow.core.runtime.ChatRuntimeManager; import tech.easyflow.core.runtime.ChatRuntimeMessage; import tech.easyflow.core.runtime.LegacyThinkingTagParser; @@ -64,9 +72,15 @@ public class AgentRunServiceDraftAndHitlTest { event.getPayload().put("agentId", "agent-1"); event.getPayload().put("toolName", "search"); event.getPayload().put("toolType", "PLUGIN"); + event.getMetadata().put("approvalId", "approval-1"); event.getPayload().put("approvalPrompt", "不应透出"); - event.getPayload().put("toolInput", Map.of("keyword", "EasyFlow")); + event.getPayload().put("toolInput", Map.of( + "authorization", "sentinel-secret-authorization", + "callbackUrl", "https://example.test/callback?token=sentinel-secret-query", + "keyword", "EasyFlow", + "nested", Map.of("apiKey", "sentinel-secret-api-key"))); event.getPayload().put("approvalMetadata", Map.of( + "credential", "sentinel-secret-metadata", "risk", "low", "prompt", "不应透出", "toolType", "WORKFLOW" @@ -76,16 +90,21 @@ public class AgentRunServiceDraftAndHitlTest { new Class[]{String.class, AgentRuntimeEvent.class}, "request-1", event); Assert.assertEquals("request-1", payload.getRequestId()); - Assert.assertEquals("token-1", payload.getResumeToken()); + Assert.assertEquals("approval-1", payload.getApprovalId()); Assert.assertEquals("session-1", payload.getSessionId()); Assert.assertEquals("agent-1", payload.getAgentId()); Assert.assertEquals("call-1", payload.getToolCallId()); Assert.assertEquals("search", payload.getToolName()); Assert.assertEquals("PLUGIN", payload.getToolType()); Assert.assertEquals("EasyFlow", payload.getInput().get("keyword")); + Assert.assertEquals("[已隐藏]", payload.getInput().get("authorization")); + Assert.assertEquals("https://example.test/callback?token=[已隐藏]", + payload.getInput().get("callbackUrl")); + Assert.assertFalse(payload.getInput().toString().contains("sentinel-secret")); Assert.assertEquals("low", payload.getMetadata().get("risk")); Assert.assertEquals("PLUGIN", payload.getMetadata().get("toolType")); Assert.assertFalse(payload.getMetadata().containsKey("prompt")); + Assert.assertFalse(payload.getMetadata().toString().contains("sentinel-secret")); } /** @@ -99,14 +118,18 @@ public class AgentRunServiceDraftAndHitlTest { AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_RESULT); event.setToolCallId("call-runtime"); event.getPayload().put("toolName", "search"); - event.getPayload().put("text", "ok"); + event.getPayload().put("text", "sentinel-secret-result"); + event.getPayload().put("result", Map.of("token", "sentinel-secret-token")); + event.getMetadata().put("authorization", "sentinel-secret-metadata"); Map payload = invoke(service, "buildToolEventPayload", new Class[]{AgentRuntimeEvent.class}, event); Assert.assertEquals("call-runtime", payload.get("toolCallId")); Assert.assertEquals("search", payload.get("toolName")); - Assert.assertEquals("ok", payload.get("text")); + Assert.assertFalse(payload.containsKey("text")); + Assert.assertFalse(payload.containsKey("result")); + Assert.assertFalse(payload.toString().contains("sentinel-secret")); } /** @@ -123,7 +146,7 @@ public class AgentRunServiceDraftAndHitlTest { String reasoning = invoke(service, "stringPayload", new Class[]{AgentRuntimeEvent.class, String.class}, event, "reasoning"); String fallback = invoke(service, "firstText", - new Class[]{String.class, String.class}, reasoning, "正文"); + new Class[]{String[].class}, (Object) new String[]{reasoning, "正文"}); Assert.assertEquals("思考中", fallback); } @@ -142,7 +165,7 @@ public class AgentRunServiceDraftAndHitlTest { invoke(service, "handleRuntimeEvent", runtimeEventParameterTypes(), - event, "request-1", emitter, new StringBuilder(), new ChatAssistantAccumulator(), + event, "request-1", legacyOutput(emitter), new StringBuilder(), new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false); Assert.assertEquals(1, emitter.envelopes.size()); @@ -169,7 +192,7 @@ public class AgentRunServiceDraftAndHitlTest { invoke(service, "handleRuntimeEvent", runtimeEventParameterTypes(), - event, "request-1", emitter, answer, new ChatAssistantAccumulator(), + event, "request-1", legacyOutput(emitter), answer, new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false); Assert.assertEquals("正文增量", answer.toString()); @@ -201,7 +224,7 @@ public class AgentRunServiceDraftAndHitlTest { event.getPayload().put("text", delta); invoke(service, "handleRuntimeEvent", legacyRuntimeEventParameterTypes(), - event, "request-legacy-thinking", emitter, answer, assistantAccumulator, + event, "request-legacy-thinking", legacyOutput(emitter), answer, assistantAccumulator, parser, chatContext(), finished, false); } @@ -209,7 +232,7 @@ public class AgentRunServiceDraftAndHitlTest { completed.getPayload().put("text", "先分析\n最终回答"); invoke(service, "handleRuntimeEvent", legacyRuntimeEventParameterTypes(), - completed, "request-legacy-thinking", emitter, answer, assistantAccumulator, + completed, "request-legacy-thinking", legacyOutput(emitter), answer, assistantAccumulator, parser, chatContext(), finished, false); StringBuilder reasoning = new StringBuilder(); @@ -256,7 +279,7 @@ public class AgentRunServiceDraftAndHitlTest { invoke(service, "handleRuntimeEvent", runtimeEventParameterTypes(), - event, "request-1", emitter, new StringBuilder(), new ChatAssistantAccumulator(), + event, "request-1", legacyOutput(emitter), new StringBuilder(), new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false); Assert.assertEquals(1, emitter.envelopes.size()); @@ -269,6 +292,33 @@ public class AgentRunServiceDraftAndHitlTest { Assert.assertEquals("正在整理上下文", payload.get("label")); } + /** + * 验证知识检索状态不会携带命中文档和内部 metadata。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + public void handleRuntimeEventShouldWhitelistKnowledgeStatusPayload() 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"))); + 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", "done", + "statusKey", "knowledge-retrieval"), payload); + } + /** * 验证完成事件不会再次发送正文消息,只用于最终收口。 * @@ -285,7 +335,7 @@ public class AgentRunServiceDraftAndHitlTest { invoke(service, "handleRuntimeEvent", runtimeEventParameterTypes(), - event, "request-1", emitter, answer, new ChatAssistantAccumulator(), + event, "request-1", legacyOutput(emitter), answer, new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false); Assert.assertEquals("最终正文", answer.toString()); @@ -311,7 +361,7 @@ public class AgentRunServiceDraftAndHitlTest { "request-suspended", "session-suspended", new NoopRuntime(), - emitter, + legacyOutput(emitter), chatContext(), new StringBuilder(), new ChatAssistantAccumulator(), @@ -330,9 +380,9 @@ public class AgentRunServiceDraftAndHitlTest { runContext.markSuspended(); invoke(service, "finishIfNeeded", - new Class[]{String.class, ChatSseEmitter.class, ChatRuntimeContext.class, StringBuilder.class, + new Class[]{String.class, AgentRunOutput.class, ChatRuntimeContext.class, StringBuilder.class, ChatAssistantAccumulator.class, AtomicBoolean.class, boolean.class}, - "request-suspended", emitter, chatContext(), new StringBuilder(), + "request-suspended", legacyOutput(emitter), chatContext(), new StringBuilder(), new ChatAssistantAccumulator(), finished, false); Assert.assertFalse(finished.get()); @@ -340,6 +390,34 @@ public class AgentRunServiceDraftAndHitlTest { Assert.assertTrue(emitter.envelopes.isEmpty()); } + /** + * 验证要求显式终态的协议遇到自然 EOF 时记录失败,避免数据库完成态与 UI 错误态矛盾。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + public void finishIfNeededShouldRecordFailureWhenProtocolTerminalIsMissing() throws Exception { + AgentRunService service = new AgentRunService(); + RecordingChatRuntimeManager chatRuntimeManager = new RecordingChatRuntimeManager(); + setField(service, "agentRunRegistry", new AgentRunRegistry()); + setField(service, "chatRuntimeManager", chatRuntimeManager); + AgentRunOutput output = Mockito.mock(AgentRunOutput.class); + Mockito.when(output.canFinishSuccessfully()).thenReturn(false); + Mockito.when(output.emitViewEvent(Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(true); + AtomicBoolean finished = new AtomicBoolean(false); + + invoke(service, "finishIfNeeded", + new Class[]{String.class, AgentRunOutput.class, ChatRuntimeContext.class, StringBuilder.class, + ChatAssistantAccumulator.class, AtomicBoolean.class, boolean.class}, + "request-missing-terminal", output, chatContext(), new StringBuilder(), + new ChatAssistantAccumulator(), finished, true); + + Assert.assertTrue(finished.get()); + Assert.assertEquals(1, chatRuntimeManager.recordFailureCount); + Assert.assertEquals(0, chatRuntimeManager.recordCompletedCount); + Mockito.verify(output).complete(); + } + /** * 验证取消事件作为业务状态收口,不按系统错误发送。 * @@ -358,7 +436,7 @@ public class AgentRunServiceDraftAndHitlTest { invoke(service, "handleRuntimeEvent", runtimeEventParameterTypes(), - event, "request-1", emitter, answer, new ChatAssistantAccumulator(), + event, "request-1", legacyOutput(emitter), answer, new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), true); Assert.assertEquals(2, emitter.envelopes.size()); @@ -373,6 +451,8 @@ public class AgentRunServiceDraftAndHitlTest { Assert.assertEquals(ChatType.DONE, emitter.envelopes.get(1).getType()); Assert.assertEquals(1, chatRuntimeManager.recordAssistantCompletedCount); Assert.assertEquals("取消前正文", chatRuntimeManager.lastAssistantMessage.getContentText()); + Assert.assertEquals("CANCELLED", + chatRuntimeManager.lastAssistantMessage.getContentPayload().get("terminalStatus")); Assert.assertEquals(1, chatRuntimeManager.recordFailureCount); } @@ -423,8 +503,8 @@ public class AgentRunServiceDraftAndHitlTest { "previewUrl", "/api/v1/agent/media/content?reference=formal:101:201:0:png")); boolean sent = invoke(service, "sendInputAccepted", - new Class[]{ChatSseEmitter.class, BigInteger.class, BigInteger.class, List.class, List.class}, - emitter, BigInteger.valueOf(101), BigInteger.valueOf(201), List.of(image), List.of()); + new Class[]{AgentRunOutput.class, BigInteger.class, BigInteger.class, List.class, List.class}, + legacyOutput(emitter), BigInteger.valueOf(101), BigInteger.valueOf(201), List.of(image), List.of()); Assert.assertTrue(sent); Assert.assertEquals(1, emitter.envelopes.size()); @@ -444,6 +524,10 @@ public class AgentRunServiceDraftAndHitlTest { @Test public void buildDraftAgentShouldGenerateRuntimeIdForUnsavedAgent() throws Exception { AgentRunService service = new AgentRunService(); + AgentSkillRuntimeProjector projector = Mockito.mock(AgentSkillRuntimeProjector.class); + Mockito.when(projector.projectCurrentBindings(Mockito.any(), Mockito.anyList())) + .thenReturn(List.of()); + setField(service, "agentSkillRuntimeProjector", projector); AgentDraftChatRequest request = new AgentDraftChatRequest(); Agent agent = new Agent(); agent.setModelId(BigInteger.valueOf(10)); @@ -543,11 +627,11 @@ public class AgentRunServiceDraftAndHitlTest { invoke(service, "startRuntime", new Class[]{Agent.class, AgentMessage.class, AgentDocumentContext.class, LoginAccount.class, String.class, String.class, - String.class, String.class, ChatRuntimeContext.class, ChatSseEmitter.class, boolean.class, + String.class, String.class, ChatRuntimeContext.class, AgentRunOutput.class, boolean.class, AgentSessionStore.class, AgentRunLock.Handle.class}, agent, AgentMessage.text(AgentMessageRole.USER, "你好"), AgentDocumentContext.empty(), account, "request-draft", "trace-draft", "agent-draft-100", "AGENT_DRAFT", - chatContext(), new RecordingChatSseEmitter(), false, draftStore, null); + chatContext(), legacyOutput(new RecordingChatSseEmitter()), false, draftStore, null); Assert.assertSame(draftStore, runtime.initRequest.getSessionStore()); } @@ -568,19 +652,107 @@ public class AgentRunServiceDraftAndHitlTest { invoke(service, "handleRuntimeEvent", runtimeEventParameterTypes(), - draftEvent, "request-draft", new RecordingChatSseEmitter(), new StringBuilder(), + draftEvent, "request-draft", legacyOutput(new RecordingChatSseEmitter()), new StringBuilder(), new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false); Assert.assertEquals(0, recorder.recordCount); AgentRuntimeEvent formalEvent = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_CALL); formalEvent.getPayload().put("toolName", "search"); - invoke(service, "handleRuntimeEvent", - runtimeEventParameterTypes(), - formalEvent, "request-formal", new RecordingChatSseEmitter(), new StringBuilder(), - new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), true); + formalEvent.getPayload().put("input", Map.of("apiKey", "sentinel-secret-input")); + formalEvent.getMetadata().put("authorization", "sentinel-secret-metadata"); + RecordingChatSseEmitter formalEmitter = new RecordingChatSseEmitter(); + ChatAssistantAccumulator formalAccumulator = new ChatAssistantAccumulator(); + ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) + LoggerFactory.getLogger(AgentRunService.class); + ListAppender logAppender = new ListAppender<>(); + logAppender.start(); + logger.addAppender(logAppender); + try { + invoke(service, "handleRuntimeEvent", + runtimeEventParameterTypes(), + formalEvent, "request-formal", legacyOutput(formalEmitter), new StringBuilder(), + formalAccumulator, chatContext(), new AtomicBoolean(false), true); + } finally { + logger.detachAppender(logAppender); + logAppender.stop(); + } Assert.assertEquals(1, recorder.recordCount); + Assert.assertNotNull(recorder.lastEvent); + Assert.assertFalse(recorder.lastEvent.getPayload().toString().contains("sentinel-secret")); + Assert.assertFalse(recorder.lastEvent.getMetadata().toString().contains("sentinel-secret")); + Assert.assertFalse(formalEmitter.envelopes.stream() + .map(ChatEnvelope::getPayload) + .map(String::valueOf) + .anyMatch(payload -> payload.contains("sentinel-secret"))); + Assert.assertFalse(formalAccumulator.buildPayload("").toString().contains("sentinel-secret")); + Assert.assertFalse(logAppender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .anyMatch(message -> message.contains("sentinel-secret"))); + } + + /** + * 验证 artifact_publish 内部投影会形成安全实时状态并进入 assistant 历史 payload。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + public void handleRuntimeEventShouldProjectArtifactToLiveAndHistory() throws Exception { + AgentRunService service = new AgentRunService(); + setField(service, "agentRunRegistry", new AgentRunRegistry()); + RecordingChatSseEmitter emitter = new RecordingChatSseEmitter(); + ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator(); + AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_RESULT); + event.getPayload().put("artifactProjectionOnly", true); + event.getPayload().put("artifactPublished", Map.of( + "schemaVersion", 1, + "artifactId", "a1", + "fileName", "report.csv", + "mimeType", "text/csv", + "size", 12, + "sha256", "abc", + "downloadUrl", "/api/v1/agent/artifacts/a1/content", + "status", "AVAILABLE", + "objectKey", "private/object/key")); + + invoke(service, "handleRuntimeEvent", runtimeEventParameterTypes(), + event, "request-artifact", legacyOutput(emitter), new StringBuilder(), accumulator, + chatContext(), new AtomicBoolean(false), false); + + Assert.assertEquals(1, emitter.envelopes.size()); + Assert.assertEquals(ChatType.STATUS, emitter.envelopes.get(0).getType()); + @SuppressWarnings("unchecked") + Map live = (Map) emitter.envelopes.get(0).getPayload(); + Assert.assertEquals("artifact-published", live.get("statusKey")); + Assert.assertFalse(live.containsKey("objectKey")); + @SuppressWarnings("unchecked") + List> artifacts = (List>) accumulator + .buildPayload("done").get("artifacts"); + Assert.assertEquals("a1", artifacts.get(0).get("artifactId")); + Assert.assertFalse(artifacts.get(0).containsKey("objectKey")); + } + + /** + * 验证当前轮次 ID 由服务端聊天上下文注入 RuntimeContext,供产物账本可信绑定。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + public void buildAgentRuntimeContextShouldCarryTrustedRoundId() throws Exception { + AgentRunService service = new AgentRunService(); + ChatRuntimeContext context = chatContext(); + context.getExt().put(ChatRuntimeExtKeys.CURRENT_ROUND_ID, BigInteger.valueOf(200)); + context.getExt().put(ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX, 2); + + AgentRuntimeContext runtimeContext = invoke(service, "buildAgentRuntimeContext", + new Class[]{ChatRuntimeContext.class, String.class, String.class}, + context, "trace-1", "100"); + + Assert.assertEquals("200", + runtimeContext.getMetadata().get(ChatRuntimeExtKeys.CURRENT_ROUND_ID)); + Assert.assertEquals(2, + runtimeContext.getMetadata().get(ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX)); } /** @@ -648,14 +820,64 @@ public class AgentRunServiceDraftAndHitlTest { registry.register(runContext("request-draft", "agent-draft-tool", false)); AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED); event.getPayload().put("resumeToken", "token-draft"); + event.getPayload().put("toolName", "search"); + event.getPayload().put("toolInput", Map.of( + "authorization", "sentinel-secret-authorization", + "keyword", "EasyFlow")); + RecordingChatSseEmitter emitter = new RecordingChatSseEmitter(); invoke(service, "handleRuntimeEvent", runtimeEventParameterTypes(), - event, "request-draft", new RecordingChatSseEmitter(), new StringBuilder(), + event, "request-draft", legacyOutput(emitter), new StringBuilder(), new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false); Assert.assertTrue(registry.containsResumeTarget("request-draft", "token-draft")); Assert.assertEquals(0, pendingService.recordApprovalRequiredCount); + Assert.assertEquals(1, emitter.envelopes.size()); + AgentToolHitlPayload payload = (AgentToolHitlPayload) emitter.envelopes.get(0).getPayload(); + Assert.assertNotNull(payload.getApprovalId()); + Assert.assertFalse(payload.getApprovalId().isBlank()); + Assert.assertEquals("EasyFlow", payload.getInput().get("keyword")); + Assert.assertEquals("[已隐藏]", payload.getInput().get("authorization")); + Assert.assertFalse(payload.getInput().toString().contains("sentinel-secret")); + } + + /** + * 验证正式工具审批事件在生成公开审批 ID 后才持久化,并移除内部恢复令牌与敏感参数。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + public void formalToolApprovalShouldPersistPublicApprovalIdOnly() throws Exception { + AgentRunService service = new AgentRunService(); + AgentRunRegistry registry = new AgentRunRegistry(); + RecordingAgentRunEventRecorder recorder = new RecordingAgentRunEventRecorder(); + RecordingAgentHitlPendingService pendingService = new RecordingAgentHitlPendingService(); + setField(service, "agentRunRegistry", registry); + setField(service, "agentRunEventRecorder", recorder); + setField(service, "agentHitlPendingService", pendingService); + registry.register(runContext("request-formal-tool", "session-formal-tool", true)); + AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED); + event.getPayload().put("resumeToken", "sentinel-secret-resume-token"); + event.getPayload().put("toolName", "search"); + event.getPayload().put("toolInput", Map.of( + "password", "sentinel-secret-password", + "keyword", "EasyFlow")); + + invoke(service, "handleRuntimeEvent", + runtimeEventParameterTypes(), + event, "request-formal-tool", legacyOutput(new RecordingChatSseEmitter()), new StringBuilder(), + new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), true); + + Assert.assertEquals(1, recorder.recordCount); + Assert.assertNotNull(recorder.lastEvent); + Assert.assertNotNull(recorder.lastEvent.getMetadata().get("approvalId")); + Assert.assertFalse(recorder.lastEvent.getMetadata().get("approvalId").toString().isBlank()); + Assert.assertFalse(recorder.lastEvent.getPayload().containsKey("resumeToken")); + Assert.assertEquals("EasyFlow", + ((Map) recorder.lastEvent.getPayload().get("toolInput")).get("keyword")); + Assert.assertFalse(recorder.lastEvent.getPayload().toString().contains("sentinel-secret")); + Assert.assertEquals(1, pendingService.recordApprovalRequiredCount); } /** @@ -768,6 +990,50 @@ public class AgentRunServiceDraftAndHitlTest { Assert.assertEquals("request-remote-expire", commandProducer.lastRequestId); } + /** + * 验证本机审批过期会先发送决议事件,再恢复 runtime 的拒绝分支并清理公开审批 ID。 + */ + @Test + public void expireApprovalLocalShouldResolveCardAndRejectRuntime() { + AgentRunService service = new AgentRunService(); + AgentRunRegistry registry = new AgentRunRegistry(); + RecordingAgentRuntime runtime = new RecordingAgentRuntime(); + AgentRunOutput output = Mockito.mock(AgentRunOutput.class); + Mockito.when(output.emitViewEvent(Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(true); + setFieldUnchecked(service, "agentRunRegistry", registry); + registry.register(new AgentRunRegistry.AgentRunContext( + "request-local-expire", + "session-local-expire", + runtime, + output, + chatContext(), + new StringBuilder(), + new ChatAssistantAccumulator(), + new AtomicBoolean(false), + false, + new AgentRunRegistry.RunOwner("agent-1", "session-local-expire", "1"), + null, + event -> { + }, + error -> { + }, + () -> { + })); + registry.registerResumeToken("request-local-expire", "token-local-expire"); + String approvalId = registry.registerApproval("request-local-expire", "token-local-expire"); + + service.expireApprovalLocal("request-local-expire", "token-local-expire", "审批已过期"); + + Assert.assertEquals(1, runtime.resumeCount); + Assert.assertNull(registry.findApprovalId("request-local-expire", "token-local-expire")); + Mockito.verify(output).emitViewEvent( + Mockito.eq(ChatDomain.TOOL), + Mockito.eq(ChatType.FORM_CANCEL), + Mockito.argThat(payload -> payload instanceof Map map + && approvalId.equals(map.get("approvalId")) + && "EXPIRED".equals(map.get("status")))); + } + /** * 验证 owner 缺失时明确失败。 * @@ -861,7 +1127,8 @@ public class AgentRunServiceDraftAndHitlTest { setField(service, "draftAgentSessionStore", draftStore); invoke(service, "clearDraftSessionInternal", - new Class[]{String.class, String.class}, "agent-draft-clear", "1"); + new Class[]{String.class, String.class, String.class}, + "agent-draft-clear", "1", "1"); Assert.assertEquals("agent-draft-clear", draftStore.deletedSessionKey); Assert.assertEquals(0, pendingService.deleteByRuntimeSessionIdCount); @@ -878,7 +1145,8 @@ public class AgentRunServiceDraftAndHitlTest { RecordingChatSseEmitter emitter = new RecordingChatSseEmitter(); Boolean sent = invoke(service, "sendSessionCreated", - new Class[]{ChatSseEmitter.class, BigInteger.class}, emitter, BigInteger.valueOf(123)); + new Class[]{AgentRunOutput.class, BigInteger.class}, + legacyOutput(emitter), BigInteger.valueOf(123)); Assert.assertTrue(sent); Assert.assertEquals(1, emitter.envelopes.size()); @@ -934,7 +1202,7 @@ public class AgentRunServiceDraftAndHitlTest { "request-disconnected", "session-disconnected", new NoopRuntime(), - new FailingChatSseEmitter(), + legacyOutput(new FailingChatSseEmitter()), context, new StringBuilder(), new ChatAssistantAccumulator(), @@ -957,7 +1225,7 @@ public class AgentRunServiceDraftAndHitlTest { invoke(service, "handleRuntimeEvent", runtimeEventParameterTypes(), - event, "request-disconnected", new FailingChatSseEmitter(), answer, + event, "request-disconnected", legacyOutput(new FailingChatSseEmitter()), answer, assistantAccumulator, context, finished, true); Assert.assertTrue(finished.get()); @@ -981,14 +1249,22 @@ public class AgentRunServiceDraftAndHitlTest { field.set(target, value); } + private void setFieldUnchecked(Object target, String fieldName, Object value) { + try { + setField(target, fieldName, value); + } catch (Exception exception) { + throw new AssertionError(exception); + } + } + private Class[] runtimeEventParameterTypes() { - return new Class[]{AgentRuntimeEvent.class, String.class, ChatSseEmitter.class, StringBuilder.class, + return new Class[]{AgentRuntimeEvent.class, String.class, AgentRunOutput.class, StringBuilder.class, ChatAssistantAccumulator.class, ChatRuntimeContext.class, AtomicBoolean.class, boolean.class}; } private Class[] legacyRuntimeEventParameterTypes() { - return new Class[]{AgentRuntimeEvent.class, String.class, ChatSseEmitter.class, StringBuilder.class, + return new Class[]{AgentRuntimeEvent.class, String.class, AgentRunOutput.class, StringBuilder.class, ChatAssistantAccumulator.class, LegacyThinkingTagParser.class, ChatRuntimeContext.class, AtomicBoolean.class, boolean.class}; } @@ -998,7 +1274,7 @@ public class AgentRunServiceDraftAndHitlTest { requestId, sessionId, new RecordingAgentRuntime(), - new RecordingChatSseEmitter(), + legacyOutput(new RecordingChatSseEmitter()), chatContext(), new StringBuilder(), new ChatAssistantAccumulator(), @@ -1015,6 +1291,10 @@ public class AgentRunServiceDraftAndHitlTest { ); } + private AgentRunOutput legacyOutput(ChatSseEmitter emitter) { + return new LegacyAgentRunOutput(emitter); + } + private ChatRuntimeContext chatContext() { ChatRuntimeContext context = new ChatRuntimeContext(); context.setAssistantId(BigInteger.valueOf(100)); @@ -1209,10 +1489,12 @@ public class AgentRunServiceDraftAndHitlTest { private static class RecordingAgentRunEventRecorder implements AgentRunEventRecorder { private int recordCount; + private AgentRuntimeEvent lastEvent; @Override public void record(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) { recordCount++; + lastEvent = event; } } diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRuntimeCompilerModelConfigTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRuntimeCompilerModelConfigTest.java index aae1b12a..1f779710 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRuntimeCompilerModelConfigTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRuntimeCompilerModelConfigTest.java @@ -5,15 +5,21 @@ import com.easyagents.agent.runtime.model.AgentGenerationOptions; import com.easyagents.agent.runtime.model.AgentHttpVersionPolicy; import com.easyagents.agent.runtime.model.AgentMessageContentFormat; import com.easyagents.agent.runtime.model.AgentModelSpec; +import com.easyagents.agent.runtime.tool.AgentToolSpec; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.Assert; import org.junit.Test; +import tech.easyflow.agent.config.AgentBuiltinToolsConfig; import tech.easyflow.agent.entity.Agent; import tech.easyflow.ai.entity.Model; import tech.easyflow.ai.service.ModelService; import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; import java.util.Map; /** @@ -230,6 +236,65 @@ public class AgentRuntimeCompilerModelConfigTest { Assert.assertEquals(50, policy.getMaxAttachedMessageCount()); } + /** + * 合并直接 Tool 与 Skill Tool 后超过统一数量预算时应拒绝。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + public void runtimeToolBudgetShouldRejectMoreThanOneHundredTwentyEightTools() throws Exception { + AgentRuntimeCompiler compiler = compilerWithObjectMapper(); + List tools = new ArrayList<>(); + for (int index = 0; index < 129; index++) { + AgentToolSpec spec = new AgentToolSpec(); + spec.setName("tool_" + index); + spec.setParametersSchema(Map.of("type", "object")); + tools.add(spec); + } + + assertBudgetFailure(compiler, tools, "数量超过 128"); + } + + /** + * 合并后的 Tool Schema 超过统一字节预算时应拒绝。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + public void runtimeToolBudgetShouldRejectOversizedSchemas() throws Exception { + AgentRuntimeCompiler compiler = compilerWithObjectMapper(); + AgentToolSpec spec = new AgentToolSpec(); + spec.setName("oversized_tool"); + spec.setParametersSchema(Map.of( + "type", "object", + "description", "x".repeat(2 * 1024 * 1024))); + + assertBudgetFailure(compiler, List.of(spec), "Schema 超过 2 MiB"); + } + + /** + * 验证产物发布工具会明确要求模型主动发布用户需要的最终文件。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + public void artifactPublishToolShouldRequirePublishingFinalDeliverables() throws Exception { + AgentRuntimeCompiler compiler = new AgentRuntimeCompiler(); + Method method = AgentRuntimeCompiler.class.getDeclaredMethod( + "buildArtifactPublishSpec", AgentBuiltinToolsConfig.ToolSwitch.class); + method.setAccessible(true); + + AgentToolSpec spec = (AgentToolSpec) method.invoke( + compiler, new AgentBuiltinToolsConfig.ToolSwitch(true, false)); + + Assert.assertTrue(spec.getDescription().contains("MUST call this tool")); + Assert.assertTrue(spec.getDescription().contains("Do not finish with only a workspace path")); + Assert.assertTrue(spec.getDescription().contains("Do not publish temporary files")); + Map properties = (Map) spec.getParametersSchema().get("properties"); + Map path = (Map) properties.get("path"); + Assert.assertTrue(String.valueOf(path.get("description")).contains("completed final file")); + } + /** * 创建已注入模型服务的编译器。 * @@ -249,6 +314,43 @@ public class AgentRuntimeCompilerModelConfigTest { return compiler; } + /** + * 创建只注入 JSON 映射器的 Runtime 编译器。 + * + * @return 编译器 + * @throws Exception 反射注入失败时抛出 + */ + private AgentRuntimeCompiler compilerWithObjectMapper() throws Exception { + AgentRuntimeCompiler compiler = new AgentRuntimeCompiler(); + Field field = AgentRuntimeCompiler.class.getDeclaredField("objectMapper"); + field.setAccessible(true); + field.set(compiler, new ObjectMapper()); + return compiler; + } + + /** + * 断言统一 Tool 预算校验失败。 + * + * @param compiler Runtime 编译器 + * @param toolSpecs Tool 声明 + * @param messageFragment 错误消息片段 + * @throws Exception 反射调用失败时抛出 + */ + private void assertBudgetFailure(AgentRuntimeCompiler compiler, + List toolSpecs, + String messageFragment) throws Exception { + Method method = AgentRuntimeCompiler.class.getDeclaredMethod( + "assertToolBudget", List.class, List.class); + method.setAccessible(true); + try { + method.invoke(compiler, toolSpecs, List.of()); + Assert.fail("Expected Agent Runtime tool budget failure"); + } catch (InvocationTargetException exception) { + Assert.assertTrue(exception.getCause().getMessage(), + exception.getCause().getMessage().contains(messageFragment)); + } + } + /** * 创建测试模型。 * diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/agui/AgentAguiRunInputMapperTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/agui/AgentAguiRunInputMapperTest.java new file mode 100644 index 00000000..7fb18e2b --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/agui/AgentAguiRunInputMapperTest.java @@ -0,0 +1,223 @@ +package tech.easyflow.agent.runtime.agui; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.agui.model.AguiMessage; +import io.agentscope.core.agui.model.AguiTool; +import io.agentscope.core.agui.model.RunAgentInput; +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.runtime.AgentChatRequest; +import tech.easyflow.agent.runtime.AgentDraftChatRequest; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; + +/** + * {@link AgentAguiRunInputMapper} 的输入裁剪与安全测试。 + */ +public class AgentAguiRunInputMapperTest { + + private final AgentAguiRunInputMapper mapper = new AgentAguiRunInputMapper(new ObjectMapper()); + + /** + * 验证正式入口只提取本轮用户消息和白名单 forwardedProps。 + */ + @Test + public void shouldMapSingleUserMessageAndWhitelistedInput() { + RunAgentInput input = input( + "123", + "run-1", + List.of(AguiMessage.userMessage("current", "新问题")), + Map.of("easyflow", Map.of("input", Map.of( + "imageUploadIds", List.of("image-1"), + "documentUploadIds", List.of("document-1"))))); + + AgentChatRequest request = mapper.toFormalRequest(BigInteger.TEN, input); + AgentAguiWireContext wireContext = mapper.wireContext(input); + + Assert.assertEquals(BigInteger.TEN, request.getAgentId()); + Assert.assertEquals(new BigInteger("123"), request.getSessionId()); + Assert.assertEquals("新问题", request.getPrompt()); + Assert.assertEquals(List.of("image-1"), request.getImageUploadIds()); + Assert.assertEquals(List.of("document-1"), request.getDocumentUploadIds()); + Assert.assertEquals("run-1", wireContext.runId()); + Assert.assertEquals("current", wireContext.userMessageId()); + Assert.assertEquals("新问题", wireContext.userMessageContent()); + } + + /** + * 验证草稿入口从 EasyFlow 命名空间恢复现有草稿请求。 + */ + @Test + public void shouldMapDraftSnapshot() { + Agent agent = new Agent(); + agent.setName("草稿 Agent"); + RunAgentInput input = input( + "agent-draft-123", + "run-2", + List.of(AguiMessage.userMessage("message-1", "试一下")), + Map.of("easyflow", Map.of("draft", Map.of("agent", agent)))); + + AgentDraftChatRequest request = mapper.toDraftRequest(input); + + Assert.assertEquals("agent-draft-123", request.getSessionId()); + Assert.assertEquals("试一下", request.getPrompt()); + Assert.assertEquals("草稿 Agent", request.getAgent().getName()); + } + + /** + * 验证草稿入口只接收 Skill ID 与排序号,不接受客户端伪造服务端快照。 + */ + @Test + public void shouldMapWhitelistedSkillBindingsAndRejectServerFields() { + Agent agent = new Agent(); + agent.setName("草稿 Agent"); + RunAgentInput valid = input( + "agent-draft-123", + "run-skill-valid", + List.of(AguiMessage.userMessage("message-1", "试一下")), + Map.of("easyflow", Map.of("draft", Map.of( + "agent", agent, + "skillBindings", List.of(Map.of("skillId", "101", "sortNo", 3)))))); + + AgentDraftChatRequest request = mapper.toDraftRequest(valid); + + Assert.assertEquals(1, request.getSkillBindings().size()); + Assert.assertEquals(BigInteger.valueOf(101), request.getSkillBindings().get(0).getSkillId()); + Assert.assertEquals(Integer.valueOf(3), request.getSkillBindings().get(0).getSortNo()); + Assert.assertTrue(request.getSkillBindings().get(0).getResourceSnapshot().isEmpty()); + Assert.assertTrue(request.getSkillBindings().get(0).getResourceSummary().isEmpty()); + + RunAgentInput forged = input( + "agent-draft-123", + "run-skill-forged", + List.of(AguiMessage.userMessage("message-2", "试一下")), + Map.of("easyflow", Map.of("draft", Map.of( + "agent", agent, + "skillBindings", List.of(Map.of( + "skillId", "101", "resourceSnapshot", Map.of("skillContent", "forged"))))))); + + Assert.assertThrows(BusinessException.class, () -> mapper.toDraftRequest(forged)); + } + + /** + * 验证单次草稿试用最多接受二十个 Skill 引用。 + */ + @Test + public void shouldRejectTooManySkillBindings() { + Agent agent = new Agent(); + agent.setName("草稿 Agent"); + List> bindings = java.util.stream.IntStream.rangeClosed(1, 21) + .mapToObj(index -> Map.of("skillId", index)) + .toList(); + RunAgentInput input = input( + "agent-draft-123", + "run-skill-overflow", + List.of(AguiMessage.userMessage("message-1", "试一下")), + Map.of("easyflow", Map.of("draft", Map.of( + "agent", agent, "skillBindings", bindings)))); + + Assert.assertThrows(BusinessException.class, () -> mapper.toDraftRequest(input)); + } + + /** + * 验证客户端工具不会被静默注册到服务端 Runtime。 + */ + @Test(expected = BusinessException.class) + public void shouldRejectFrontendTools() { + RunAgentInput input = new RunAgentInput( + "123", + "run-1", + List.of(AguiMessage.userMessage("message-1", "hello")), + List.of(new AguiTool("unsafe", "unsafe", Map.of())), + List.of(), + Map.of(), + Map.of()); + + mapper.toFormalRequest(BigInteger.ONE, input); + } + + /** + * 验证客户端不能携带历史消息或 state 影响服务端会话状态。 + */ + @Test + public void shouldRejectHistoryAndClientState() { + RunAgentInput history = input( + "123", + "run-history", + List.of( + AguiMessage.userMessage("old", "旧问题"), + AguiMessage.userMessage("current", "新问题")), + Map.of()); + Assert.assertThrows(BusinessException.class, + () -> mapper.toFormalRequest(BigInteger.ONE, history)); + + RunAgentInput state = new RunAgentInput( + "123", + "run-state", + List.of(AguiMessage.userMessage("message-1", "hello")), + List.of(), + List.of(), + Map.of("forged", true), + Map.of()); + Assert.assertThrows(BusinessException.class, + () -> mapper.toFormalRequest(BigInteger.ONE, state)); + } + + /** + * 验证畸形扩展字段会转换为可预期业务错误。 + */ + @Test + public void shouldRejectMalformedOrUnknownForwardedProps() { + RunAgentInput malformed = input( + "123", + "run-malformed", + List.of(AguiMessage.userMessage("message-1", "hello")), + Map.of("easyflow", Map.of("input", Map.of( + "capabilities", List.of(Map.of("resourceIds", "invalid")))))); + Assert.assertThrows(BusinessException.class, + () -> mapper.toFormalRequest(BigInteger.ONE, malformed)); + + RunAgentInput unknown = input( + "123", + "run-unknown", + List.of(AguiMessage.userMessage("message-1", "hello")), + Map.of("easyflow", Map.of("unsupported", true))); + Assert.assertThrows(BusinessException.class, + () -> mapper.toFormalRequest(BigInteger.ONE, unknown)); + } + + /** + * 验证用户消息 ID 和工具字段不能伪造服务端消息链路。 + */ + @Test + public void shouldRejectInvalidUserMessageIdentityOrToolFields() { + RunAgentInput invalidId = input( + "123", + "run-invalid-message", + List.of(AguiMessage.userMessage("invalid message id", "hello")), + Map.of()); + Assert.assertThrows(BusinessException.class, + () -> mapper.toFormalRequest(BigInteger.ONE, invalidId)); + + RunAgentInput forgedToolCall = input( + "123", + "run-forged-tool", + List.of(new AguiMessage("message-1", "user", "hello", List.of(), "tool-call-1")), + Map.of()); + Assert.assertThrows(BusinessException.class, + () -> mapper.toFormalRequest(BigInteger.ONE, forgedToolCall)); + } + + private static RunAgentInput input( + String threadId, + String runId, + List messages, + Map forwardedProps) { + return new RunAgentInput( + threadId, runId, messages, List.of(), List.of(), Map.of(), forwardedProps); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/artifact/AgentArtifactChatSessionExtensionTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/artifact/AgentArtifactChatSessionExtensionTest.java new file mode 100644 index 00000000..c61187f7 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/artifact/AgentArtifactChatSessionExtensionTest.java @@ -0,0 +1,76 @@ +package tech.easyflow.agent.runtime.artifact; + +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; +import tech.easyflow.agent.runtime.AgentRuntimeStateCleanupService; +import tech.easyflow.chatlog.domain.dto.ChatMessageRecord; +import tech.easyflow.chatlog.domain.dto.ChatSessionSummary; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.math.BigInteger; +import java.util.List; + +/** + * {@link AgentArtifactChatSessionExtension} Agent 专属行为测试。 + */ +public class AgentArtifactChatSessionExtensionTest { + + /** + * 验证 Agent 会话删除成功后才按可信归属标记 Artifact。 + */ + @Test + public void shouldCleanupRuntimeBeforeDeleteAndMarkArtifactAfterDelete() { + AgentRuntimeStateCleanupService runtimeCleanup = Mockito.mock(AgentRuntimeStateCleanupService.class); + AgentArtifactService artifactService = Mockito.mock(AgentArtifactService.class); + AgentArtifactChatSessionExtension extension = + new AgentArtifactChatSessionExtension(runtimeCleanup, artifactService); + ChatSessionSummary summary = agentSession(); + + extension.beforeDelete(summary, BigInteger.valueOf(2), BigInteger.valueOf(2)); + extension.afterDelete(summary, BigInteger.valueOf(2), BigInteger.valueOf(2)); + + Mockito.verify(runtimeCleanup).clearChatSession(BigInteger.valueOf(4), BigInteger.valueOf(2)); + Mockito.verify(artifactService).markSessionDeletePending( + BigInteger.ONE, BigInteger.valueOf(2), BigInteger.valueOf(3), BigInteger.valueOf(4)); + } + + /** + * 验证非 Agent 会话不匹配扩展。 + */ + @Test + public void shouldIgnoreNonAgentSession() { + AgentArtifactChatSessionExtension extension = new AgentArtifactChatSessionExtension( + Mockito.mock(AgentRuntimeStateCleanupService.class), Mockito.mock(AgentArtifactService.class)); + ChatSessionSummary summary = agentSession(); + summary.setAssistantCode("BOT"); + + Assert.assertFalse(extension.supports(summary)); + } + + /** + * 验证历史投影使用会话中的可信租户、用户、Agent 和会话归属。 + */ + @Test + public void shouldProjectUsingTrustedSessionIdentity() { + AgentArtifactService artifactService = Mockito.mock(AgentArtifactService.class); + AgentArtifactChatSessionExtension extension = new AgentArtifactChatSessionExtension( + Mockito.mock(AgentRuntimeStateCleanupService.class), artifactService); + List records = List.of(new ChatMessageRecord()); + + extension.projectMessages(agentSession(), records); + + Mockito.verify(artifactService).projectHistoryArtifacts(records, + BigInteger.ONE, BigInteger.valueOf(2), BigInteger.valueOf(3), BigInteger.valueOf(4)); + } + + private ChatSessionSummary agentSession() { + ChatSessionSummary summary = new ChatSessionSummary(); + summary.setTenantId(BigInteger.ONE); + summary.setUserId(BigInteger.valueOf(2)); + summary.setAssistantId(BigInteger.valueOf(3)); + summary.setId(BigInteger.valueOf(4)); + summary.setAssistantCode("AGENT"); + return summary; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/artifact/AgentArtifactCleanupSchedulerTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/artifact/AgentArtifactCleanupSchedulerTest.java new file mode 100644 index 00000000..39911097 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/artifact/AgentArtifactCleanupSchedulerTest.java @@ -0,0 +1,91 @@ +package tech.easyflow.agent.runtime.artifact; + +import org.junit.Assert; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import tech.easyflow.agent.entity.AgentArtifact; +import tech.easyflow.agent.mapper.AgentArtifactMapper; + +import java.math.BigInteger; +import java.util.Date; +import java.util.List; + +/** + * {@link AgentArtifactCleanupScheduler} 超时发布恢复测试。 + */ +public class AgentArtifactCleanupSchedulerTest { + + /** + * 验证过期 PUBLISHING 记录经条件领取后立即进入幂等对象删除。 + */ + @Test + public void shouldClaimAndDeleteAbandonedPublishingArtifact() { + AgentArtifactMapper mapper = Mockito.mock(AgentArtifactMapper.class); + AgentArtifactService artifactService = Mockito.mock(AgentArtifactService.class); + AgentArtifact abandoned = publishingArtifact(); + Mockito.when(mapper.selectListByQuery(Mockito.any())) + .thenReturn(List.of(), List.of(abandoned), List.of(), List.of()); + Mockito.when(mapper.selectOrphanedFormalArtifacts(Mockito.anyInt())).thenReturn(List.of()); + Mockito.when(mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1); + + new AgentArtifactCleanupScheduler(mapper, artifactService).cleanup(); + + ArgumentCaptor update = ArgumentCaptor.forClass(AgentArtifact.class); + Mockito.verify(mapper).updateByQuery(update.capture(), Mockito.any()); + Assert.assertEquals(AgentArtifactStatus.DELETE_PENDING.name(), update.getValue().getStatus()); + Assert.assertEquals("ARTIFACT_PUBLISH_TIMEOUT", update.getValue().getLastErrorCode()); + Assert.assertNotNull(update.getValue().getNextRetryAt()); + Mockito.verify(artifactService).deleteObject(abandoned); + } + + /** + * 验证条件领取失败时不会删除可能已被其他线程接管的对象。 + */ + @Test + public void shouldSkipDeleteWhenPublishingClaimLosesRace() { + AgentArtifactMapper mapper = Mockito.mock(AgentArtifactMapper.class); + AgentArtifactService artifactService = Mockito.mock(AgentArtifactService.class); + Mockito.when(mapper.selectListByQuery(Mockito.any())) + .thenReturn(List.of(), List.of(publishingArtifact()), List.of(), List.of()); + Mockito.when(mapper.selectOrphanedFormalArtifacts(Mockito.anyInt())).thenReturn(List.of()); + Mockito.when(mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(0); + + new AgentArtifactCleanupScheduler(mapper, artifactService).cleanup(); + + Mockito.verify(artifactService, Mockito.never()).deleteObject(Mockito.any()); + } + + /** + * 验证会话删除已落库但 after hook 失败时可由孤儿扫描补偿删除。 + */ + @Test + public void shouldClaimAndDeleteFormalArtifactWhoseSessionIsUnavailable() { + AgentArtifactMapper mapper = Mockito.mock(AgentArtifactMapper.class); + AgentArtifactService artifactService = Mockito.mock(AgentArtifactService.class); + AgentArtifact orphan = publishingArtifact(); + orphan.setStatus(AgentArtifactStatus.AVAILABLE.name()); + Mockito.when(mapper.selectListByQuery(Mockito.any())) + .thenReturn(List.of(), List.of(), List.of(), List.of()); + Mockito.when(mapper.selectOrphanedFormalArtifacts(Mockito.anyInt())).thenReturn(List.of(orphan)); + Mockito.when(mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1); + + new AgentArtifactCleanupScheduler(mapper, artifactService).cleanup(); + + ArgumentCaptor update = ArgumentCaptor.forClass(AgentArtifact.class); + Mockito.verify(mapper).updateByQuery(update.capture(), Mockito.any()); + Assert.assertEquals(AgentArtifactStatus.DELETE_PENDING.name(), update.getValue().getStatus()); + Assert.assertEquals("ARTIFACT_SESSION_UNAVAILABLE", update.getValue().getLastErrorCode()); + Mockito.verify(artifactService).deleteObject(orphan); + } + + private AgentArtifact publishingArtifact() { + AgentArtifact artifact = new AgentArtifact(); + artifact.setId(BigInteger.valueOf(9)); + artifact.setArtifactId("artifact-9"); + artifact.setObjectKey("artifacts/9/content"); + artifact.setStatus(AgentArtifactStatus.PUBLISHING.name()); + artifact.setNextRetryAt(new Date(System.currentTimeMillis() - 1_000)); + return artifact; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/artifact/AgentArtifactServiceTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/artifact/AgentArtifactServiceTest.java new file mode 100644 index 00000000..cf9b288b --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/artifact/AgentArtifactServiceTest.java @@ -0,0 +1,598 @@ +package tech.easyflow.agent.runtime.artifact; + +import com.easyagents.agent.runtime.AgentRuntimeContext; +import com.easyagents.agent.runtime.tool.AgentToolContext; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import tech.easyflow.agent.config.AgentWorkspaceProperties; +import tech.easyflow.agent.entity.AgentArtifact; +import tech.easyflow.agent.mapper.AgentArtifactMapper; +import tech.easyflow.agent.runtime.workspace.AgentWorkspaceResolver; +import tech.easyflow.chatlog.domain.dto.ChatMessageRecord; +import tech.easyflow.chatlog.domain.dto.ChatSessionSummary; +import tech.easyflow.chatlog.service.ChatSessionQueryService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.core.runtime.ChatRuntimeExtKeys; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.springframework.web.server.ResponseStatusException; + +/** + * Agent Artifact 发布、安全返回和失败补偿测试。 + */ +public class AgentArtifactServiceTest { + + /** 临时工作区。 */ + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + /** + * 验证发布主链流式计算摘要并只返回安全字段。 + * + * @throws Exception 文件准备失败 + */ + @Test + public void publishShouldReturnSafeAvailableView() throws Exception { + Path file = file("report.csv", "name,value\na,1\n"); + Fixture fixture = fixture(file); + Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString())) + .thenAnswer(invocation -> { + invocation.getArgument(1).readAllBytes(); + return "etag-1"; + }); + Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1); + + AgentArtifactView view = fixture.service.publish( + file.getParent(), "report.csv", null, AgentArtifactService.MODE_DRAFT, context("draft-session")); + + Assert.assertEquals("AVAILABLE", view.status()); + Assert.assertEquals(Files.size(file), view.size()); + Assert.assertEquals(64, view.sha256().length()); + Assert.assertEquals("text/plain", view.mimeType()); + Map safe = view.toMap(); + Assert.assertFalse(safe.containsKey("objectKey")); + Assert.assertFalse(safe.containsKey("storagePlatform")); + Assert.assertTrue(view.downloadUrl().contains(view.artifactId())); + ArgumentCaptor inserted = ArgumentCaptor.forClass(AgentArtifact.class); + Mockito.verify(fixture.mapper).insert(inserted.capture()); + Assert.assertTrue(inserted.getValue().getNextRetryAt().after(new java.util.Date())); + } + + /** + * 验证对象已写入但状态提交失败、补偿删除也失败时进入可重试删除失败态。 + * + * @throws Exception 文件准备失败 + */ + @Test + public void publishShouldRecordDeleteFailedWhenCompensationDeleteFails() throws Exception { + Path file = file("report.txt", "content"); + Fixture fixture = fixture(file); + Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString())) + .thenAnswer(invocation -> { + invocation.getArgument(1).readAllBytes(); + return "etag-1"; + }); + Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(0); + Mockito.doThrow(new AgentArtifactOperationException("STORAGE", "delete failed", true)) + .when(fixture.storage).delete(Mockito.anyString()); + + Assert.assertThrows(AgentArtifactOperationException.class, () -> fixture.service.publish( + file.getParent(), "report.txt", null, AgentArtifactService.MODE_DRAFT, context("draft-session"))); + + ArgumentCaptor updates = ArgumentCaptor.forClass(AgentArtifact.class); + Mockito.verify(fixture.mapper, Mockito.atLeast(2)).updateByQuery(updates.capture(), Mockito.any()); + AgentArtifact compensation = updates.getAllValues().get(updates.getAllValues().size() - 1); + Assert.assertEquals(AgentArtifactStatus.DELETE_FAILED.name(), compensation.getStatus()); + Assert.assertNotNull(compensation.getNextRetryAt()); + } + + /** + * 验证对象可能已经写入但 put 响应抛错时仍会执行幂等补偿删除。 + * + * @throws Exception 文件准备失败 + */ + @Test + public void publishShouldCompensateWhenPutThrowsAfterPossibleWrite() throws Exception { + Path file = file("uncertain.txt", "content"); + Fixture fixture = fixture(file); + Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString())) + .thenThrow(new AgentArtifactOperationException("STORAGE_TIMEOUT", "response lost", true)); + Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1); + + Assert.assertThrows(AgentArtifactOperationException.class, () -> fixture.service.publish( + file.getParent(), "uncertain.txt", null, + AgentArtifactService.MODE_DRAFT, context("draft-session"))); + + Mockito.verify(fixture.storage).delete(Mockito.anyString()); + ArgumentCaptor update = ArgumentCaptor.forClass(AgentArtifact.class); + Mockito.verify(fixture.mapper).updateByQuery(update.capture(), Mockito.any()); + Assert.assertEquals(AgentArtifactStatus.FAILED.name(), update.getValue().getStatus()); + } + + /** + * 验证展示扩展名不能伪造 MIME,未知二进制安全回退为 octet-stream。 + * + * @throws Exception 文件准备失败 + */ + @Test + public void publishShouldDetectMimeFromContentInsteadOfExtension() throws Exception { + Path file = temporaryFolder.newFile("forged.png").toPath(); + Files.write(file, new byte[]{0, 1, 2, 3}); + Fixture fixture = fixture(file); + Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString())) + .thenAnswer(invocation -> { + invocation.getArgument(1).readAllBytes(); + return "etag-1"; + }); + Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1); + + AgentArtifactView view = fixture.service.publish( + file.getParent(), "forged.png", null, AgentArtifactService.MODE_DRAFT, context("draft-session")); + + Assert.assertEquals("application/octet-stream", view.mimeType()); + } + + /** + * 验证正式产物只从可信 RuntimeContext 绑定当前聊天轮次。 + * + * @throws Exception 文件准备失败 + */ + @Test + public void formalPublishShouldPersistTrustedRoundId() throws Exception { + Path file = file("report.txt", "content"); + Fixture fixture = fixture(file); + Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString())) + .thenAnswer(invocation -> { + invocation.getArgument(1).readAllBytes(); + return "etag-1"; + }); + Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1); + AgentToolContext context = context("100"); + context.getRuntimeContext().getMetadata().put(ChatRuntimeExtKeys.CURRENT_ROUND_ID, "200"); + context.getRuntimeContext().getMetadata().put(ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX, 1); + + fixture.service.publish(file.getParent(), "report.txt", null, + AgentArtifactService.MODE_FORMAL, context); + + ArgumentCaptor inserted = ArgumentCaptor.forClass(AgentArtifact.class); + Mockito.verify(fixture.mapper).insert(inserted.capture()); + Assert.assertEquals(BigInteger.valueOf(100), inserted.getValue().getChatSessionId()); + Assert.assertEquals(BigInteger.valueOf(200), inserted.getValue().getRoundId()); + Assert.assertEquals(Integer.valueOf(1), inserted.getValue().getVariantIndex()); + } + + /** + * 验证历史投影只查询一次账本,并以当前状态覆盖旧 AVAILABLE 快照。 + */ + @Test + public void historyProjectionShouldUseCurrentLedgerStatusWithoutStorageFields() { + Fixture fixture = fixture(Path.of("unused")); + Mockito.when(fixture.mapper.selectListByQuery(Mockito.any())).thenReturn(List.of( + ledger("available", AgentArtifactStatus.AVAILABLE, BigInteger.valueOf(200)), + ledger("deleted", AgentArtifactStatus.DELETED, BigInteger.valueOf(200)), + ledger("delete-failed", AgentArtifactStatus.DELETE_FAILED, BigInteger.valueOf(200)))); + ChatMessageRecord message = new ChatMessageRecord(); + message.setSessionId(BigInteger.valueOf(100)); + message.setRoundId(BigInteger.valueOf(200)); + message.setSenderRole("assistant"); + message.setVariantIndex(1); + message.setContentPayload(new LinkedHashMap<>(Map.of("artifacts", List.of( + oldView("available"), oldView("deleted"), oldView("delete-failed"), oldView("missing"))))); + + fixture.service.projectHistoryArtifacts( + List.of(message), BigInteger.ONE, BigInteger.valueOf(2), BigInteger.valueOf(3), BigInteger.valueOf(100)); + + Mockito.verify(fixture.mapper, Mockito.times(1)).selectListByQuery(Mockito.any()); + @SuppressWarnings("unchecked") + List> projected = + (List>) message.getContentPayload().get("artifacts"); + Assert.assertEquals("AVAILABLE", projected.get(0).get("status")); + Assert.assertNotNull(projected.get(0).get("downloadUrl")); + Assert.assertEquals("DELETED", projected.get(1).get("status")); + Assert.assertNull(projected.get(1).get("downloadUrl")); + Assert.assertEquals("DELETE_FAILED", projected.get(2).get("status")); + Assert.assertNull(projected.get(2).get("downloadUrl")); + Assert.assertEquals("UNAVAILABLE", projected.get(3).get("status")); + Assert.assertNull(projected.get(3).get("downloadUrl")); + for (Map item : projected) { + Assert.assertFalse(item.containsKey("objectKey")); + Assert.assertFalse(item.containsKey("storagePlatform")); + } + } + + /** + * 验证 AVAILABLE 已落账但消息 payload 尚未持久化时可按当前页轮次补回。 + */ + @Test + public void historyProjectionShouldRecoverLedgerArtifactMissingFromPayload() { + Fixture fixture = fixture(Path.of("unused")); + Mockito.when(fixture.mapper.selectListByQuery(Mockito.any())).thenReturn(List.of( + ledger("recovered", AgentArtifactStatus.AVAILABLE, BigInteger.valueOf(201)))); + ChatMessageRecord message = new ChatMessageRecord(); + message.setSessionId(BigInteger.valueOf(100)); + message.setRoundId(BigInteger.valueOf(201)); + message.setSenderRole("assistant"); + message.setVariantIndex(1); + message.setContentPayload(new LinkedHashMap<>(Map.of("answer", "done"))); + + fixture.service.projectHistoryArtifacts( + List.of(message), BigInteger.ONE, BigInteger.valueOf(2), BigInteger.valueOf(3), BigInteger.valueOf(100)); + + @SuppressWarnings("unchecked") + List> projected = + (List>) message.getContentPayload().get("artifacts"); + Assert.assertEquals(1, projected.size()); + Assert.assertEquals("recovered", projected.get(0).get("artifactId")); + Assert.assertEquals("AVAILABLE", projected.get(0).get("status")); + Mockito.verify(fixture.mapper, Mockito.times(1)).selectListByQuery(Mockito.any()); + } + + /** + * 验证同一答案版本已有部分 payload 时仍可合并账本中已发布但尚未持久化的产物。 + */ + @Test + public void historyProjectionShouldMergeMissingLedgerArtifactIntoExistingPayload() { + Fixture fixture = fixture(Path.of("unused")); + Mockito.when(fixture.mapper.selectListByQuery(Mockito.any())).thenReturn(List.of( + ledger("persisted", AgentArtifactStatus.AVAILABLE, BigInteger.valueOf(201)), + ledger("late", AgentArtifactStatus.AVAILABLE, BigInteger.valueOf(201)))); + ChatMessageRecord message = assistantMessage(BigInteger.valueOf(201), 1); + message.setContentPayload(new LinkedHashMap<>(Map.of( + "artifacts", List.of(oldView("persisted"))))); + + fixture.service.projectHistoryArtifacts( + List.of(message), BigInteger.ONE, BigInteger.valueOf(2), BigInteger.valueOf(3), BigInteger.valueOf(100)); + + Assert.assertEquals(List.of("persisted", "late"), projectedArtifacts(message).stream() + .map(item -> String.valueOf(item.get("artifactId"))) + .toList()); + Mockito.verify(fixture.mapper, Mockito.times(1)).selectListByQuery(Mockito.any()); + } + + /** + * 验证同一轮重答的两个答案版本只恢复各自绑定的产物。 + */ + @Test + public void historyProjectionShouldIsolateArtifactsByVariantIndex() { + Fixture fixture = fixture(Path.of("unused")); + AgentArtifact first = ledger("variant-one", AgentArtifactStatus.AVAILABLE, BigInteger.valueOf(201)); + first.setVariantIndex(1); + AgentArtifact second = ledger("variant-two", AgentArtifactStatus.AVAILABLE, BigInteger.valueOf(201)); + second.setVariantIndex(2); + Mockito.when(fixture.mapper.selectListByQuery(Mockito.any())).thenReturn(List.of(first, second)); + ChatMessageRecord firstMessage = assistantMessage(BigInteger.valueOf(201), 1); + ChatMessageRecord secondMessage = assistantMessage(BigInteger.valueOf(201), 2); + + fixture.service.projectHistoryArtifacts(List.of(firstMessage, secondMessage), + BigInteger.ONE, BigInteger.valueOf(2), BigInteger.valueOf(3), BigInteger.valueOf(100)); + + Assert.assertEquals("variant-one", projectedArtifacts(firstMessage).get(0).get("artifactId")); + Assert.assertEquals("variant-two", projectedArtifacts(secondMessage).get(0).get("artifactId")); + Mockito.verify(fixture.mapper, Mockito.times(1)).selectListByQuery(Mockito.any()); + } + + /** + * 验证 MinIO 实际对象大小不一致时发布失败并幂等删除。 + * + * @throws Exception 文件准备失败 + */ + @Test + public void publishShouldCompensateWhenStoredSizeMismatches() throws Exception { + Path file = file("size.txt", "content"); + Fixture fixture = fixture(file); + Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString())) + .thenReturn("etag-1"); + Mockito.when(fixture.storage.stat(Mockito.anyString())) + .thenReturn(new AgentArtifactObjectStorage.StoredObjectMetadata(1L, "etag-1")); + Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1); + + Assert.assertThrows(AgentArtifactOperationException.class, () -> fixture.service.publish( + file.getParent(), file.getFileName().toString(), null, + AgentArtifactService.MODE_DRAFT, context("draft-session"))); + + Mockito.verify(fixture.storage).delete(Mockito.anyString()); + } + + /** + * 验证 MinIO 回读对象摘要不一致时发布失败并幂等删除。 + * + * @throws Exception 文件准备失败 + */ + @Test + public void publishShouldCompensateWhenStoredHashMismatches() throws Exception { + Path file = file("hash.txt", "content"); + Fixture fixture = fixture(file); + Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString())) + .thenReturn("etag-1"); + Mockito.when(fixture.storage.open(Mockito.anyString())) + .thenReturn(new java.io.ByteArrayInputStream("changed".getBytes(StandardCharsets.UTF_8))); + Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1); + + Assert.assertThrows(AgentArtifactOperationException.class, () -> fixture.service.publish( + file.getParent(), file.getFileName().toString(), null, + AgentArtifactService.MODE_DRAFT, context("draft-session"))); + + Mockito.verify(fixture.storage).delete(Mockito.anyString()); + } + + /** + * 验证 OOXML 文件依据 ZIP 内部结构识别 MIME。 + * + * @throws Exception 文件准备失败 + */ + @Test + public void publishShouldRecognizeDocxFromZipStructure() throws Exception { + Path file = temporaryFolder.newFile("report.bin").toPath(); + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(file))) { + output.putNextEntry(new ZipEntry("[Content_Types].xml")); + output.write("".getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + output.putNextEntry(new ZipEntry("word/document.xml")); + output.write("".getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + } + Fixture fixture = fixture(file); + Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString())) + .thenAnswer(invocation -> { + invocation.getArgument(1).readAllBytes(); + return "etag-1"; + }); + Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1); + + AgentArtifactView view = fixture.service.publish(file.getParent(), file.getFileName().toString(), null, + AgentArtifactService.MODE_DRAFT, context("draft-session")); + + Assert.assertEquals("application/vnd.openxmlformats-officedocument.wordprocessingml.document", + view.mimeType()); + } + + /** + * 验证高压缩比条目仅通过 central directory 元数据拒绝,不展开正文进行 MIME 探测。 + * + * @throws Exception 文件准备失败 + */ + @Test + public void publishShouldTreatZipBombLikeArchiveAsGenericZip() throws Exception { + Path file = temporaryFolder.newFile("compressed.bin").toPath(); + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(file))) { + output.putNextEntry(new ZipEntry("payload.bin")); + byte[] zeros = new byte[8192]; + for (int index = 0; index < 1024; index++) { + output.write(zeros); + } + output.closeEntry(); + output.putNextEntry(new ZipEntry("[Content_Types].xml")); + output.write("".getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + output.putNextEntry(new ZipEntry("word/document.xml")); + output.write("".getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + } + Fixture fixture = fixture(file); + Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString())) + .thenAnswer(invocation -> { + invocation.getArgument(1).readAllBytes(); + return "etag-1"; + }); + Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1); + + AgentArtifactView view = fixture.service.publish(file.getParent(), file.getFileName().toString(), null, + AgentArtifactService.MODE_DRAFT, context("draft-session")); + + Assert.assertEquals("application/zip", view.mimeType()); + } + + /** + * 验证海量空条目在 ZipFile 构造前由 EOCD/中央目录预检拒绝。 + * + * @throws Exception 文件准备失败 + */ + @Test + public void publishShouldRejectExcessiveCentralDirectoryEntries() throws Exception { + Path file = temporaryFolder.newFile("many-entries.bin").toPath(); + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(file))) { + output.putNextEntry(new ZipEntry("[Content_Types].xml")); + output.write("".getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + output.putNextEntry(new ZipEntry("word/document.xml")); + output.write("".getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + for (int index = 0; index < 4096; index++) { + output.putNextEntry(new ZipEntry("empty/" + index)); + output.closeEntry(); + } + } + Fixture fixture = fixture(file); + Mockito.when(fixture.storage.put(Mockito.anyString(), Mockito.any(), Mockito.anyLong(), Mockito.anyString())) + .thenAnswer(invocation -> { + invocation.getArgument(1).readAllBytes(); + return "etag-1"; + }); + Mockito.when(fixture.mapper.updateByQuery(Mockito.any(), Mockito.any())).thenReturn(1); + + AgentArtifactView view = fixture.service.publish(file.getParent(), file.getFileName().toString(), null, + AgentArtifactService.MODE_DRAFT, context("draft-session")); + + Assert.assertEquals("application/zip", view.mimeType()); + } + + /** + * 验证正式产物下载必须同时匹配当前 Agent、模式、会话和存活会话归属。 + */ + @Test + public void formalDownloadShouldRejectCrossAgentModeAndSessionReplay() { + Fixture fixture = fixture(Path.of("unused")); + AgentArtifact artifact = downloadableArtifact(AgentArtifactService.MODE_FORMAL); + Mockito.when(fixture.mapper.selectOneByQuery(Mockito.any())).thenReturn(artifact); + ChatSessionQueryService queryService = Mockito.mock(ChatSessionQueryService.class); + ChatSessionSummary summary = new ChatSessionSummary(); + summary.setId(BigInteger.valueOf(100)); + summary.setTenantId(BigInteger.ONE); + summary.setUserId(BigInteger.valueOf(2)); + summary.setAssistantId(BigInteger.valueOf(3)); + summary.setAssistantCode("AGENT"); + Mockito.when(queryService.getSessionSummary(BigInteger.valueOf(100))).thenReturn(summary); + fixture.service.setChatSessionQueryService(queryService); + LoginAccount account = account(); + + Assert.assertSame(artifact, fixture.service.requireDownload( + "artifact", account, BigInteger.valueOf(3), "FORMAL", BigInteger.valueOf(100), null)); + Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload( + "artifact", account, BigInteger.valueOf(4), "FORMAL", BigInteger.valueOf(100), null)); + Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload( + "artifact", account, BigInteger.valueOf(3), "DRAFT", null, "100")); + Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload( + "artifact", account, BigInteger.valueOf(3), "FORMAL", BigInteger.valueOf(101), null)); + Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload( + "artifact", account, BigInteger.valueOf(3), "FORMAL", BigInteger.valueOf(100), "draft")); + } + + /** + * 验证草稿产物下载必须匹配当前 Agent 和 Runtime 会话。 + */ + @Test + public void draftDownloadShouldRejectCrossAgentAndRuntimeSessionReplay() { + Fixture fixture = fixture(Path.of("unused")); + AgentArtifact artifact = downloadableArtifact(AgentArtifactService.MODE_DRAFT); + Mockito.when(fixture.mapper.selectOneByQuery(Mockito.any())).thenReturn(artifact); + LoginAccount account = account(); + + Assert.assertSame(artifact, fixture.service.requireDownload( + "artifact", account, BigInteger.valueOf(3), "DRAFT", null, "draft-session")); + Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload( + "artifact", account, BigInteger.valueOf(4), "DRAFT", null, "draft-session")); + Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload( + "artifact", account, BigInteger.valueOf(3), "DRAFT", null, "another-session")); + Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload( + "artifact", account, BigInteger.valueOf(3), "FORMAL", BigInteger.valueOf(100), null)); + Assert.assertThrows(ResponseStatusException.class, () -> fixture.service.requireDownload( + "artifact", account, BigInteger.valueOf(3), "DRAFT", BigInteger.valueOf(100), "draft-session")); + } + + private ChatMessageRecord assistantMessage(BigInteger roundId, int variantIndex) { + ChatMessageRecord message = new ChatMessageRecord(); + message.setSessionId(BigInteger.valueOf(100)); + message.setRoundId(roundId); + message.setVariantIndex(variantIndex); + message.setSenderRole("assistant"); + message.setContentPayload(new LinkedHashMap<>()); + return message; + } + + private AgentArtifact downloadableArtifact(String mode) { + AgentArtifact artifact = new AgentArtifact(); + artifact.setArtifactId("artifact"); + artifact.setTenantId(BigInteger.ONE); + artifact.setOwnerUserId(BigInteger.valueOf(2)); + artifact.setAgentId(BigInteger.valueOf(3)); + artifact.setChatMode(mode); + artifact.setChatSessionId(AgentArtifactService.MODE_FORMAL.equals(mode) + ? BigInteger.valueOf(100) : null); + artifact.setRuntimeSessionId(AgentArtifactService.MODE_DRAFT.equals(mode) + ? "draft-session" : "100"); + artifact.setStatus(AgentArtifactStatus.AVAILABLE.name()); + return artifact; + } + + private LoginAccount account() { + LoginAccount account = new LoginAccount(); + account.setTenantId(BigInteger.ONE); + account.setId(BigInteger.valueOf(2)); + return account; + } + + @SuppressWarnings("unchecked") + private List> projectedArtifacts(ChatMessageRecord message) { + return (List>) message.getContentPayload().get("artifacts"); + } + + private Fixture fixture(Path file) { + AgentArtifactMapper mapper = Mockito.mock(AgentArtifactMapper.class); + AgentArtifactObjectStorage storage = Mockito.mock(AgentArtifactObjectStorage.class); + AgentWorkspaceResolver resolver = Mockito.mock(AgentWorkspaceResolver.class); + Mockito.when(resolver.resolveExistingFile(Mockito.any(), Mockito.anyString())).thenReturn(file); + Mockito.when(storage.stat(Mockito.anyString())).thenAnswer(invocation -> + new AgentArtifactObjectStorage.StoredObjectMetadata(Files.size(file), "etag-1")); + try { + Mockito.when(storage.open(Mockito.anyString())).thenAnswer(invocation -> Files.newInputStream(file)); + } catch (java.io.IOException error) { + throw new IllegalStateException(error); + } + Mockito.doAnswer(invocation -> { + invocation.getArgument(0).setId(BigInteger.valueOf(99)); + return 1; + }).when(mapper).insert(Mockito.any()); + AgentWorkspaceProperties properties = new AgentWorkspaceProperties(); + return new Fixture(mapper, storage, + new AgentArtifactService(mapper, storage, resolver, properties)); + } + + private Path file(String name, String content) throws Exception { + Path file = temporaryFolder.newFile(name).toPath(); + Files.writeString(file, content, StandardCharsets.UTF_8); + return file; + } + + private AgentToolContext context(String sessionId) { + AgentRuntimeContext runtimeContext = new AgentRuntimeContext(); + runtimeContext.setTenantId("1"); + runtimeContext.setUserId("2"); + runtimeContext.setSessionId(sessionId); + AgentToolContext context = new AgentToolContext(); + context.setAgentId("3"); + context.setSessionId(sessionId); + context.setRequestId("request-1"); + context.setToolCallId("tool-1"); + context.setRuntimeContext(runtimeContext); + return context; + } + + private AgentArtifact ledger(String artifactId, + AgentArtifactStatus status, + BigInteger roundId) { + AgentArtifact artifact = new AgentArtifact(); + artifact.setArtifactId(artifactId); + artifact.setRoundId(roundId); + artifact.setVariantIndex(1); + artifact.setFileName(artifactId + ".txt"); + artifact.setMimeType("text/plain"); + artifact.setSizeBytes(10L); + artifact.setSha256("sha-" + artifactId); + artifact.setStatus(status.name()); + artifact.setStoragePlatform("secret-platform"); + artifact.setObjectKey("secret/object/key"); + return artifact; + } + + private Map oldView(String artifactId) { + Map view = new LinkedHashMap<>(); + view.put("schemaVersion", 1); + view.put("artifactId", artifactId); + view.put("fileName", artifactId + ".txt"); + view.put("mimeType", "text/plain"); + view.put("size", 10L); + view.put("sha256", "old-sha"); + view.put("downloadUrl", "/stale-download"); + view.put("status", "AVAILABLE"); + view.put("objectKey", "must-not-leak"); + view.put("storagePlatform", "must-not-leak"); + return view; + } + + private record Fixture(AgentArtifactMapper mapper, + AgentArtifactObjectStorage storage, + AgentArtifactService service) { + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/asynctool/WorkflowPluginAsyncSubToolsTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/asynctool/WorkflowPluginAsyncSubToolsTest.java index d5771d8c..09d03909 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/asynctool/WorkflowPluginAsyncSubToolsTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/asynctool/WorkflowPluginAsyncSubToolsTest.java @@ -13,6 +13,7 @@ import tech.easyflow.agent.runtime.tool.AgentToolExecutionResult; import tech.easyflow.agent.runtime.tool.PluginToolExecutor; import tech.easyflow.agent.runtime.tool.WorkflowToolExecutor; import tech.easyflow.ai.entity.PluginItem; +import tech.easyflow.ai.entity.Plugin; import tech.easyflow.ai.entity.Workflow; import java.math.BigInteger; @@ -67,6 +68,7 @@ public class WorkflowPluginAsyncSubToolsTest { try { Map businessResult = Map.of("pluginOutput", List.of("a", "b")); PluginAsyncSubTools subTools = new PluginAsyncSubTools(pluginItem(), + new Plugin(), "plugin_demo", "测试插件", new StubPluginToolExecutor(businessResult), @@ -165,7 +167,9 @@ public class WorkflowPluginAsyncSubToolsTest { } @Override - public AgentToolExecutionResult execute(PluginItem pluginItem, Map arguments) { + public AgentToolExecutionResult execute(PluginItem pluginItem, + Plugin plugin, + Map arguments) { return new AgentToolExecutionResult(businessResult, null); } } diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingServiceImplTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingServiceImplTest.java index 252b63b7..057cc8ae 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingServiceImplTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/hitl/AgentHitlPendingServiceImplTest.java @@ -1,8 +1,11 @@ package tech.easyflow.agent.runtime.hitl; +import com.easyagents.agent.runtime.event.AgentRuntimeEvent; +import com.easyagents.agent.runtime.event.AgentRuntimeEventType; import com.mybatisflex.core.query.QueryWrapper; import org.junit.Assert; import org.junit.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import tech.easyflow.agent.config.AgentRuntimeProperties; import tech.easyflow.agent.entity.AgentHitlPending; @@ -10,12 +13,44 @@ import tech.easyflow.agent.mapper.AgentHitlPendingMapper; import java.math.BigInteger; import java.util.List; +import java.util.Map; /** * {@link AgentHitlPendingServiceImpl} 回归测试。 */ public class AgentHitlPendingServiceImplTest { + /** + * 验证审批 pending 仅持久化可审查的脱敏工具参数与元数据。 + */ + @Test + public void recordApprovalRequiredShouldRedactSensitiveInputAndMetadata() { + AgentHitlPendingMapper mapper = Mockito.mock(AgentHitlPendingMapper.class); + AgentHitlPendingServiceImpl service = + new AgentHitlPendingServiceImpl(mapper, new AgentRuntimeProperties()); + AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED); + event.getPayload().put("resumeToken", "resume-1"); + event.getPayload().put("toolName", "search"); + event.getPayload().put("toolInput", Map.of( + "authorization", "sentinel-secret-authorization", + "keyword", "EasyFlow", + "nested", Map.of("password", "sentinel-secret-password"))); + event.getPayload().put("approvalMetadata", Map.of( + "credential", "sentinel-secret-credential", + "risk", "low")); + + service.recordApprovalRequired("request-1", null, event); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AgentHitlPending.class); + Mockito.verify(mapper).insertOrUpdate(captor.capture()); + AgentHitlPending stored = captor.getValue(); + Assert.assertEquals("EasyFlow", stored.getToolInputJson().get("keyword")); + Assert.assertEquals("[已隐藏]", stored.getToolInputJson().get("authorization")); + Assert.assertFalse(stored.getToolInputJson().toString().contains("sentinel-secret")); + Assert.assertEquals("low", stored.getMetadataJson().get("risk")); + Assert.assertFalse(stored.getMetadataJson().toString().contains("sentinel-secret")); + } + /** * 验证过期扫描只返回成功从 PENDING 原子更新为 EXPIRED 的记录。 */ diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/output/AguiAgentRunOutputTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/output/AguiAgentRunOutputTest.java new file mode 100644 index 00000000..99f15213 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/output/AguiAgentRunOutputTest.java @@ -0,0 +1,221 @@ +package tech.easyflow.agent.runtime.output; + +import com.easyagents.agent.runtime.event.AgentRuntimeEvent; +import com.easyagents.agent.runtime.event.AgentRuntimeEventType; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import tech.easyflow.core.chat.protocol.ChatDomain; +import tech.easyflow.core.chat.protocol.ChatType; +import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter; + +import java.util.List; +import java.util.Map; + +import static org.mockito.Mockito.*; + +/** + * {@link AguiAgentRunOutput} 的线级顺序、终态和脱敏测试。 + */ +public class AguiAgentRunOutputTest { + + /** + * 验证输入确认、正文、引用和完成终态顺序。 + */ + @Test + public void shouldEmitOrderedAguiEventsAndSingleTerminal() { + ChatSseEmitter emitter = emitter(); + AguiAgentRunOutput output = new AguiAgentRunOutput( + "123", "run-1", "user-message-1", emitter); + + Assert.assertTrue(output.emitViewEvent( + ChatDomain.SYSTEM, ChatType.INPUT_ACCEPTED, Map.of("messageId", "server-message-1"))); + Assert.assertTrue(output.emitViewEvent( + ChatDomain.LLM, ChatType.MESSAGE, Map.of("delta", "hello"))); + AgentRuntimeEvent completed = AgentRuntimeEvent.of(AgentRuntimeEventType.COMPLETED); + Assert.assertTrue(output.emitRuntimeEvent(completed)); + Assert.assertTrue(output.emitViewEvent( + ChatDomain.BUSINESS, ChatType.CITATIONS, Map.of("items", List.of(Map.of("id", "c1"))))); + Assert.assertTrue(output.finish("hello")); + + ArgumentCaptor frames = ArgumentCaptor.forClass(String.class); + verify(emitter, atLeastOnce()).sendData(frames.capture()); + List json = frames.getAllValues(); + Assert.assertTrue(json.get(0).contains("\"type\":\"RUN_STARTED\"")); + Assert.assertTrue(json.stream().anyMatch(value -> value.contains("easyflow.input.accepted"))); + Assert.assertTrue(json.stream().anyMatch(value -> value.contains("\"type\":\"TEXT_MESSAGE_CONTENT\""))); + Assert.assertTrue(json.stream().anyMatch(value -> value.contains("easyflow.knowledge.citations"))); + Assert.assertTrue(json.get(json.size() - 1).contains("\"type\":\"RUN_FINISHED\"")); + Assert.assertEquals(1, json.stream().filter(value -> value.contains("\"type\":\"RUN_FINISHED\"")).count()); + verify(emitter).complete(); + } + + /** + * 验证审批 Custom Event 不泄漏恢复令牌。 + */ + @Test + public void shouldHideResumeTokenInApprovalEvent() { + ChatSseEmitter emitter = emitter(); + AguiAgentRunOutput output = new AguiAgentRunOutput("123", "run-1", "user-1", emitter); + AgentRuntimeEvent approval = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED); + approval.setToolCallId("tool-1"); + approval.getPayload().put("resumeToken", "secret-resume-token"); + approval.getPayload().put("toolName", "dangerous-tool"); + approval.getPayload().put("input", Map.of( + "authorization", "sentinel-secret-authorization", + "callbackUrl", "https://example.test/callback?api_key=sentinel-secret-query", + "value", 1)); + approval.getMetadata().put("approvalId", "approval-public"); + + Assert.assertTrue(output.emitRuntimeEvent(approval)); + + ArgumentCaptor frames = ArgumentCaptor.forClass(String.class); + verify(emitter, atLeastOnce()).sendData(frames.capture()); + String wire = String.join("\n", frames.getAllValues()); + Assert.assertTrue(wire.contains("approval-public")); + Assert.assertTrue(wire.contains("[已隐藏]")); + Assert.assertFalse(wire.contains("secret-resume-token")); + Assert.assertFalse(wire.contains("sentinel-secret")); + } + + /** + * 验证 EasyFlow 业务 CUSTOM 只接受服务层公开载荷,Runtime 原始检索数据不会透出。 + */ + @Test + public void shouldNotExposeRawBusinessRuntimePayloads() { + ChatSseEmitter emitter = emitter(); + AguiAgentRunOutput output = new AguiAgentRunOutput("123", "run-1", "user-1", emitter); + AgentRuntimeEvent knowledge = AgentRuntimeEvent.of(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL); + knowledge.getPayload().put("documents", List.of(Map.of( + "chunkContent", "private chunk", + "sourceUri", "private://document"))); + + Assert.assertTrue(output.emitRuntimeEvent(knowledge)); + Assert.assertTrue(output.emitViewEvent( + ChatDomain.BUSINESS, + ChatType.STATUS, + Map.of( + "documents", knowledge.getPayload().get("documents"), + "label", "已检索知识库", + "status", "done", + "statusKey", "knowledge-retrieval"))); + + ArgumentCaptor frames = ArgumentCaptor.forClass(String.class); + verify(emitter, atLeastOnce()).sendData(frames.capture()); + String wire = String.join("\n", frames.getAllValues()); + Assert.assertTrue(wire.contains("easyflow.knowledge.retrieval_status")); + Assert.assertFalse(wire.contains("private chunk")); + Assert.assertFalse(wire.contains("private://document")); + } + + /** + * 验证没有 Runtime 终态的正常 EOF 会转换为明确协议错误。 + */ + @Test + public void shouldFailWhenStreamEndsWithoutTerminalEvent() { + ChatSseEmitter emitter = emitter(); + AguiAgentRunOutput output = new AguiAgentRunOutput("123", "run-1", "user-1", emitter); + + Assert.assertFalse(output.canFinishSuccessfully()); + Assert.assertTrue(output.finish(null)); + + ArgumentCaptor frames = ArgumentCaptor.forClass(String.class); + verify(emitter, atLeastOnce()).sendData(frames.capture()); + String wire = String.join("\n", frames.getAllValues()); + Assert.assertTrue(wire.contains("\"type\":\"RUN_ERROR\"")); + Assert.assertTrue(wire.contains("MISSING_TERMINAL_EVENT")); + } + + /** + * 验证最终权威文本发生修正时通过标准消息快照收敛,且协议保留字段不可被覆盖。 + */ + @Test + public void shouldReconcileAuthoritativeTextAndProtectReservedFields() { + ChatSseEmitter emitter = emitter(); + AguiAgentRunOutput output = new AguiAgentRunOutput( + "123", "run-1", "user-1", "question", emitter); + Assert.assertTrue(output.emitViewEvent( + ChatDomain.LLM, ChatType.MESSAGE, Map.of("delta", "draft"))); + Assert.assertTrue(output.emitViewEvent( + ChatDomain.SYSTEM, ChatType.INPUT_ACCEPTED, + Map.of("messageId", "server-1", "runId", "untrusted-run"))); + Assert.assertTrue(output.emitRuntimeEvent(AgentRuntimeEvent.of(AgentRuntimeEventType.COMPLETED))); + Assert.assertTrue(output.finish("final")); + + ArgumentCaptor frames = ArgumentCaptor.forClass(String.class); + verify(emitter, atLeastOnce()).sendData(frames.capture()); + String wire = String.join("\n", frames.getAllValues()); + Assert.assertTrue(wire.contains("\"type\":\"MESSAGES_SNAPSHOT\"")); + Assert.assertTrue(wire.contains("\"role\":\"user\"")); + Assert.assertTrue(wire.contains("\"content\":\"question\"")); + Assert.assertTrue(wire.contains("\"content\":\"final\"")); + Assert.assertTrue(wire.contains("\"runId\":\"run-1\"")); + Assert.assertFalse(wire.contains("untrusted-run")); + } + + /** + * 验证工具前后的多段正文使用不同消息 ID,推理开始事件使用标准 reasoning 角色。 + */ + @Test + public void shouldUseUniqueSegmentMessageIdsAndReasoningRole() { + ChatSseEmitter emitter = emitter(); + AguiAgentRunOutput output = new AguiAgentRunOutput("123", "run-1", "user-1", emitter); + Assert.assertTrue(output.emitViewEvent( + ChatDomain.LLM, ChatType.MESSAGE, Map.of("delta", "before"))); + AgentRuntimeEvent toolCall = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_CALL); + toolCall.setToolCallId("tool-1"); + toolCall.getPayload().put("toolName", "search"); + toolCall.getPayload().put("toolDisplayName", "联网搜索"); + Assert.assertTrue(output.emitRuntimeEvent(toolCall)); + Assert.assertTrue(output.emitViewEvent( + ChatDomain.LLM, ChatType.MESSAGE, Map.of("delta", "after"))); + Assert.assertTrue(output.emitViewEvent( + ChatDomain.LLM, ChatType.THINKING, Map.of("delta", "reason"))); + + ArgumentCaptor frames = ArgumentCaptor.forClass(String.class); + verify(emitter, atLeastOnce()).sendData(frames.capture()); + String wire = String.join("\n", frames.getAllValues()); + Assert.assertTrue(wire.contains("run-1-assistant-1")); + Assert.assertTrue(wire.contains("run-1-assistant-2")); + Assert.assertTrue(wire.contains("\"role\":\"reasoning\"")); + Assert.assertTrue(wire.contains("easyflow.tool.metadata")); + Assert.assertTrue(wire.contains("联网搜索")); + } + + /** + * 验证 Artifact 发布事件仅发送安全字段。 + */ + @Test + public void shouldEmitSafeArtifactPublishedCustomEvent() { + ChatSseEmitter emitter = emitter(); + AguiAgentRunOutput output = new AguiAgentRunOutput("123", "run-1", "user-1", emitter); + + Assert.assertTrue(output.emitViewEvent(ChatDomain.BUSINESS, ChatType.STATUS, Map.of( + "statusKey", "artifact-published", + "schemaVersion", 1, + "artifactId", "a1", + "fileName", "report.csv", + "mimeType", "text/csv", + "size", 12, + "sha256", "abc", + "downloadUrl", "/api/v1/agent/artifacts/a1/content", + "status", "AVAILABLE", + "objectKey", "private/object/key"))); + + ArgumentCaptor frames = ArgumentCaptor.forClass(String.class); + verify(emitter, atLeastOnce()).sendData(frames.capture()); + String wire = String.join("\n", frames.getAllValues()); + Assert.assertTrue(wire.contains("easyflow.artifact.published")); + Assert.assertTrue(wire.contains("report.csv")); + Assert.assertFalse(wire.contains("private/object/key")); + } + + private static ChatSseEmitter emitter() { + ChatSseEmitter emitter = mock(ChatSseEmitter.class); + when(emitter.getEmitter()).thenReturn(new SseEmitter()); + when(emitter.sendData(anyString())).thenReturn(true); + when(emitter.isClosed()).thenReturn(false); + return emitter; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/skill/AgentSkillRuntimeCompilerTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/skill/AgentSkillRuntimeCompilerTest.java new file mode 100644 index 00000000..86e121a2 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/skill/AgentSkillRuntimeCompilerTest.java @@ -0,0 +1,138 @@ +package tech.easyflow.agent.runtime.skill; + +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.AgentSkillBinding; +import tech.easyflow.agent.entity.AgentToolBinding; +import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompilation; +import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler; +import tech.easyflow.agent.service.AgentDependencyAccessService; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.service.SkillService; + +import java.math.BigInteger; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Agent Skill Runtime 编译器测试。 + */ +public class AgentSkillRuntimeCompilerTest { + + /** + * 完整冻结投影应编译为 SkillBox,且运行时不再回查当前 Skill。 + */ + @Test + public void shouldCompileFrozenSkillBox() { + ObjectMapper objectMapper = new ObjectMapper(); + AgentSkillRuntimeProjector projector = projector(objectMapper, Map.of()); + Agent agent = new Agent(); + agent.setSkillBindings(projector.projectCurrentBindings( + agent, List.of(binding(BigInteger.ONE)))); + AgentToolRuntimeCompiler toolCompiler = mock(AgentToolRuntimeCompiler.class); + when(toolCompiler.compileBindings(anyList())).thenReturn(new AgentToolRuntimeCompilation()); + AgentSkillRuntimeCompiler compiler = new AgentSkillRuntimeCompiler( + projector, toolCompiler, objectMapper); + + AgentSkillRuntimeCompilation compilation = compiler.compile(agent); + + Assert.assertNotNull(compilation.getSkillBoxSpec()); + Assert.assertEquals(1, compilation.getSkillBoxSpec().getSkills().size()); + Assert.assertEquals("skill-1", compilation.getSkillBoxSpec().getSkills().get(0).getName()); + Assert.assertEquals(List.of(), compilation.getSkillBoxSpec().getToolBindings().get("1")); + } + + /** + * Agent 直接 Tool 与 Skill Tool 指向同一资源时应拒绝,避免执行归属不确定。 + */ + @Test + public void shouldRejectTargetSharedByDirectAndSkillTool() { + ObjectMapper objectMapper = new ObjectMapper(); + Map frozenTool = Map.of( + "toolType", "WORKFLOW", + "targetId", 100, + "toolCount", 1, + "resourceSnapshot", Map.of("id", 100)); + AgentSkillRuntimeProjector projector = projector(objectMapper, Map.of( + "schemaVersion", 1, + "bindings", List.of(frozenTool), + "snapshotHash", "tool-hash")); + Agent agent = new Agent(); + agent.setSkillBindings(projector.projectCurrentBindings( + agent, List.of(binding(BigInteger.ONE)))); + AgentToolBinding direct = new AgentToolBinding(); + direct.setToolType("WORKFLOW"); + direct.setTargetId(BigInteger.valueOf(100)); + direct.setEnabled(true); + agent.setToolBindings(List.of(direct)); + AgentSkillRuntimeCompiler compiler = new AgentSkillRuntimeCompiler( + projector, mock(AgentToolRuntimeCompiler.class), objectMapper); + + Assert.assertThrows(BusinessException.class, () -> compiler.compile(agent)); + } + + /** + * 缺失冻结投影的绑定不得进入正式 Runtime。 + */ + @Test + public void shouldRejectBindingWithoutFrozenSnapshot() { + AgentSkillRuntimeProjector projector = mock(AgentSkillRuntimeProjector.class); + AgentSkillRuntimeCompiler compiler = new AgentSkillRuntimeCompiler( + projector, mock(AgentToolRuntimeCompiler.class), new ObjectMapper()); + Agent agent = new Agent(); + agent.setSkillBindings(List.of(binding(BigInteger.ONE))); + + Assert.assertThrows(BusinessException.class, () -> compiler.compile(agent)); + } + + /** + * 创建基于单个已发布 Skill 的真实投影器。 + * + * @param objectMapper JSON 映射器 + * @param toolSnapshot Tool 发布快照 + * @return 投影器 + */ + private AgentSkillRuntimeProjector projector(ObjectMapper objectMapper, + Map toolSnapshot) { + AgentDependencyAccessService accessService = mock(AgentDependencyAccessService.class); + SkillService skillService = mock(SkillService.class); + Skill skill = new Skill(); + skill.setId(BigInteger.ONE); + skill.setName("skill-1"); + Map content = new LinkedHashMap<>(); + content.put("schemaVersion", 2); + content.put("name", "skill-1"); + content.put("displayName", "测试 Skill"); + content.put("description", "用于测试 Skill Runtime"); + content.put("visibilityScope", "PRIVATE"); + content.put("skillContent", "# 指令\n执行测试"); + content.put("packageHash", "package-hash"); + content.put("resources", List.of()); + content.put("snapshotHash", "content-hash"); + skill.setPublishedSnapshotJson(content); + skill.setPublishedToolBindingsJson(toolSnapshot); + when(accessService.requireSkill(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.eq(BigInteger.ONE))).thenReturn(skill); + return new AgentSkillRuntimeProjector(accessService, skillService, objectMapper); + } + + /** + * 创建 Skill 绑定。 + * + * @param skillId Skill ID + * @return 绑定 + */ + private AgentSkillBinding binding(BigInteger skillId) { + AgentSkillBinding binding = new AgentSkillBinding(); + binding.setSkillId(skillId); + return binding; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/skill/AgentSkillRuntimeProjectorTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/skill/AgentSkillRuntimeProjectorTest.java new file mode 100644 index 00000000..7a9504f7 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/skill/AgentSkillRuntimeProjectorTest.java @@ -0,0 +1,202 @@ +package tech.easyflow.agent.runtime.skill; + +import com.easyagents.agent.runtime.mcp.McpToolManifestEntry; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.InOrder; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.entity.AgentSkillBinding; +import tech.easyflow.agent.service.AgentDependencyAccessService; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.service.SkillService; + +import java.math.BigInteger; +import java.util.LinkedHashMap; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Agent Skill 冻结运行投影测试。 + */ +public class AgentSkillRuntimeProjectorTest { + + /** + * 投影应只使用 Skill 发布快照展示字段、排除二进制正文并校验组合 hash。 + */ + @Test + @SuppressWarnings("unchecked") + public void shouldProjectPublishedContentAndDetectTampering() { + AgentDependencyAccessService accessService = mock(AgentDependencyAccessService.class); + SkillService skillService = mock(SkillService.class); + AgentSkillRuntimeProjector projector = new AgentSkillRuntimeProjector( + accessService, skillService, new ObjectMapper()); + Skill skill = skill(BigInteger.ONE, "线上名称"); + skill.setDisplayName("未发布草稿名称"); + when(accessService.requireSkill(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.eq(BigInteger.ONE))).thenReturn(skill); + + List projected = projector.projectCurrentBindings( + new Agent(), List.of(binding(BigInteger.ONE))); + + Map snapshot = projected.get(0).getResourceSnapshot(); + Map summary = projected.get(0).getResourceSummary(); + Assert.assertEquals("线上名称", snapshot.get("displayName")); + Assert.assertEquals(Map.of("references/guide.md", "指南正文"), snapshot.get("resources")); + Assert.assertEquals(1, summary.get("binaryExcludedCount")); + Assert.assertEquals(1, summary.get("toolCount")); + projector.assertFrozenBindings(projected); + + Map tampered = new LinkedHashMap<>(snapshot); + tampered.put("skillContent", "被篡改的指令"); + projected.get(0).setResourceSnapshot(tampered); + Assert.assertThrows(BusinessException.class, () -> projector.assertFrozenBindings(projected)); + } + + /** + * MCP 清单在发布前为 POJO、落库后为 Map 时,运行快照 hash 应保持一致。 + */ + @Test + @SuppressWarnings("unchecked") + public void shouldKeepRuntimeHashAfterMcpManifestJsonRoundTrip() { + ObjectMapper objectMapper = new ObjectMapper(); + AgentDependencyAccessService accessService = mock(AgentDependencyAccessService.class); + SkillService skillService = mock(SkillService.class); + AgentSkillRuntimeProjector projector = new AgentSkillRuntimeProjector( + accessService, skillService, objectMapper); + Skill skill = skill(BigInteger.ONE, "MCP Skill"); + McpToolManifestEntry entry = new McpToolManifestEntry(); + entry.setName("query-docs"); + entry.setDescription("查询文档"); + entry.setInputSchema(Map.of("type", "object")); + entry.setOutputSchema(Map.of("type", "object")); + skill.setPublishedToolBindingsJson(Map.of( + "schemaVersion", 1, + "bindings", List.of(Map.of( + "toolType", "MCP", + "targetId", 200, + "toolCount", 1, + "mcpToolManifest", List.of(entry), + "resourceSnapshot", Map.of("id", 200))), + "snapshotHash", "tools-mcp")); + when(accessService.requireSkill(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.eq(BigInteger.ONE))).thenReturn(skill); + + List projected = projector.projectCurrentBindings( + new Agent(), List.of(binding(BigInteger.ONE))); + Map persisted = objectMapper.convertValue( + projected.get(0).getResourceSnapshot(), Map.class); + projected.get(0).setResourceSnapshot(persisted); + + projector.assertFrozenBindings(projected); + } + + /** + * 资源锁应按 Skill ID 稳定获取,同时保留用户编排顺序。 + */ + @Test + public void shouldLockSkillsInStableOrderAndPreserveBindingOrder() { + AgentDependencyAccessService accessService = mock(AgentDependencyAccessService.class); + SkillService skillService = mock(SkillService.class); + AgentSkillRuntimeProjector projector = new AgentSkillRuntimeProjector( + accessService, skillService, new ObjectMapper()); + Agent agent = new Agent(); + when(accessService.requireSkill(agent, BigInteger.ONE)).thenReturn(skill(BigInteger.ONE, "一")); + when(accessService.requireSkill(agent, BigInteger.TWO)).thenReturn(skill(BigInteger.TWO, "二")); + + List projected = projector.projectCurrentBindings( + agent, List.of(binding(BigInteger.TWO), binding(BigInteger.ONE))); + + InOrder order = inOrder(accessService); + order.verify(accessService).requireSkill(agent, BigInteger.ONE); + order.verify(accessService).requireSkill(agent, BigInteger.TWO); + Assert.assertEquals(BigInteger.TWO, projected.get(0).getSkillId()); + Assert.assertEquals(BigInteger.ONE, projected.get(1).getSkillId()); + } + + /** + * 同一 Agent 不得重复绑定同一 Skill。 + */ + @Test + public void shouldRejectDuplicateSkillBindings() { + AgentDependencyAccessService accessService = mock(AgentDependencyAccessService.class); + SkillService skillService = mock(SkillService.class); + AgentSkillRuntimeProjector projector = new AgentSkillRuntimeProjector( + accessService, skillService, new ObjectMapper()); + when(accessService.requireSkill(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.eq(BigInteger.ONE))).thenReturn(skill(BigInteger.ONE, "一")); + + Assert.assertThrows(BusinessException.class, () -> projector.projectCurrentBindings( + new Agent(), List.of(binding(BigInteger.ONE), binding(BigInteger.ONE)))); + } + + /** + * 单个 Agent 超过二十个 Skill 时应在访问依赖资源前拒绝。 + */ + @Test + public void shouldRejectMoreThanTwentySkills() { + AgentSkillRuntimeProjector projector = new AgentSkillRuntimeProjector( + mock(AgentDependencyAccessService.class), mock(SkillService.class), new ObjectMapper()); + List bindings = new ArrayList<>(); + for (int index = 1; index <= 21; index++) { + bindings.add(binding(BigInteger.valueOf(index))); + } + + Assert.assertThrows(BusinessException.class, + () -> projector.projectCurrentBindings(new Agent(), bindings)); + } + + /** + * 创建带发布内容和 Tool 快照的 Skill。 + * + * @param id Skill ID + * @param publishedDisplayName 已发布展示名 + * @return Skill + */ + private Skill skill(BigInteger id, String publishedDisplayName) { + Skill skill = new Skill(); + skill.setId(id); + skill.setName("skill-" + id); + skill.setDisplayName(publishedDisplayName); + Map content = new LinkedHashMap<>(); + content.put("schemaVersion", 2); + content.put("name", "skill-" + id); + content.put("displayName", publishedDisplayName); + content.put("description", "用于测试 Skill 运行投影"); + content.put("visibilityScope", "PRIVATE"); + content.put("skillContent", "# 指令\n执行测试"); + content.put("packageHash", "package-" + id); + content.put("resources", List.of( + Map.of("path", "references/guide.md", "text", true, "textContent", "指南正文"), + Map.of("path", "assets/template.bin", "text", false, "contentRef", "sha256:binary"))); + content.put("snapshotHash", "content-" + id); + skill.setPublishedSnapshotJson(content); + skill.setPublishedToolBindingsJson(Map.of( + "schemaVersion", 1, + "bindings", List.of(Map.of( + "toolType", "WORKFLOW", + "targetId", 100, + "toolCount", 1, + "resourceSnapshot", Map.of("id", 100))), + "snapshotHash", "tools-" + id)); + return skill; + } + + /** + * 创建 Skill 绑定。 + * + * @param skillId Skill ID + * @return 绑定 + */ + private AgentSkillBinding binding(BigInteger skillId) { + AgentSkillBinding binding = new AgentSkillBinding(); + binding.setSkillId(skillId); + return binding; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/tool/AgentToolRuntimeCompilerTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/tool/AgentToolRuntimeCompilerTest.java index b5a9cd24..c3ae13dd 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/tool/AgentToolRuntimeCompilerTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/tool/AgentToolRuntimeCompilerTest.java @@ -1,6 +1,8 @@ package tech.easyflow.agent.runtime.tool; import com.easyagents.agent.runtime.tool.AgentToolSpec; +import com.easyagents.agent.runtime.tool.AgentToolContext; +import com.easyagents.agent.runtime.tool.AgentToolResult; import com.easyagents.core.model.chat.tool.Parameter; import com.easyagents.core.model.chat.tool.Tool; import com.fasterxml.jackson.databind.ObjectMapper; @@ -128,6 +130,40 @@ public class AgentToolRuntimeCompilerTest { } } + /** + * 验证同步工具异常对外只返回稳定消息,不暴露底层连接细节。 + * + * @throws Exception 反射注入依赖失败时抛出 + */ + @Test + public void syncToolFailureShouldReturnSanitizedMessage() throws Exception { + AgentToolRuntimeCompiler compiler = compiler(); + setField(compiler, "workflowToolExecutor", new WorkflowToolExecutor(null) { + @Override + public Tool buildTool(Workflow workflow) { + return testTool(workflow.getEnglishName(), workflow.getDescription()); + } + + @Override + public AgentToolExecutionResult execute(Workflow workflow, Map arguments) { + throw new IllegalStateException("jdbc:mysql://internal:3306 secret-token"); + } + }); + AgentToolRuntimeCompilation compilation = compiler.compile(agent(workflowBinding(null, false, "flow-sync"))); + AgentToolContext context = new AgentToolContext(); + context.setAgentId("agent-1"); + context.setSessionId("session-1"); + context.setRequestId("request-1"); + context.setTraceId("trace-1"); + context.setToolCallId("tool-call-1"); + + AgentToolResult result = compilation.getToolInvokers().get("flow-sync").invoke(Map.of(), context); + + Assert.assertFalse(result.isSuccess()); + Assert.assertEquals("工具执行失败,请稍后重试", result.getErrorMessage()); + Assert.assertFalse(result.getModelContent().contains("internal")); + } + private AgentToolRuntimeCompiler compiler() throws Exception { AgentToolRuntimeCompiler compiler = new AgentToolRuntimeCompiler(); setField(compiler, "objectMapper", new ObjectMapper()); @@ -175,10 +211,17 @@ public class AgentToolRuntimeCompilerTest { binding.setEnabled(true); binding.setOptionsJson(Map.of("executionMode", executionMode)); binding.setResourceSnapshot(Map.of( - "id", BigInteger.valueOf(102L), - "name", "插件工具", - "description", "调用插件", - "englishName", "plugin-tool" + "pluginItem", Map.of( + "id", BigInteger.valueOf(102L), + "pluginId", BigInteger.valueOf(202L), + "name", "插件工具", + "description", "调用插件", + "englishName", "plugin-tool" + ), + "plugin", Map.of( + "id", BigInteger.valueOf(202L), + "name", "测试插件" + ) )); return binding; } diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/workspace/AgentWorkspaceCleanupServiceTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/workspace/AgentWorkspaceCleanupServiceTest.java new file mode 100644 index 00000000..67729a7b --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/workspace/AgentWorkspaceCleanupServiceTest.java @@ -0,0 +1,84 @@ +package tech.easyflow.agent.runtime.workspace; + +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.Mockito; +import tech.easyflow.agent.config.AgentWorkspaceProperties; +import tech.easyflow.agent.runtime.AgentRunRegistry; +import tech.easyflow.agent.runtime.lock.AgentRunLock; + +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.time.Duration; +import java.time.Instant; + +/** + * {@link AgentWorkspaceCleanupService} 会话锁竞态保护测试。 + */ +public class AgentWorkspaceCleanupServiceTest { + + /** 临时工作区。 */ + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + /** + * 验证运行已持有同一会话锁但尚未注册活动态时,清理任务无等待跳过目录。 + * + * @throws Exception 测试目录准备失败 + */ + @Test + public void cleanupShouldSkipInitializationWindowWhenSessionLockIsHeld() throws Exception { + Fixture fixture = fixture(); + Mockito.when(fixture.runRegistry.hasActiveSession("runtime-3")).thenReturn(false); + Mockito.when(fixture.runLock.tryAcquire(BigInteger.TWO, "runtime-3")).thenReturn(null); + + fixture.service.cleanup(); + + Assert.assertTrue(Files.isDirectory(fixture.workspace)); + Mockito.verify(fixture.runLock).tryAcquire(BigInteger.TWO, "runtime-3"); + } + + /** + * 验证取得清理锁后仍会重检活动态并释放锁,不删除刚完成注册的目录。 + * + * @throws Exception 测试目录准备失败 + */ + @Test + public void cleanupShouldRecheckActiveSessionAfterAcquiringLock() throws Exception { + Fixture fixture = fixture(); + AgentRunLock.Handle handle = Mockito.mock(AgentRunLock.Handle.class); + Mockito.when(fixture.runRegistry.hasActiveSession("runtime-3")).thenReturn(false, true); + Mockito.when(fixture.runLock.tryAcquire(BigInteger.TWO, "runtime-3")).thenReturn(handle); + + fixture.service.cleanup(); + + Assert.assertTrue(Files.isDirectory(fixture.workspace)); + Mockito.verify(handle).close(); + } + + private Fixture fixture() throws Exception { + AgentWorkspaceProperties properties = new AgentWorkspaceProperties(); + properties.setRoot(temporaryFolder.newFolder("agent-workspaces").getAbsolutePath()); + properties.setRetention(Duration.ofMinutes(1)); + AgentWorkspaceResolver resolver = new AgentWorkspaceResolver(properties); + resolver.initialize(); + Path workspace = resolver.resolve(BigInteger.ONE, BigInteger.TWO, "runtime-3"); + Files.setLastModifiedTime(workspace, FileTime.from(Instant.now().minus(Duration.ofHours(2)))); + Files.setLastModifiedTime( + resolver.activityFile(workspace), FileTime.from(Instant.now().minus(Duration.ofHours(2)))); + AgentRunRegistry runRegistry = Mockito.mock(AgentRunRegistry.class); + AgentRunLock runLock = Mockito.mock(AgentRunLock.class); + return new Fixture(workspace, runRegistry, runLock, + new AgentWorkspaceCleanupService(resolver, properties, runRegistry, runLock)); + } + + private record Fixture(Path workspace, + AgentRunRegistry runRegistry, + AgentRunLock runLock, + AgentWorkspaceCleanupService service) { + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/workspace/AgentWorkspaceResolverTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/workspace/AgentWorkspaceResolverTest.java new file mode 100644 index 00000000..937a8aaf --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/workspace/AgentWorkspaceResolverTest.java @@ -0,0 +1,86 @@ +package tech.easyflow.agent.runtime.workspace; + +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import tech.easyflow.agent.config.AgentWorkspaceProperties; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Agent 会话工作区隔离和路径越界防护测试。 + */ +public class AgentWorkspaceResolverTest { + + /** 临时工作区根目录。 */ + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + /** + * 验证工作区按 tenant、agent、runtimeSession 三级隔离。 + * + * @throws Exception 临时目录创建失败 + */ + @Test + public void resolveShouldCreateThreeLevelIsolatedWorkspace() throws Exception { + AgentWorkspaceResolver resolver = resolver(); + + Path workspace = resolver.resolve(BigInteger.ONE, BigInteger.TWO, "runtime-3"); + + Assert.assertEquals("1/2/runtime-3", resolver.getRealRoot().relativize(workspace).toString()); + Assert.assertTrue(Files.isDirectory(workspace)); + Assert.assertTrue(Files.exists(resolver.activityFile(workspace))); + } + + /** + * 验证 Artifact 读取不能通过父路径或符号链接逃逸工作区。 + * + * @throws Exception 测试文件创建失败 + */ + @Test + public void resolveExistingFileShouldRejectTraversalAndSymlink() throws Exception { + AgentWorkspaceResolver resolver = resolver(); + Path workspace = resolver.resolve(BigInteger.ONE, BigInteger.TWO, "runtime-3"); + Path outside = temporaryFolder.newFile("outside.txt").toPath(); + Files.createSymbolicLink(workspace.resolve("escape.txt"), outside); + + assertForbidden(resolver, workspace, "../outside.txt"); + assertForbidden(resolver, workspace, "escape.txt"); + } + + /** + * 验证 Unix 硬链接不能绕过工作区真实文件归属检查。 + * + * @throws Exception 测试文件或硬链接创建失败 + */ + @Test + public void resolveExistingFileShouldRejectUnixHardlink() throws Exception { + AgentWorkspaceResolver resolver = resolver(); + Path workspace = resolver.resolve(BigInteger.ONE, BigInteger.TWO, "runtime-3"); + Path outside = temporaryFolder.newFile("hardlink-source.txt").toPath(); + Files.createLink(workspace.resolve("hardlink.txt"), outside); + + assertForbidden(resolver, workspace, "hardlink.txt"); + } + + private AgentWorkspaceResolver resolver() throws Exception { + AgentWorkspaceProperties properties = new AgentWorkspaceProperties(); + properties.setRoot(temporaryFolder.newFolder("agent-workspaces").getAbsolutePath()); + AgentWorkspaceResolver resolver = new AgentWorkspaceResolver(properties); + resolver.initialize(); + return resolver; + } + + private void assertForbidden(AgentWorkspaceResolver resolver, Path workspace, String path) { + try { + resolver.resolveExistingFile(workspace, path); + Assert.fail("Expected BusinessException"); + } catch (BusinessException expected) { + Assert.assertNotNull(expected.getMessage()); + } + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/AgentOptionQueryServiceTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/AgentOptionQueryServiceTest.java new file mode 100644 index 00000000..2580e3a0 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/AgentOptionQueryServiceTest.java @@ -0,0 +1,60 @@ +package tech.easyflow.agent.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.agent.vo.AgentResourceOptionsView; +import tech.easyflow.skill.entity.Skill; + +import java.lang.reflect.Method; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +/** + * Agent 设计器安全资源选项测试。 + */ +public class AgentOptionQueryServiceTest { + + /** + * Skill 候选必须使用已发布展示字段,并按发布资源真实统计文本与二进制摘要。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + public void skillOptionShouldUsePublishedMetadataAndResourceKinds() throws Exception { + AgentOptionQueryService service = new AgentOptionQueryService( + null, null, null, null, null, null, null, null, + null, null, null, new ObjectMapper()); + Skill skill = new Skill(); + skill.setId(BigInteger.ONE); + skill.setDisplayName("未发布草稿名称"); + skill.setDescription("未发布草稿描述"); + skill.setVisibilityScope("ALL"); + skill.setSnapshotHash("aggregate-hash"); + skill.setPublishedSnapshotJson(Map.of( + "displayName", "线上名称", + "description", "线上描述", + "visibilityScope", "PRIVATE", + "skillContent", "主", + "resources", List.of( + Map.of("path", "references/a.md", "text", true, "textContent", "参考"), + Map.of("path", "assets/a.bin", "text", false, "contentRef", "sha256:a")))); + skill.setPublishedToolBindingsJson(Map.of( + "bindings", List.of(Map.of("toolCount", 3)))); + + Method method = AgentOptionQueryService.class.getDeclaredMethod("toSkillOption", Skill.class); + method.setAccessible(true); + AgentResourceOptionsView.SkillOption option = + (AgentResourceOptionsView.SkillOption) method.invoke(service, skill); + + Assert.assertEquals("线上名称", option.displayName()); + Assert.assertEquals("线上描述", option.description()); + Assert.assertEquals("PRIVATE", option.visibilityScope()); + Assert.assertEquals(3, option.toolCount()); + Assert.assertEquals(1, option.textResourceCount()); + Assert.assertEquals(1, option.binaryResourceCount()); + Assert.assertEquals(("主" + "参考").getBytes(StandardCharsets.UTF_8).length, option.textBytes()); + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentBindingSemanticComparatorTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentBindingSemanticComparatorTest.java new file mode 100644 index 00000000..d2835de0 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentBindingSemanticComparatorTest.java @@ -0,0 +1,90 @@ +package tech.easyflow.agent.service.impl; + +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.agent.entity.AgentKnowledgeBinding; +import tech.easyflow.agent.entity.AgentSkillBinding; +import tech.easyflow.agent.entity.AgentToolBinding; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; + +/** + * Agent 绑定业务字段比较测试。 + */ +public class AgentBindingSemanticComparatorTest { + + /** + * 验证工具绑定忽略 ID 与审计字段,并识别真实配置变化。 + */ + @Test + public void toolsShouldCompareOnlyPersistedBusinessFields() { + AgentToolBinding persisted = toolBinding(); + persisted.setId(BigInteger.valueOf(99)); + AgentToolBinding requested = toolBinding(); + + Assert.assertTrue(AgentBindingSemanticComparator.sameTools( + List.of(persisted), List.of(requested))); + + requested.setHitlEnabled(true); + Assert.assertFalse(AgentBindingSemanticComparator.sameTools( + List.of(persisted), List.of(requested))); + } + + /** + * 验证知识库默认检索模式与默认启用状态保持幂等。 + */ + @Test + public void knowledgesShouldNormalizeDefaultsBeforeComparison() { + AgentKnowledgeBinding persisted = new AgentKnowledgeBinding(); + persisted.setKnowledgeId(BigInteger.TEN); + persisted.setRetrievalMode("HYBRID"); + persisted.setEnabled(true); + persisted.setOptionsJson(Map.of("limit", 5)); + persisted.setSortNo(0); + + AgentKnowledgeBinding requested = new AgentKnowledgeBinding(); + requested.setKnowledgeId(BigInteger.TEN); + requested.setOptionsJson(Map.of("limit", 5)); + + Assert.assertTrue(AgentBindingSemanticComparator.sameKnowledges( + List.of(persisted), List.of(requested))); + } + + /** + * 验证 Skill 顺序变化会触发替换。 + */ + @Test + public void skillsShouldDetectOrderChanges() { + AgentSkillBinding first = skillBinding(1, 0); + AgentSkillBinding second = skillBinding(2, 1); + + Assert.assertTrue(AgentBindingSemanticComparator.sameSkills( + List.of(first, second), + List.of(skillBinding(1, null), skillBinding(2, null)))); + Assert.assertFalse(AgentBindingSemanticComparator.sameSkills( + List.of(first, second), + List.of(skillBinding(2, null), skillBinding(1, null)))); + } + + private AgentToolBinding toolBinding() { + AgentToolBinding binding = new AgentToolBinding(); + binding.setToolType("PLUGIN"); + binding.setTargetId(BigInteger.ONE); + binding.setToolName("lookup"); + binding.setEnabled(true); + binding.setHitlEnabled(false); + binding.setHitlConfigJson(Map.of()); + binding.setOptionsJson(Map.of("executionMode", "SYNC")); + binding.setSortNo(0); + return binding; + } + + private AgentSkillBinding skillBinding(long id, Integer sortNo) { + AgentSkillBinding binding = new AgentSkillBinding(); + binding.setSkillId(BigInteger.valueOf(id)); + binding.setSortNo(sortNo); + return binding; + } +} diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentBindingValidationLockTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentBindingValidationLockTest.java index 96e8351e..22606687 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentBindingValidationLockTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentBindingValidationLockTest.java @@ -23,8 +23,11 @@ import tech.easyflow.ai.service.PluginVisibilityService; import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.ResourceAccessService; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.service.SkillService; import java.math.BigInteger; +import java.util.Map; import java.util.Locale; /** @@ -157,6 +160,36 @@ public class AgentBindingValidationLockTest { Assert.assertTrue(queryCaptor.getValue().toSQL().toUpperCase(Locale.ROOT).contains("FOR UPDATE")); } + /** + * 验证 Agent 绑定 Skill 时只锁定并校验已发布快照,不触发底层 MCP 在线发现。 + */ + @Test + public void skillBindingShouldConsumePublishedSnapshotWithoutMcpDiscovery() { + Skill skill = new Skill(); + skill.setPublishStatus(PublishStatus.PUBLISHED.getCode()); + skill.setTenantId(BigInteger.ONE); + skill.setPublishedSnapshotJson(Map.of("snapshotHash", "published")); + SkillService skillService = Mockito.mock(SkillService.class); + Mockito.when(skillService.getOne(Mockito.any(QueryWrapper.class))).thenReturn(skill); + ResourceAccessService resourceAccessService = Mockito.mock(ResourceAccessService.class); + AgentDependencyAccessService service = createService( + Mockito.mock(WorkflowService.class), + Mockito.mock(PluginItemService.class), + Mockito.mock(PluginMapper.class), + Mockito.mock(PluginVisibilityService.class), + Mockito.mock(McpService.class), + Mockito.mock(DocumentCollectionService.class), + resourceAccessService, + skillService + ); + + Assert.assertSame(skill, service.requireSkill(agent(), BigInteger.valueOf(5001))); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class); + Mockito.verify(skillService).getOne(queryCaptor.capture()); + Assert.assertTrue(queryCaptor.getValue().toSQL().toUpperCase(Locale.ROOT).contains("FOR UPDATE")); + } + /** * 创建依赖资源校验服务。 * @@ -177,6 +210,40 @@ public class AgentBindingValidationLockTest { McpService mcpService, DocumentCollectionService documentCollectionService, ResourceAccessService resourceAccessService) { + return createService( + workflowService, + pluginItemService, + pluginMapper, + pluginVisibilityService, + mcpService, + documentCollectionService, + resourceAccessService, + Mockito.mock(SkillService.class) + ); + } + + /** + * 创建带指定 Skill 服务的依赖资源校验服务。 + * + * @param workflowService 工作流服务 + * @param pluginItemService 插件工具服务 + * @param pluginMapper 插件 Mapper + * @param pluginVisibilityService 插件可见性服务 + * @param mcpService MCP 服务 + * @param documentCollectionService 知识库服务 + * @param resourceAccessService 资源权限服务 + * @param skillService Skill 服务 + * @return 依赖资源校验服务 + */ + private AgentDependencyAccessService createService( + WorkflowService workflowService, + PluginItemService pluginItemService, + PluginMapper pluginMapper, + PluginVisibilityService pluginVisibilityService, + McpService mcpService, + DocumentCollectionService documentCollectionService, + ResourceAccessService resourceAccessService, + SkillService skillService) { return new AgentDependencyAccessService( Mockito.mock(ModelService.class), workflowService, @@ -187,7 +254,8 @@ public class AgentBindingValidationLockTest { documentCollectionService, Mockito.mock(AgentCategoryService.class), Mockito.mock(CategoryPermissionService.class), - resourceAccessService + resourceAccessService, + skillService ); } diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentResourceBindingProviderImplTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentResourceBindingProviderImplTest.java index 4505c082..313dd34a 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentResourceBindingProviderImplTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentResourceBindingProviderImplTest.java @@ -102,6 +102,41 @@ public class AgentResourceBindingProviderImplTest { Assert.assertEquals("已发布智能体", result.get(0).getTitle()); } + /** + * Agent 已发布 Skill 内部的 Tool 引用也必须阻止资源下线或删除。 + */ + @Test + public void listAgentsByWorkflowIdShouldIncludeNestedSkillToolReference() { + AgentService agentService = Mockito.mock(AgentService.class); + AgentToolBindingService toolBindingService = Mockito.mock(AgentToolBindingService.class); + AgentKnowledgeBindingService knowledgeBindingService = + Mockito.mock(AgentKnowledgeBindingService.class); + BigInteger agentId = BigInteger.valueOf(8); + Agent agent = new Agent(); + agent.setId(agentId); + agent.setName("Skill 智能体"); + agent.setPublishedSnapshotJson(Map.of( + "skillBindings", List.of(Map.of( + "skillId", BigInteger.ONE, + "resourceSnapshot", Map.of( + "toolBindings", List.of(Map.of( + "toolType", AgentToolType.WORKFLOW.name(), + "targetId", BigInteger.TEN))))))); + Mockito.when(toolBindingService.list(Mockito.any(QueryWrapper.class))).thenReturn(List.of()); + Mockito.when(agentService.list(Mockito.any(QueryWrapper.class))).thenReturn(List.of(agent)); + Mockito.when(agentService.listByIds(Mockito.anyCollection())).thenReturn(List.of(agent)); + AgentResourceBindingProviderImpl provider = new AgentResourceBindingProviderImpl( + agentService, + toolBindingService, + knowledgeBindingService, + Mockito.mock(AgentBindingLockExecutor.class)); + + var result = provider.listAgentsByWorkflowId(BigInteger.TEN); + + Assert.assertEquals(1, result.size()); + Assert.assertEquals(agentId, result.get(0).getId()); + } + /** * 构造工作流工具绑定。 * diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentSkillReferenceProviderTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentSkillReferenceProviderTest.java new file mode 100644 index 00000000..9eae9d89 --- /dev/null +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/service/impl/AgentSkillReferenceProviderTest.java @@ -0,0 +1,66 @@ +package tech.easyflow.agent.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; +import tech.easyflow.agent.entity.Agent; +import tech.easyflow.agent.entity.AgentSkillBinding; +import tech.easyflow.agent.service.AgentService; +import tech.easyflow.agent.service.AgentSkillBindingService; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; + +/** + * Agent 对 Skill 的生命周期引用查询测试。 + */ +public class AgentSkillReferenceProviderTest { + + /** + * 草稿绑定和已发布冻结绑定都应阻止 Skill 下线或删除,并按 Agent 去重。 + */ + @Test + public void shouldIncludeDraftAndPublishedSkillReferences() { + AgentService agentService = Mockito.mock(AgentService.class); + AgentSkillBindingService bindingService = Mockito.mock(AgentSkillBindingService.class); + AgentSkillBinding draftBinding = new AgentSkillBinding(); + draftBinding.setAgentId(BigInteger.ONE); + draftBinding.setSkillId(BigInteger.TEN); + Agent published = new Agent(); + published.setId(BigInteger.TWO); + published.setPublishedSnapshotJson(Map.of( + "skillBindings", List.of(Map.of("skillId", BigInteger.TEN)))); + Agent draftAgent = agent(BigInteger.ONE, "草稿引用智能体"); + Agent publishedAgent = agent(BigInteger.TWO, "线上引用智能体"); + Mockito.when(bindingService.list(Mockito.any(QueryWrapper.class))) + .thenReturn(List.of(draftBinding)); + Mockito.when(agentService.list(Mockito.any(QueryWrapper.class))) + .thenReturn(List.of(published)); + Mockito.when(agentService.listByIds(Mockito.anyCollection())) + .thenReturn(List.of(draftAgent, publishedAgent)); + AgentSkillReferenceProvider provider = new AgentSkillReferenceProvider( + agentService, bindingService); + + List references = provider.listReferences(BigInteger.TEN); + + Assert.assertEquals(List.of( + "智能体“草稿引用智能体”", + "智能体“线上引用智能体”"), references); + } + + /** + * 创建 Agent 摘要。 + * + * @param id Agent ID + * @param name 名称 + * @return Agent + */ + private Agent agent(BigInteger id, String name) { + Agent agent = new Agent(); + agent.setId(id); + agent.setName(name); + return agent; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/PluginTool.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/PluginTool.java index 423b50cb..d49956ee 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/PluginTool.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/tool/PluginTool.java @@ -24,6 +24,7 @@ import tech.easyflow.common.ai.plugin.PluginParamConverter; import tech.easyflow.common.filestorage.FileStorageManager; import tech.easyflow.common.filestorage.FileStorageService; import tech.easyflow.common.util.SpringContextUtil; +import tech.easyflow.common.web.exceptions.BusinessException; import com.easyagents.flow.core.util.IoBulkhead; import java.io.*; @@ -32,6 +33,8 @@ import java.math.BigInteger; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class PluginTool extends BaseTool { @@ -43,6 +46,8 @@ public class PluginTool extends BaseTool { private transient PluginItem pluginItemSnapshot; private transient Plugin pluginSnapshot; private static final Logger logger = LoggerFactory.getLogger(PluginTool.class); + private static final Pattern INPUT_REFERENCE = + Pattern.compile("^\\$\\{input:([A-Za-z0-9_.-]+)}$"); public PluginTool() { @@ -175,18 +180,18 @@ public class PluginTool extends BaseTool { List> headers = getDataList(plugin.getHeaders()); Map headersMap = new HashMap<>(); for (Map header : headers) { - headersMap.put((String) header.get("label"), header.get("value")); + headersMap.put((String) header.get("label"), resolveInputReference(header.get("value"))); } List params = new ArrayList<>(); String authType = plugin.getAuthType(); if (!StrUtil.isEmpty(authType) && "apiKey".equals(plugin.getAuthType())){ if ("headers".equals(plugin.getPosition())){ - headersMap.put(plugin.getTokenKey(), plugin.getTokenValue()); + headersMap.put(plugin.getTokenKey(), resolveInputReference(plugin.getTokenValue())); } else { PluginParam pluginParam = new PluginParam(); pluginParam.setName(plugin.getTokenKey()); - pluginParam.setDefaultValue(plugin.getTokenValue()); + pluginParam.setDefaultValue(resolveInputReference(plugin.getTokenValue())); pluginParam.setEnabled(true); pluginParam.setRequired(true); pluginParam.setMethod("query"); @@ -407,6 +412,29 @@ public class PluginTool extends BaseTool { return true; } + /** + * 在实际调用前解析服务端插件输入引用,避免发布快照持久化明文凭据。 + * + * @param rawValue 快照中的字段值 + * @return 原值或服务端解析后的凭据 + * @throws BusinessException 引用未配置时抛出 + */ + private Object resolveInputReference(Object rawValue) { + if (!(rawValue instanceof String text)) { + return rawValue; + } + Matcher matcher = INPUT_REFERENCE.matcher(text.trim()); + if (!matcher.matches()) { + return rawValue; + } + String key = matcher.group(1); + String value = System.getProperty("plugin.input." + key); + if (value == null || value.isBlank()) { + throw new BusinessException("插件输入变量未解析:" + key); + } + return value; + } + private void processParamWithChildren(Map paramDef, Map argsMap, List params) { boolean enabled = (boolean) paramDef.get("enabled"); if (!enabled){ diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/listener/ChainEventListenerForSave.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/listener/ChainEventListenerForSave.java index 755b228e..22a9da36 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/listener/ChainEventListenerForSave.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/listener/ChainEventListenerForSave.java @@ -11,6 +11,7 @@ import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Component; import tech.easyflow.ai.easyagentsflow.event.WorkflowExecutionAuditEvent; import tech.easyflow.ai.easyagentsflow.event.WorkflowExecutionAuditProducer; +import tech.easyflow.ai.easyagentsflow.repository.FrozenWorkflowDefinitionRegistry; import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds; import tech.easyflow.ai.easyagentsflow.support.WorkflowExecutionStepKey; import tech.easyflow.ai.entity.Workflow; @@ -35,6 +36,8 @@ public class ChainEventListenerForSave implements ChainEventListener { private WorkflowExecResultService workflowExecResultService; @Resource private WorkflowExecutionAuditProducer auditProducer; + @Resource + private FrozenWorkflowDefinitionRegistry frozenWorkflowDefinitionRegistry; @Override public void onEvent(Event event, Chain chain) { @@ -245,6 +248,9 @@ public class ChainEventListenerForSave implements ChainEventListener { return null; } String definitionId = definition.getId(); + if (frozenWorkflowDefinitionRegistry.isFrozen(definitionId)) { + return frozenWorkflowDefinitionRegistry.getWorkflow(definitionId); + } String workflowId = PublishedWorkflowDefinitionIds.unwrap(definitionId); try { java.math.BigInteger id = new java.math.BigInteger(workflowId); diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/repository/AgentWorkflowSnapshotFactory.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/repository/AgentWorkflowSnapshotFactory.java new file mode 100644 index 00000000..5effcdc8 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/repository/AgentWorkflowSnapshotFactory.java @@ -0,0 +1,98 @@ +package tech.easyflow.ai.easyagentsflow.repository; + +import com.easyagents.flow.core.chain.ChainDefinition; +import com.easyagents.flow.core.node.ConfirmNode; +import com.easyagents.flow.core.parser.ChainParser; +import org.springframework.stereotype.Component; +import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.node.WorkflowNode; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 构建并校验 Agent 与 Skill 使用的 Workflow 冻结快照。 + * + *

Agent Tool 当前按同步调用执行,因此发布投影只能接受不会依赖子工作流热读、 + * 也不会在内部等待人工确认的定义。独立 Workflow 的发布能力不受此组件限制。

+ */ +@Component +public class AgentWorkflowSnapshotFactory { + + private final ChainParser chainParser; + private final WorkflowDatacenterContentService contentService; + + /** + * 创建 Workflow 冻结快照工厂。 + * + * @param chainParser 工作流定义解析器 + * @param contentService 数据中枢内容准备服务 + */ + public AgentWorkflowSnapshotFactory(ChainParser chainParser, + WorkflowDatacenterContentService contentService) { + this.chainParser = chainParser; + this.contentService = contentService; + } + + /** + * 编译并校验一份 Agent 可执行的 Workflow 定义。 + * + * @param workflow 包含完整 content 的 Workflow + * @return 已准备内容和解析后的定义 + * @throws BusinessException 快照不完整或包含同步 Tool 不支持的节点时抛出 + */ + public PreparedWorkflow prepare(Workflow workflow) { + if (workflow == null || workflow.getId() == null + || workflow.getContent() == null || workflow.getContent().isBlank()) { + throw new BusinessException(409, 4092, "绑定工作流快照不完整,请重新发布工作流"); + } + String preparedContent = contentService.prepareContent(workflow.getContent()); + ChainDefinition definition; + try { + definition = chainParser.parse(preparedContent); + } catch (BusinessException exception) { + throw exception; + } catch (RuntimeException exception) { + throw new BusinessException(409, 4092, "绑定工作流定义无效,请修复后重新发布", exception); + } + if (definition.getNodes() != null + && definition.getNodes().stream().anyMatch(WorkflowNode.class::isInstance)) { + throw new BusinessException(409, 4092, "Agent 或 Skill 绑定的工作流暂不支持子工作流节点"); + } + if (definition.getNodes() != null + && definition.getNodes().stream().anyMatch(ConfirmNode.class::isInstance)) { + throw new BusinessException(409, 4092, "Agent 或 Skill 绑定的工作流暂不支持内部确认节点"); + } + return new PreparedWorkflow(preparedContent, definition); + } + + /** + * 构建字段白名单 Workflow 冻结快照。 + * + * @param workflow 已发布 Workflow + * @return 仅包含 Runtime 所需字段的快照 + * @throws BusinessException Workflow 不兼容同步 Agent Tool 时抛出 + */ + public Map snapshot(Workflow workflow) { + PreparedWorkflow prepared = prepare(workflow); + Map snapshot = new LinkedHashMap<>(); + snapshot.put("id", workflow.getId()); + snapshot.put("title", workflow.getTitle()); + snapshot.put("description", workflow.getDescription()); + snapshot.put("englishName", workflow.getEnglishName()); + snapshot.put("revision", workflow.getRevision()); + snapshot.put("content", prepared.content()); + return snapshot; + } + + /** + * Agent 可执行 Workflow 的准备结果。 + * + * @param content 已完成服务端占位处理的定义内容 + * @param definition 解析后的工作流定义 + */ + public record PreparedWorkflow(String content, ChainDefinition definition) { + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/repository/ChainDefinitionRepositoryImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/repository/ChainDefinitionRepositoryImpl.java index 6231ca27..82b04cc2 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/repository/ChainDefinitionRepositoryImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/repository/ChainDefinitionRepositoryImpl.java @@ -22,9 +22,18 @@ public class ChainDefinitionRepositoryImpl implements ChainDefinitionRepository private WorkflowDatacenterContentService workflowDatacenterContentService; @Resource private WorkflowDefinitionCache workflowDefinitionCache; + @Resource + private FrozenWorkflowDefinitionRegistry frozenWorkflowDefinitionRegistry; @Override public ChainDefinition getChainDefinitionById(String id) { + ChainDefinition frozen = frozenWorkflowDefinitionRegistry.get(id); + if (frozen != null) { + return frozen; + } + if (frozenWorkflowDefinitionRegistry.isFrozen(id)) { + throw new IllegalStateException("Frozen workflow definition is not registered: " + id); + } return workflowDefinitionCache.get(id, () -> loadAndCompile(id)); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/repository/FrozenWorkflowDefinitionRegistry.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/repository/FrozenWorkflowDefinitionRegistry.java new file mode 100644 index 00000000..95fd7a2e --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/repository/FrozenWorkflowDefinitionRegistry.java @@ -0,0 +1,114 @@ +package tech.easyflow.ai.easyagentsflow.repository; + +import com.easyagents.flow.core.chain.ChainDefinition; +import org.springframework.stereotype.Component; +import tech.easyflow.ai.entity.Workflow; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 当前进程内的已发布 Agent 工作流冻结定义注册表。 + * + *

正式 Agent 编译时使用发布快照中的工作流内容生成内容寻址定义,执行阶段只读取该定义, + * 不再按工作流 ID 回查当前发布版本。注册表按访问顺序有界保留,Agent 再次编译时可无损重建。

+ */ +@Component +public class FrozenWorkflowDefinitionRegistry { + + private static final String PREFIX = "agent-frozen:"; + private static final int MAX_ENTRIES = 512; + + private final AgentWorkflowSnapshotFactory snapshotFactory; + private final Map definitions = + new LinkedHashMap<>(32, 0.75F, true); + private final Map workflows = + new LinkedHashMap<>(32, 0.75F, true); + + /** + * 创建冻结定义注册表。 + * + * @param snapshotFactory Agent Workflow 冻结快照工厂 + */ + public FrozenWorkflowDefinitionRegistry(AgentWorkflowSnapshotFactory snapshotFactory) { + this.snapshotFactory = snapshotFactory; + } + + /** + * 注册一份工作流快照并返回内容寻址定义 ID。 + * + * @param workflow 包含完整 content 的工作流快照 + * @return 冻结定义 ID + * @throws tech.easyflow.common.web.exceptions.BusinessException 工作流快照不完整或不兼容时抛出 + */ + public String register(Workflow workflow) { + AgentWorkflowSnapshotFactory.PreparedWorkflow prepared = snapshotFactory.prepare(workflow); + String preparedContent = prepared.content(); + String id = PREFIX + workflow.getId() + ":" + sha256(preparedContent); + synchronized (definitions) { + if (definitions.containsKey(id)) { + definitions.get(id); + return id; + } + ChainDefinition definition = prepared.definition(); + definition.setId(id); + definition.setName(workflow.getEnglishName()); + definition.setDescription(workflow.getDescription()); + definitions.put(id, definition); + workflows.put(id, workflow); + while (definitions.size() > MAX_ENTRIES) { + String eldest = definitions.keySet().iterator().next(); + definitions.remove(eldest); + workflows.remove(eldest); + } + } + return id; + } + + /** + * 获取已注册冻结定义。 + * + * @param definitionId 定义 ID + * @return 冻结定义;不存在时返回 null + */ + public ChainDefinition get(String definitionId) { + synchronized (definitions) { + return definitions.get(definitionId); + } + } + + /** + * 获取冻结定义对应的工作流快照,用于执行审计展示。 + * + * @param definitionId 冻结定义 ID + * @return 工作流快照;不存在时返回 null + */ + public Workflow getWorkflow(String definitionId) { + synchronized (definitions) { + return workflows.get(definitionId); + } + } + + /** + * 判断定义 ID 是否属于冻结 Agent 工作流。 + * + * @param definitionId 定义 ID + * @return 是否冻结定义 + */ + public boolean isFrozen(String definitionId) { + return definitionId != null && definitionId.startsWith(PREFIX); + } + + private String sha256(String content) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(content.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mcp/McpConnectionSnapshotFactory.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mcp/McpConnectionSnapshotFactory.java new file mode 100644 index 00000000..5b3fcfc1 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mcp/McpConnectionSnapshotFactory.java @@ -0,0 +1,192 @@ +package tech.easyflow.ai.mcp; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; +import tech.easyflow.ai.entity.Mcp; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 构建可持久化的 MCP 受控连接快照。 + * + *

连接拓扑可冻结,凭据值只能使用 {@code ${input:key}} 服务端引用, + * 避免发布与审批快照复制已经解析的令牌、Header 或查询参数。

+ */ +@Component +public class McpConnectionSnapshotFactory { + + private static final TypeReference> MAP_TYPE = new TypeReference<>() { }; + private static final Pattern INPUT_REFERENCE = + Pattern.compile("^\\$\\{input:[A-Za-z0-9_.-]+}$"); + private static final Pattern SENSITIVE_NAME = Pattern.compile( + ".*(token|secret|password|passwd|api[_-]?key|authorization|cookie|credential|private[_-]?key).*", + Pattern.CASE_INSENSITIVE); + private static final Set PUBLIC_HEADERS = Set.of( + "accept", "accept-language", "content-type", "user-agent"); + + private final ObjectMapper objectMapper; + + /** + * 创建快照工厂。 + * + * @param objectMapper JSON 映射器 + */ + public McpConnectionSnapshotFactory(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * 构建字段白名单 MCP 连接快照并校验凭据引用。 + * + * @param mcp MCP 资源 + * @return 仅供服务端 Runtime 使用的连接快照 + * @throws BusinessException 配置包含明文凭据或格式无效时抛出 + */ + public Map snapshot(Mcp mcp) { + if (mcp == null || mcp.getId() == null) { + throw new BusinessException("MCP 资源不能为空"); + } + validateCredentialReferences(mcp.getConfigJson()); + Map snapshot = new LinkedHashMap<>(); + snapshot.put("id", mcp.getId()); + snapshot.put("title", mcp.getTitle()); + snapshot.put("description", mcp.getDescription()); + snapshot.put("transportType", mcp.getTransportType()); + snapshot.put("approvalRequired", Boolean.TRUE.equals(mcp.getApprovalRequired())); + snapshot.put("configJson", mcp.getConfigJson()); + snapshot.put("configHash", sha256(mcp.getConfigJson())); + return snapshot; + } + + private void validateCredentialReferences(String configJson) { + if (configJson == null || configJson.isBlank()) { + throw new BusinessException("MCP 配置 JSON 不能为空"); + } + Map config; + try { + config = objectMapper.readValue(configJson, MAP_TYPE); + } catch (Exception exception) { + throw new BusinessException("MCP 配置 JSON 格式错误"); + } + Map servers = map(config.get("mcpServers"), "mcpServers"); + for (Map.Entry entry : servers.entrySet()) { + Map server = map(entry.getValue(), "MCP 服务 " + entry.getKey()); + validateMap(server.get("headers"), "headers", true); + validateMap(server.get("queryParams"), "queryParams", true); + validateMap(server.get("env"), "env", false); + validateUrl(server.get("url")); + validateArgs(server.get("args")); + validateNestedSensitiveValues(server, "mcpServers." + entry.getKey()); + } + } + + private void validateMap(Object value, String field, boolean requireReferenceForAll) { + if (value == null) { + return; + } + Map values = map(value, field); + for (Map.Entry entry : values.entrySet()) { + String key = entry.getKey(); + String text = entry.getValue() == null ? "" : String.valueOf(entry.getValue()).trim(); + boolean publicHeader = "headers".equals(field) + && PUBLIC_HEADERS.contains(key.toLowerCase(Locale.ROOT)); + boolean requiresReference = (requireReferenceForAll && !publicHeader) + || SENSITIVE_NAME.matcher(key).matches(); + if (requiresReference && !text.isEmpty() && !INPUT_REFERENCE.matcher(text).matches()) { + throw new BusinessException("MCP " + field + " 中的凭据必须使用 ${input:key} 引用:" + key); + } + } + } + + private void validateUrl(Object value) { + if (value == null) { + return; + } + String lower = String.valueOf(value).toLowerCase(Locale.ROOT); + if (lower.matches(".*[?&](token|secret|password|api[_-]?key|authorization)=[^&$][^&]*.*") + || lower.matches("^[a-z][a-z0-9+.-]*://[^/@]+:[^/@]+@.*")) { + throw new BusinessException("MCP URL 不能包含明文凭据,请使用 ${input:key} 引用"); + } + } + + private void validateArgs(Object value) { + if (!(value instanceof List args)) { + return; + } + for (int index = 0; index < args.size(); index++) { + Object raw = args.get(index); + String arg = raw == null ? "" : String.valueOf(raw); + if (!SENSITIVE_NAME.matcher(arg).matches()) { + continue; + } + int separator = arg.indexOf('='); + if (separator >= 0 && INPUT_REFERENCE.matcher(arg.substring(separator + 1).trim()).matches()) { + continue; + } + if (separator < 0 && index + 1 < args.size() + && INPUT_REFERENCE.matcher(String.valueOf(args.get(index + 1)).trim()).matches()) { + index++; + continue; + } + throw new BusinessException("MCP 启动参数不能包含明文凭据,请使用 ${input:key} 引用"); + } + } + + /** + * 递归检查扩展配置,防止未知或嵌套敏感字段绕过固定字段校验。 + * + * @param value 当前配置值 + * @param path 配置路径 + */ + private void validateNestedSensitiveValues(Object value, String path) { + if (value instanceof Map values) { + for (Map.Entry entry : values.entrySet()) { + String key = String.valueOf(entry.getKey()); + Object nested = entry.getValue(); + String nestedPath = path + "." + key; + if (SENSITIVE_NAME.matcher(key).matches() + && (nested == null || !INPUT_REFERENCE.matcher(String.valueOf(nested).trim()).matches())) { + throw new BusinessException("MCP 敏感配置必须使用 ${input:key} 引用:" + nestedPath); + } + if (!SENSITIVE_NAME.matcher(key).matches()) { + validateNestedSensitiveValues(nested, nestedPath); + } + } + return; + } + if (value instanceof List values) { + for (int index = 0; index < values.size(); index++) { + validateNestedSensitiveValues(values.get(index), path + "[" + index + "]"); + } + } + } + + private Map map(Object value, String field) { + if (!(value instanceof Map raw)) { + throw new BusinessException("MCP 配置字段必须是对象:" + field); + } + Map result = new LinkedHashMap<>(); + raw.forEach((key, item) -> result.put(String.valueOf(key), item)); + return result; + } + + private String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mcp/McpRuntimeSpecFactory.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mcp/McpRuntimeSpecFactory.java new file mode 100644 index 00000000..de2b57e9 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mcp/McpRuntimeSpecFactory.java @@ -0,0 +1,278 @@ +package tech.easyflow.ai.mcp; + +import com.easyagents.agent.runtime.mcp.McpSpec; +import com.easyagents.agent.runtime.mcp.McpTransportType; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; +import tech.easyflow.ai.entity.Mcp; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 将 EasyFlow MCP 配置映射为无业务状态的运行时连接声明。 + */ +@Component +public class McpRuntimeSpecFactory { + + private static final Pattern INPUT_PATTERN = Pattern.compile("\\$\\{input:([A-Za-z0-9_.-]+)}"); + private static final TypeReference> MAP_TYPE = new TypeReference<>() { }; + + private final ObjectMapper objectMapper; + + /** + * 创建 MCP 运行声明工厂。 + * + * @param objectMapper JSON 映射器 + */ + public McpRuntimeSpecFactory(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * 构建 MCP 运行连接声明。 + * + * @param mcp MCP 资源 + * @param requireUniqueServer 是否要求配置中只有一个服务 + * @return MCP 运行连接声明 + * @throws BusinessException 配置为空、格式错误、多服务或输入变量未解析时抛出 + */ + public McpSpec build(Mcp mcp, boolean requireUniqueServer) { + if (mcp == null || mcp.getId() == null) { + throw new BusinessException("MCP 资源不能为空"); + } + Map config = parseConfig(mcp.getConfigJson()); + Map servers = mapValue(config, "mcpServers"); + if (servers.isEmpty()) { + throw new BusinessException("MCP 配置 JSON 中没有找到任何 MCP 服务"); + } + if (requireUniqueServer && servers.size() != 1) { + throw new BusinessException(409, 4092, "MCP 配置必须且只能包含一个服务,请拆分后重试"); + } + Map.Entry server = servers.entrySet().iterator().next(); + if (!(server.getValue() instanceof Map rawServer)) { + throw new BusinessException("MCP 服务配置必须是对象:" + server.getKey()); + } + Map serverConfig = new LinkedHashMap<>(); + rawServer.forEach((key, value) -> serverConfig.put(String.valueOf(key), value)); + + McpSpec spec = new McpSpec(); + spec.setName("mcp_" + safeSegment(mcp.getId().toString())); + spec.setDescription(firstNonBlank(mcp.getDescription(), mcp.getTitle())); + spec.setTransportType(McpTransportType.from(firstNonBlank( + mcp.getTransportType(), stringValue(serverConfig, "transport", null)))); + spec.setCommand(resolveInput(stringValue(serverConfig, "command", null))); + spec.setArgs(resolveInputs(stringListValue(serverConfig, "args"))); + spec.setEnv(resolveInputMap(stringMapValue(serverConfig, "env"))); + spec.setUrl(resolveInput(stringValue(serverConfig, "url", null))); + spec.setHeaders(resolveInputMap(stringMapValue(serverConfig, "headers"))); + spec.setQueryParams(resolveInputMap(stringMapValue(serverConfig, "queryParams"))); + Duration timeout = durationValue(serverConfig, "timeout"); + if (timeout != null) { + spec.setTimeout(timeout); + } + Duration initializationTimeout = durationValue(serverConfig, "initializationTimeout"); + if (initializationTimeout != null) { + spec.setInitializationTimeout(initializationTimeout); + } + spec.getMetadata().put("mcpId", mcp.getId().toString()); + spec.getMetadata().put("mcpTitle", mcp.getTitle()); + spec.getMetadata().put("serverName", server.getKey()); + return spec; + } + + /** + * 解析 MCP JSON。 + * + * @param configJson MCP JSON + * @return 配置 Map + */ + private Map parseConfig(String configJson) { + if (configJson == null || configJson.isBlank()) { + throw new BusinessException("MCP 配置 JSON 不能为空"); + } + try { + return objectMapper.readValue(configJson, MAP_TYPE); + } catch (Exception exception) { + throw new BusinessException("MCP 配置 JSON 格式错误"); + } + } + + /** + * 读取对象字段。 + * + * @param source 配置 Map + * @param key 字段名 + * @return 对象 Map + */ + private Map mapValue(Map source, String key) { + Object value = source == null ? null : source.get(key); + if (value == null) { + return new LinkedHashMap<>(); + } + if (!(value instanceof Map raw)) { + throw new BusinessException("MCP 配置字段必须是对象:" + key); + } + Map result = new LinkedHashMap<>(); + raw.forEach((rawKey, rawValue) -> result.put(String.valueOf(rawKey), rawValue)); + return result; + } + + /** + * 读取字符串数组字段。 + * + * @param source 配置 Map + * @param key 字段名 + * @return 字符串数组 + */ + private List stringListValue(Map source, String key) { + Object value = source == null ? null : source.get(key); + if (value == null) { + return new ArrayList<>(); + } + if (!(value instanceof Collection collection)) { + throw new BusinessException("MCP 配置字段必须是数组:" + key); + } + List result = new ArrayList<>(); + collection.stream().filter(item -> item != null).forEach(item -> result.add(String.valueOf(item))); + return result; + } + + /** + * 读取字符串 Map 字段。 + * + * @param source 配置 Map + * @param key 字段名 + * @return 字符串 Map + */ + private Map stringMapValue(Map source, String key) { + Map raw = mapValue(source, key); + Map result = new LinkedHashMap<>(); + raw.forEach((name, value) -> { + if (value != null) { + result.put(name, String.valueOf(value)); + } + }); + return result; + } + + /** + * 读取字符串字段。 + * + * @param source 配置 Map + * @param key 字段名 + * @param fallback 默认值 + * @return 字符串值 + */ + private String stringValue(Map source, String key, String fallback) { + Object value = source == null ? null : source.get(key); + if (value == null || String.valueOf(value).isBlank()) { + return fallback; + } + return String.valueOf(value); + } + + /** + * 读取秒数或 ISO-8601 Duration。 + * + * @param source 配置 Map + * @param key 字段名 + * @return Duration 或 null + */ + private Duration durationValue(Map source, String key) { + Object value = source == null ? null : source.get(key); + if (value == null || String.valueOf(value).isBlank()) { + return null; + } + if (value instanceof Number number) { + return Duration.ofSeconds(number.longValue()); + } + try { + return Duration.parse(String.valueOf(value).trim()); + } catch (Exception ignored) { + try { + return Duration.ofSeconds(Long.parseLong(String.valueOf(value).trim())); + } catch (NumberFormatException exception) { + throw new BusinessException("MCP 配置字段必须是秒数或 Duration:" + key); + } + } + } + + /** + * 解析数组中的 MCP 输入变量。 + * + * @param values 原值 + * @return 已解析值 + */ + private List resolveInputs(List values) { + List result = new ArrayList<>(); + values.forEach(value -> result.add(resolveInput(value))); + return result; + } + + /** + * 解析 Map 中的 MCP 输入变量。 + * + * @param values 原值 + * @return 已解析值 + */ + private Map resolveInputMap(Map values) { + Map result = new LinkedHashMap<>(); + values.forEach((key, value) -> result.put(key, resolveInput(value))); + return result; + } + + /** + * 从系统属性解析 MCP 输入变量。 + * + * @param value 原值 + * @return 已解析值 + */ + private String resolveInput(String value) { + if (value == null || value.isBlank()) { + return value; + } + Matcher matcher = INPUT_PATTERN.matcher(value); + StringBuffer result = new StringBuffer(); + while (matcher.find()) { + String key = matcher.group(1); + String replacement = System.getProperty("mcp.input." + key); + if (replacement == null || replacement.isBlank()) { + throw new BusinessException("MCP 输入变量未解析:" + key); + } + matcher.appendReplacement(result, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(result); + return result.toString(); + } + + /** + * 生成安全名称片段。 + * + * @param value 原值 + * @return 安全片段 + */ + private String safeSegment(String value) { + String normalized = value.trim().replaceAll("[^A-Za-z0-9_-]", "_").replaceAll("_+", "_"); + return normalized.isBlank() ? "resource" : normalized; + } + + /** + * 获取首个非空文本。 + * + * @param first 首选值 + * @param second 备选值 + * @return 非空文本 + */ + private String firstNonBlank(String first, String second) { + return first == null || first.isBlank() ? second : first; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/PluginConnectionSnapshotFactory.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/PluginConnectionSnapshotFactory.java new file mode 100644 index 00000000..07b1ff5f --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/plugin/PluginConnectionSnapshotFactory.java @@ -0,0 +1,111 @@ +package tech.easyflow.ai.plugin; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; +import tech.easyflow.ai.entity.Plugin; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 构建可持久化的插件受控连接快照。 + */ +@Component +public class PluginConnectionSnapshotFactory { + + private static final TypeReference>> HEADER_LIST_TYPE = new TypeReference<>() { }; + private static final Pattern INPUT_REFERENCE = + Pattern.compile("^\\$\\{input:[A-Za-z0-9_.-]+}$"); + private static final Set PUBLIC_HEADERS = Set.of( + "accept", "accept-language", "content-type", "user-agent"); + + private final ObjectMapper objectMapper; + + /** + * 创建插件连接快照工厂。 + * + * @param objectMapper JSON 映射器 + */ + public PluginConnectionSnapshotFactory(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * 构建字段白名单快照,并拒绝把插件凭据明文复制到发布快照。 + * + * @param plugin 插件资源 + * @return 服务端 Runtime 使用的连接快照 + * @throws BusinessException 配置缺失或包含明文凭据时抛出 + */ + public Map snapshot(Plugin plugin) { + if (plugin == null || plugin.getId() == null) { + throw new BusinessException("插件资源不能为空"); + } + validateBaseUrl(plugin.getBaseUrl()); + validateHeaders(plugin.getHeaders()); + if ("apiKey".equalsIgnoreCase(plugin.getAuthType()) + && !isInputReference(plugin.getTokenValue())) { + throw new BusinessException("插件鉴权值必须使用 ${input:key} 引用"); + } + Map result = new LinkedHashMap<>(); + result.put("id", plugin.getId()); + result.put("alias", plugin.getAlias()); + result.put("name", plugin.getName()); + result.put("description", plugin.getDescription()); + result.put("baseUrl", plugin.getBaseUrl()); + result.put("authType", plugin.getAuthType()); + result.put("position", plugin.getPosition()); + result.put("headers", plugin.getHeaders()); + result.put("tokenKey", plugin.getTokenKey()); + result.put("tokenValue", plugin.getTokenValue()); + return result; + } + + private void validateBaseUrl(String value) { + if (value == null || value.isBlank()) { + throw new BusinessException("插件基础地址不能为空"); + } + String lower = value.toLowerCase(Locale.ROOT); + if (lower.matches("^[a-z][a-z0-9+.-]*://[^/@]+:[^/@]+@.*") + || lower.matches(".*[?&](token|secret|password|api[_-]?key|authorization)=[^&]+.*")) { + throw new BusinessException("插件基础地址不能包含明文凭据"); + } + } + + private void validateHeaders(String headersJson) { + if (headersJson == null || headersJson.isBlank()) { + return; + } + List> headers; + try { + headers = objectMapper.readValue(headersJson, HEADER_LIST_TYPE); + } catch (Exception exception) { + throw new BusinessException("插件请求头格式错误"); + } + for (Map header : headers) { + String name = text(header.get("label")); + String value = text(header.get("value")); + if (name == null || name.isBlank()) { + throw new BusinessException("插件请求头名称不能为空"); + } + if (!PUBLIC_HEADERS.contains(name.toLowerCase(Locale.ROOT)) + && value != null && !value.isBlank() && !isInputReference(value)) { + throw new BusinessException("插件请求头凭据必须使用 ${input:key} 引用:" + name); + } + } + } + + private boolean isInputReference(String value) { + return value != null && INPUT_REFERENCE.matcher(value.trim()).matches(); + } + + private String text(Object value) { + return value == null ? null : String.valueOf(value).trim(); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AbstractAiResourceLifecycleHandler.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AbstractAiResourceLifecycleHandler.java index 37dd1747..a37cda33 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AbstractAiResourceLifecycleHandler.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/AbstractAiResourceLifecycleHandler.java @@ -210,6 +210,14 @@ public abstract class AbstractAiResourceLifecycleHandler implements ApprovalS protected void afterOffline(BigInteger resourceId) { } + /** + * 下线真正生效前的二次引用检查钩子。 + * + * @param resourceId 资源 ID + */ + protected void beforeOffline(BigInteger resourceId) { + } + /** * 删除成功前的额外副作用。 * @@ -344,6 +352,7 @@ public abstract class AbstractAiResourceLifecycleHandler implements ApprovalS return; } if (normalizedAction == ApprovalActionType.OFFLINE) { + beforeOffline(resourceId); markResourceOffline(resourceId); afterOffline(resourceId); return; diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandler.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandler.java index 442bc59d..385d70a7 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandler.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandler.java @@ -7,6 +7,7 @@ import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService; import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; import tech.easyflow.ai.service.ResourceOfflineImpactService; +import tech.easyflow.ai.service.AgentResourceReferenceService; import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.ai.service.WorkflowScheduleReferenceProvider; import tech.easyflow.ai.vo.OfflineImpactCheckVo; @@ -34,6 +35,7 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH private final ResourceOfflineImpactService resourceOfflineImpactService; private final WorkflowPluginBindingService workflowPluginBindingService; private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver; + private final AgentResourceReferenceService agentResourceReferenceService; private final List workflowScheduleReferenceProviders; public WorkflowApprovalSubjectHandler(WorkflowService workflowService, @@ -42,6 +44,7 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH ResourceOfflineImpactService resourceOfflineImpactService, WorkflowPluginBindingService workflowPluginBindingService, WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver, + AgentResourceReferenceService agentResourceReferenceService, ObjectMapper objectMapper, List workflowScheduleReferenceProviders) { super(approvalInstanceService, objectMapper); @@ -50,6 +53,7 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH this.resourceOfflineImpactService = resourceOfflineImpactService; this.workflowPluginBindingService = workflowPluginBindingService; this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver; + this.agentResourceReferenceService = agentResourceReferenceService; this.workflowScheduleReferenceProviders = workflowScheduleReferenceProviders == null ? List.of() : List.copyOf(workflowScheduleReferenceProviders); @@ -186,14 +190,12 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH if (impact.isHasPluginBindings()) { snapshot.put("pluginBindings", impact.getPluginBindings()); } + agentResourceReferenceService.assertWorkflowUnused(resource.getId()); } @Override protected void validateDelete(Workflow resource, PublishStatus currentStatus) { - OfflineImpactCheckVo impact = resourceOfflineImpactService.checkWorkflowImpact(resource.getId()); - if (impact.isHasAgentBindings()) { - throw new BusinessException("此工作流仍被智能体使用,请先取消绑定后再删除"); - } + agentResourceReferenceService.assertWorkflowUnused(resource.getId()); OfflineImpactBindingVo scheduledJob = findFirstScheduledJobReference(resource.getId()); if (scheduledJob != null) { String jobName = scheduledJob.getTitle() == null ? "未命名任务" : scheduledJob.getTitle(); @@ -235,7 +237,7 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH } @Override - protected void afterOffline(BigInteger resourceId) { - resourceOfflineImpactService.unbindWorkflowFromAgents(resourceId); + protected void beforeOffline(BigInteger resourceId) { + agentResourceReferenceService.assertWorkflowUnused(resourceId); } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/AgentResourceReferenceService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/AgentResourceReferenceService.java index e9cf20e5..16f22d3b 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/AgentResourceReferenceService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/AgentResourceReferenceService.java @@ -19,6 +19,21 @@ public interface AgentResourceReferenceService { */ List listAgentsByWorkflowId(BigInteger workflowId); + /** + * 查询引用指定工作流的 Skill。 + * + * @param workflowId 工作流 ID + * @return Skill 摘要列表 + */ + List listSkillsByWorkflowId(BigInteger workflowId); + + /** + * 校验工作流没有被 Agent、Skill 草稿或有效发布快照引用。 + * + * @param workflowId 工作流 ID + */ + void assertWorkflowUnused(BigInteger workflowId); + /** * 查询引用指定知识库的 Agent。 * diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/SkillToolReferenceProvider.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/SkillToolReferenceProvider.java new file mode 100644 index 00000000..d86e8941 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/SkillToolReferenceProvider.java @@ -0,0 +1,21 @@ +package tech.easyflow.ai.service; + +import tech.easyflow.ai.vo.OfflineImpactBindingVo; + +import java.math.BigInteger; +import java.util.List; + +/** + * Skill 对平台 Tool 资源引用的模块扩展点。 + */ +public interface SkillToolReferenceProvider { + + /** @param workflowId 工作流 ID @return 引用该工作流的 Skill 摘要 */ + List listSkillsByWorkflowId(BigInteger workflowId); + + /** @param pluginItemId 插件工具 ID @return 引用该插件工具的 Skill 摘要 */ + List listSkillsByPluginItemId(BigInteger pluginItemId); + + /** @param mcpId MCP ID @return 引用该 MCP 的 Skill 摘要 */ + List listSkillsByMcpId(BigInteger mcpId); +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/AgentResourceReferenceServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/AgentResourceReferenceServiceImpl.java index 03242904..b8a930d4 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/AgentResourceReferenceServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/AgentResourceReferenceServiceImpl.java @@ -1,8 +1,10 @@ package tech.easyflow.ai.service.impl; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.ObjectProvider; import tech.easyflow.ai.service.AgentResourceBindingProvider; import tech.easyflow.ai.service.AgentResourceReferenceService; +import tech.easyflow.ai.service.SkillToolReferenceProvider; import tech.easyflow.ai.vo.OfflineImpactBindingVo; import tech.easyflow.common.web.exceptions.BusinessException; @@ -20,15 +22,19 @@ import java.util.function.Function; @Service public class AgentResourceReferenceServiceImpl implements AgentResourceReferenceService { - private final List providers; + private final ObjectProvider providers; + private final ObjectProvider skillProviders; /** * 创建 Agent 资源引用查询服务。 * * @param providers Agent 资源绑定提供者 + * @param skillProviders Skill Tool 引用提供者 */ - public AgentResourceReferenceServiceImpl(List providers) { - this.providers = providers == null ? List.of() : List.copyOf(providers); + public AgentResourceReferenceServiceImpl(ObjectProvider providers, + ObjectProvider skillProviders) { + this.providers = providers; + this.skillProviders = skillProviders; } /** @@ -39,6 +45,19 @@ public class AgentResourceReferenceServiceImpl implements AgentResourceReference return merge(provider -> provider.listAgentsByWorkflowId(workflowId)); } + /** {@inheritDoc} */ + @Override + public List listSkillsByWorkflowId(BigInteger workflowId) { + return mergeSkills(provider -> provider.listSkillsByWorkflowId(workflowId)); + } + + /** {@inheritDoc} */ + @Override + public void assertWorkflowUnused(BigInteger workflowId) { + assertUnused(merge(provider -> provider.listAgentsByWorkflowId(workflowId)), "工作流"); + assertUnused(mergeSkills(provider -> provider.listSkillsByWorkflowId(workflowId)), "工作流"); + } + /** * {@inheritDoc} */ @@ -60,6 +79,7 @@ public class AgentResourceReferenceServiceImpl implements AgentResourceReference merge(provider -> provider.listAgentsByPluginItemId(pluginItemId)), "插件工具" ); + assertUnused(mergeSkills(provider -> provider.listSkillsByPluginItemId(pluginItemId)), "插件工具"); } } @@ -69,6 +89,7 @@ public class AgentResourceReferenceServiceImpl implements AgentResourceReference @Override public void assertMcpUnused(BigInteger mcpId) { assertUnused(merge(provider -> provider.listAgentsByMcpId(mcpId)), "MCP"); + assertUnused(mergeSkills(provider -> provider.listSkillsByMcpId(mcpId)), "MCP"); } /** @@ -127,6 +148,23 @@ public class AgentResourceReferenceServiceImpl implements AgentResourceReference return new ArrayList<>(merged.values()); } + private List mergeSkills( + Function> loader) { + Map merged = new LinkedHashMap<>(); + for (SkillToolReferenceProvider provider : skillProviders.orderedStream().toList()) { + List bindings = loader.apply(provider); + if (bindings == null) { + continue; + } + for (OfflineImpactBindingVo binding : bindings) { + if (binding != null && binding.getId() != null) { + merged.putIfAbsent(binding.getId(), binding); + } + } + } + return new ArrayList<>(merged.values()); + } + /** * 校验资源未被任何 Agent 引用。 * @@ -139,8 +177,8 @@ public class AgentResourceReferenceServiceImpl implements AgentResourceReference } String agentTitle = bindings.get(0).getTitle(); throw new BusinessException( - resourceLabel + "仍被智能体“" + (agentTitle == null ? "未命名智能体" : agentTitle) - + "”使用,请先取消绑定或重新发布智能体后再删除" + resourceLabel + "仍被" + (agentTitle == null ? "其他资源" : agentTitle) + + "使用,请先取消绑定或重新发布后再操作" ); } @@ -150,9 +188,12 @@ public class AgentResourceReferenceServiceImpl implements AgentResourceReference * @return Agent 资源绑定提供者 */ private List requireProviders() { - if (providers.isEmpty()) { + List resolvedProviders = providers == null + ? List.of() + : providers.orderedStream().toList(); + if (resolvedProviders.isEmpty()) { throw new BusinessException("Agent 资源引用检查服务不可用,请稍后重试"); } - return providers; + return resolvedProviders; } } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ResourceOfflineImpactServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ResourceOfflineImpactServiceImpl.java index 9822d071..ced1a3a3 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ResourceOfflineImpactServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/ResourceOfflineImpactServiceImpl.java @@ -59,17 +59,21 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe @Override public OfflineImpactCheckVo checkWorkflowImpact(BigInteger workflowId) { List agentBindings = listAgentsByWorkflowId(workflowId); + List skillBindings = + agentResourceReferenceService.listSkillsByWorkflowId(workflowId); List pluginBindings = workflowPluginDependencyService.listPluginsByWorkflowId(workflowId); OfflineImpactCheckVo result = new OfflineImpactCheckVo(); - result.setCanProceed(true); + result.setCanProceed(agentBindings.isEmpty() && skillBindings.isEmpty() && pluginBindings.isEmpty()); result.setAgentBindings(agentBindings); result.setHasAgentBindings(!agentBindings.isEmpty()); + result.setSkillBindings(skillBindings); + result.setHasSkillBindings(!skillBindings.isEmpty()); result.setPluginBindings(pluginBindings); result.setHasPluginBindings(!pluginBindings.isEmpty()); result.setWorkflowUsages(Collections.emptyList()); result.setHasWorkflowUsages(false); - result.setMessage(resolveWorkflowOfflineImpactMessage(agentBindings, pluginBindings)); + result.setMessage(resolveWorkflowOfflineImpactMessage(agentBindings, skillBindings, pluginBindings)); return result; } @@ -167,21 +171,27 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe * 生成工作流下线影响提示。 * * @param agentBindings Agent 绑定 + * @param skillBindings Skill 绑定 * @param pluginBindings 插件绑定 * @return 提示信息 */ private String resolveWorkflowOfflineImpactMessage(List agentBindings, + List skillBindings, List pluginBindings) { - if (!pluginBindings.isEmpty() && !agentBindings.isEmpty()) { - return "当前工作流被插件和智能体引用,下线后插件将不可用,智能体将自动解绑"; + List referenceTypes = new ArrayList<>(3); + if (!agentBindings.isEmpty()) { + referenceTypes.add("智能体"); + } + if (!skillBindings.isEmpty()) { + referenceTypes.add("Skill"); } if (!pluginBindings.isEmpty()) { - return "当前工作流被插件引用,下线后相关插件将不可用"; + referenceTypes.add("插件"); } - if (!agentBindings.isEmpty()) { - return "当前工作流下线成功后,将自动从相关智能体中解绑"; + if (!referenceTypes.isEmpty()) { + return "当前工作流仍被" + String.join("、", referenceTypes) + "引用,请先取消引用后再下线"; } - return "当前工作流下线后不会影响已有绑定"; + return "当前工作流可以下线"; } /** diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/OfflineImpactCheckVo.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/OfflineImpactCheckVo.java index 90329e02..21367751 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/OfflineImpactCheckVo.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/OfflineImpactCheckVo.java @@ -16,12 +16,16 @@ public class OfflineImpactCheckVo { private boolean hasPluginBindings; + private boolean hasSkillBindings; + private List agentBindings = new ArrayList<>(); private List workflowUsages = new ArrayList<>(); private List pluginBindings = new ArrayList<>(); + private List skillBindings = new ArrayList<>(); + private String message; /** @@ -130,6 +134,42 @@ public class OfflineImpactCheckVo { this.pluginBindings = pluginBindings; } + /** + * 是否存在 Skill 绑定。 + * + * @return 是否存在 Skill 绑定 + */ + public boolean isHasSkillBindings() { + return hasSkillBindings; + } + + /** + * 设置是否存在 Skill 绑定。 + * + * @param hasSkillBindings 是否存在 Skill 绑定 + */ + public void setHasSkillBindings(boolean hasSkillBindings) { + this.hasSkillBindings = hasSkillBindings; + } + + /** + * 获取 Skill 绑定列表。 + * + * @return Skill 绑定列表 + */ + public List getSkillBindings() { + return skillBindings; + } + + /** + * 设置 Skill 绑定列表。 + * + * @param skillBindings Skill 绑定列表 + */ + public void setSkillBindings(List skillBindings) { + this.skillBindings = skillBindings; + } + /** * 获取提示信息。 * diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/repository/AgentWorkflowSnapshotFactoryTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/repository/AgentWorkflowSnapshotFactoryTest.java new file mode 100644 index 00000000..01e077b5 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/repository/AgentWorkflowSnapshotFactoryTest.java @@ -0,0 +1,94 @@ +package tech.easyflow.ai.easyagentsflow.repository; + +import com.easyagents.flow.core.chain.ChainDefinition; +import com.easyagents.flow.core.node.ConfirmNode; +import com.easyagents.flow.core.parser.ChainParser; +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.node.WorkflowNode; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.math.BigInteger; +import java.util.Map; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Agent Workflow 冻结快照兼容性测试。 + */ +public class AgentWorkflowSnapshotFactoryTest { + + /** + * 验证快照只保留 Runtime 白名单字段并使用准备后的内容。 + */ + @Test + public void shouldBuildWhitelistedSnapshotFromPreparedContent() { + ChainDefinition definition = new ChainDefinition(); + Workflow workflow = workflow(); + AgentWorkflowSnapshotFactory factory = factory(definition); + + Map snapshot = factory.snapshot(workflow); + + Assert.assertEquals(workflow.getId(), snapshot.get("id")); + Assert.assertEquals("prepared-content", snapshot.get("content")); + Assert.assertEquals(6, snapshot.size()); + Assert.assertFalse(snapshot.containsKey("tenantId")); + Assert.assertFalse(snapshot.containsKey("publishedSnapshotJson")); + } + + /** + * 验证 Skill 或 Agent 发布投影会提前拒绝子工作流节点。 + */ + @Test + public void shouldRejectSubWorkflowNode() { + ChainDefinition definition = new ChainDefinition(); + definition.addNode(new WorkflowNode()); + + assertConflict(factory(definition), "子工作流节点"); + } + + /** + * 验证 Skill 或 Agent 发布投影会提前拒绝内部确认节点。 + */ + @Test + public void shouldRejectConfirmNode() { + ChainDefinition definition = new ChainDefinition(); + definition.addNode(new ConfirmNode()); + + assertConflict(factory(definition), "内部确认节点"); + } + + private AgentWorkflowSnapshotFactory factory(ChainDefinition definition) { + ChainParser parser = mock(ChainParser.class); + WorkflowDatacenterContentService contentService = mock(WorkflowDatacenterContentService.class); + when(contentService.prepareContent("raw-content")).thenReturn("prepared-content"); + when(parser.parse("prepared-content")).thenReturn(definition); + return new AgentWorkflowSnapshotFactory(parser, contentService); + } + + private Workflow workflow() { + Workflow workflow = new Workflow(); + workflow.setId(BigInteger.ONE); + workflow.setTitle("合同审查"); + workflow.setDescription("审查合同风险"); + workflow.setEnglishName("contract_review"); + workflow.setRevision(3); + workflow.setContent("raw-content"); + workflow.setTenantId(BigInteger.TEN); + workflow.setPublishedSnapshotJson(Map.of("secret", "hidden")); + return workflow; + } + + private void assertConflict(AgentWorkflowSnapshotFactory factory, String message) { + try { + factory.snapshot(workflow()); + Assert.fail("Expected incompatible workflow to be rejected"); + } catch (BusinessException exception) { + Assert.assertEquals(409, exception.getHttpStatus()); + Assert.assertTrue(exception.getMessage(), exception.getMessage().contains(message)); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandlerTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandlerTest.java index e41c4193..af40ae22 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandlerTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/publish/WorkflowApprovalSubjectHandlerTest.java @@ -8,6 +8,7 @@ import tech.easyflow.ai.enums.PublishStatus; import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService; import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver; import tech.easyflow.ai.service.ResourceOfflineImpactService; +import tech.easyflow.ai.service.AgentResourceReferenceService; import tech.easyflow.ai.service.WorkflowScheduleReferenceProvider; import tech.easyflow.ai.service.WorkflowService; import tech.easyflow.ai.vo.OfflineImpactBindingVo; @@ -50,6 +51,7 @@ public class WorkflowApprovalSubjectHandlerTest { offlineImpactService, mock(WorkflowPluginBindingService.class), mock(WorkflowPluginSnapshotResolver.class), + mock(AgentResourceReferenceService.class), new ObjectMapper(), List.of(scheduleReferenceProvider) ); @@ -87,6 +89,7 @@ public class WorkflowApprovalSubjectHandlerTest { offlineImpactService, mock(WorkflowPluginBindingService.class), mock(WorkflowPluginSnapshotResolver.class), + mock(AgentResourceReferenceService.class), new ObjectMapper(), List.of(scheduleReferenceProvider) ); diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/security/ConnectionSnapshotFactoryTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/security/ConnectionSnapshotFactoryTest.java new file mode 100644 index 00000000..7096410f --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/security/ConnectionSnapshotFactoryTest.java @@ -0,0 +1,122 @@ +package tech.easyflow.ai.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.ai.entity.Mcp; +import tech.easyflow.ai.entity.Plugin; +import tech.easyflow.ai.mcp.McpConnectionSnapshotFactory; +import tech.easyflow.ai.plugin.PluginConnectionSnapshotFactory; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.math.BigInteger; +import java.util.Map; + +/** + * 连接资源发布快照的凭据边界测试。 + */ +public class ConnectionSnapshotFactoryTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * MCP 快照应保留拓扑和服务端输入引用,同时拒绝复制明文凭据。 + */ + @Test + public void mcpSnapshotShouldKeepReferencesAndRejectPlaintextCredentials() { + McpConnectionSnapshotFactory factory = new McpConnectionSnapshotFactory(objectMapper); + Mcp mcp = mcp(""" + {"mcpServers":{"demo":{"url":"https://mcp.example.test/api", + "headers":{"Authorization":"${input:mcp.token}"}, + "queryParams":{"tenant":"${input:mcp.tenant}"}}}} + """); + + Map snapshot = factory.snapshot(mcp); + + Assert.assertEquals(mcp.getId(), snapshot.get("id")); + Assert.assertTrue(String.valueOf(snapshot.get("configJson")).contains("${input:mcp.token}")); + Assert.assertNotNull(snapshot.get("configHash")); + assertBusinessFailure(() -> factory.snapshot(mcp(""" + {"mcpServers":{"demo":{"url":"https://mcp.example.test/api", + "headers":{"Authorization":"Bearer plaintext-secret"}}}} + """)), "必须使用"); + assertBusinessFailure(() -> factory.snapshot(mcp(""" + {"mcpServers":{"demo":{"url":"https://mcp.example.test/api", + "extension":{"nestedApiKey":"plaintext-secret"}}}} + """)), "敏感配置"); + } + + /** + * Plugin 快照应只接受服务端输入引用形式的鉴权值和私有请求头。 + */ + @Test + public void pluginSnapshotShouldKeepReferencesAndRejectPlaintextCredentials() { + PluginConnectionSnapshotFactory factory = new PluginConnectionSnapshotFactory(objectMapper); + Plugin plugin = plugin("${input:plugin.token}", + "[{\"label\":\"Authorization\",\"value\":\"${input:plugin.header}\"}]"); + + Map snapshot = factory.snapshot(plugin); + + Assert.assertEquals("${input:plugin.token}", snapshot.get("tokenValue")); + Assert.assertFalse(snapshot.containsKey("tenantId")); + assertBusinessFailure(() -> factory.snapshot(plugin( + "plaintext-secret", + "[{\"label\":\"Authorization\",\"value\":\"${input:plugin.header}\"}]")), + "鉴权值"); + assertBusinessFailure(() -> factory.snapshot(plugin( + "${input:plugin.token}", + "[{\"label\":\"X-Secret\",\"value\":\"plaintext-secret\"}]")), + "请求头凭据"); + } + + /** + * 创建测试 MCP。 + * + * @param configJson MCP 配置 + * @return MCP + */ + private Mcp mcp(String configJson) { + Mcp mcp = new Mcp(); + mcp.setId(BigInteger.ONE); + mcp.setTitle("测试 MCP"); + mcp.setTransportType("SSE"); + mcp.setConfigJson(configJson); + return mcp; + } + + /** + * 创建测试 Plugin。 + * + * @param tokenValue 鉴权值 + * @param headers 请求头 JSON + * @return Plugin + */ + private Plugin plugin(String tokenValue, String headers) { + Plugin plugin = new Plugin(); + plugin.setId(BigInteger.TWO); + plugin.setName("测试插件"); + plugin.setBaseUrl("https://plugin.example.test/api"); + plugin.setAuthType("apiKey"); + plugin.setPosition("headers"); + plugin.setTokenKey("Authorization"); + plugin.setTokenValue(tokenValue); + plugin.setHeaders(headers); + plugin.setTenantId(99L); + return plugin; + } + + /** + * 断言业务校验失败且消息可定位。 + * + * @param action 待执行动作 + * @param messageFragment 消息片段 + */ + private void assertBusinessFailure(Runnable action, String messageFragment) { + try { + action.run(); + Assert.fail("Expected credential validation failure"); + } catch (BusinessException exception) { + Assert.assertTrue(exception.getMessage(), exception.getMessage().contains(messageFragment)); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/ResourceOfflineImpactServiceImplTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/ResourceOfflineImpactServiceImplTest.java index 9b6f406a..be6a31a6 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/ResourceOfflineImpactServiceImplTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/ResourceOfflineImpactServiceImplTest.java @@ -41,6 +41,7 @@ public class ResourceOfflineImpactServiceImplTest { BigInteger workflowId = BigInteger.valueOf(10); OfflineImpactBindingVo binding = binding(BigInteger.ONE, "测试智能体"); when(referenceService.listAgentsByWorkflowId(workflowId)).thenReturn(List.of(binding)); + when(referenceService.listSkillsByWorkflowId(workflowId)).thenReturn(Collections.emptyList()); when(pluginDependencyService.listPluginsByWorkflowId(workflowId)) .thenReturn(Collections.emptyList()); ResourceOfflineImpactServiceImpl service = new ResourceOfflineImpactServiceImpl( @@ -50,11 +51,37 @@ public class ResourceOfflineImpactServiceImplTest { service.unbindWorkflowFromAgents(workflowId); Assert.assertTrue(result.isHasAgentBindings()); + Assert.assertFalse(result.isCanProceed()); Assert.assertEquals(List.of(binding), result.getAgentBindings()); Assert.assertTrue(result.getMessage().contains("智能体")); verify(referenceService).unbindWorkflow(workflowId); } + /** + * 验证 Skill 引用会直接阻止工作流下线并返回可处理摘要。 + */ + @Test + public void shouldBlockWorkflowOfflineWhenSkillReferencesIt() { + WorkflowService workflowService = mock(WorkflowService.class); + DocumentCollectionService documentCollectionService = mock(DocumentCollectionService.class); + WorkflowPluginDependencyService pluginDependencyService = mock(WorkflowPluginDependencyService.class); + AgentResourceReferenceService referenceService = mock(AgentResourceReferenceService.class); + BigInteger workflowId = BigInteger.valueOf(10); + OfflineImpactBindingVo skill = binding(BigInteger.valueOf(3), "合同审查 Skill"); + when(referenceService.listAgentsByWorkflowId(workflowId)).thenReturn(Collections.emptyList()); + when(referenceService.listSkillsByWorkflowId(workflowId)).thenReturn(List.of(skill)); + when(pluginDependencyService.listPluginsByWorkflowId(workflowId)).thenReturn(Collections.emptyList()); + ResourceOfflineImpactServiceImpl service = new ResourceOfflineImpactServiceImpl( + workflowService, documentCollectionService, pluginDependencyService, referenceService); + + OfflineImpactCheckVo result = service.checkWorkflowImpact(workflowId); + + Assert.assertFalse(result.isCanProceed()); + Assert.assertTrue(result.isHasSkillBindings()); + Assert.assertEquals(List.of(skill), result.getSkillBindings()); + Assert.assertTrue(result.getMessage().contains("Skill")); + } + /** * 验证知识库影响结果使用 Agent 绑定字段并委托 Agent 解绑。 */ diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/core/runtime/ChatAssistantAccumulatorTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/core/runtime/ChatAssistantAccumulatorTest.java index 1d17606d..aa921762 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/core/runtime/ChatAssistantAccumulatorTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/core/runtime/ChatAssistantAccumulatorTest.java @@ -91,4 +91,73 @@ public class ChatAssistantAccumulatorTest { Assert.assertEquals("mcp_123_search", toolCalls.get(0).get("name")); Assert.assertEquals("知识库 MCP - search", toolCalls.get(0).get("toolDisplayName")); } + + /** + * Skill 状态应按稳定键原位更新、剔除内部字段并持久化为可回放终态。 + */ + @Test + @SuppressWarnings("unchecked") + public void shouldPersistWhitelistedSkillInvocationTerminalState() { + ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator(); + accumulator.appendSkillInvocationStatus(Map.of( + "statusKey", "skill-invocation:round-1:skill-1", + "status", "RUNNING", + "skillId", "skill-1", + "skillDisplayName", "合同审查", + "internalSnapshot", "must-not-leak")); + accumulator.appendSkillInvocationStatus(Map.of( + "statusKey", "skill-invocation:round-1:skill-1", + "status", "SUCCESS", + "skillId", "skill-1", + "skillDisplayName", "合同审查")); + + List> statuses = (List>) accumulator + .buildPayload("完成") + .get("skillInvocationStatuses"); + + Assert.assertEquals(1, statuses.size()); + Assert.assertEquals("SUCCESS", statuses.get(0).get("status")); + Assert.assertFalse(statuses.get(0).containsKey("internalSnapshot")); + } + + /** + * 流式运行异常时仍在执行的 Skill 应收口为可恢复失败状态。 + */ + @Test + @SuppressWarnings("unchecked") + public void shouldFinalizePendingSkillInvocationAfterRunFailure() { + ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator(); + accumulator.appendSkillInvocationStatus(Map.of( + "statusKey", "skill-invocation:request-1:skill-1", + "status", "RUNNING", + "skillId", "skill-1")); + + accumulator.finalizePendingSkillInvocations("FAILED", "本轮运行失败"); + List> statuses = (List>) accumulator + .buildPayload(null) + .get("skillInvocationStatuses"); + + Assert.assertEquals("FAILED", statuses.get(0).get("status")); + Assert.assertEquals("本轮运行失败", statuses.get(0).get("message")); + } + + /** + * 正常流结束但缺失终态事件时应落为未完成,避免历史页长期显示运行中。 + */ + @Test + @SuppressWarnings("unchecked") + public void shouldConvertDanglingRunningSkillInvocationToIncomplete() { + ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator(); + accumulator.appendSkillInvocationStatus(Map.of( + "statusKey", "skill-invocation:round-1:skill-1", + "status", "RUNNING", + "skillId", "skill-1")); + + List> statuses = (List>) accumulator + .buildPayload(null) + .get("skillInvocationStatuses"); + + Assert.assertEquals("INCOMPLETE", statuses.get(0).get("status")); + Assert.assertEquals("技能调用未完成", statuses.get(0).get("message")); + } } diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImpl.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImpl.java index 981a0702..2342ee73 100644 --- a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImpl.java +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImpl.java @@ -31,6 +31,7 @@ import tech.easyflow.approval.mapper.ApprovalTaskMapper; import tech.easyflow.approval.service.ApprovalActionFacade; import tech.easyflow.approval.service.ApprovalAssigneeService; import tech.easyflow.approval.service.ApprovalQueryService; +import tech.easyflow.approval.support.ApprovalSnapshotProjection; import tech.easyflow.system.entity.SysAccount; import tech.easyflow.system.service.CategoryPermissionService; import tech.easyflow.system.service.SysAccountService; @@ -171,7 +172,8 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService { detail.setApplicantId(instance.getApplicantId()); detail.setSubmittedAt(instance.getSubmittedAt()); detail.setFinishedAt(instance.getFinishedAt()); - detail.setSnapshotJson(instance.getSnapshotJson()); + detail.setSnapshotJson(ApprovalSnapshotProjection.project( + instance.getResourceType(), instance.getSnapshotJson())); List logs = approvalLogMapper.selectListByQuery( QueryWrapper.create().eq(ApprovalLog::getInstanceId, instanceId)); diff --git a/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/support/ApprovalSnapshotProjection.java b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/support/ApprovalSnapshotProjection.java new file mode 100644 index 00000000..55e0e844 --- /dev/null +++ b/easyflow-modules/easyflow-module-approval/src/main/java/tech/easyflow/approval/support/ApprovalSnapshotProjection.java @@ -0,0 +1,276 @@ +package tech.easyflow.approval.support; + +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * 审批详情快照的响应投影。 + * + *

审批实例仍持久化完整冻结快照,详情响应仅投影审核需要的摘要字段,避免内部运行正文、 + * 资源快照和连接凭据通过管理端查询接口泄露。

+ */ +public final class ApprovalSnapshotProjection { + + private static final Set STRICT_RESOURCE_TYPES = Set.of("AGENT", "SKILL"); + private static final Set REMOVED_KEYS = Set.of( + "skillcontent", + "content", + "contentref", + "resources", + "resourcecontent", + "publishedsnapshotjson", + "publishedtoolbindingsjson", + "prompt", + "systemprompt", + "instructions", + "instruction", + "script", + "source", + "code", + "body", + "text", + "raw", + "payload", + "tools", + "mcptoolmanifest", + "manifest", + "headers", + "header", + "environment", + "env", + "command", + "args", + "arguments", + "query", + "endpoint", + "url", + "uri", + "credentials", + "credential", + "secret", + "password", + "token", + "apikey", + "privatekey", + "inputschema", + "outputschema", + "schema"); + + private ApprovalSnapshotProjection() { + } + + /** + * 将审批冻结快照转换为可下发的响应副本。 + * + * @param resourceType 审批资源类型 + * @param snapshot 完整冻结快照 + * @return 不包含内部正文与连接配置的响应快照;原快照为空时返回空 Map + */ + public static Map project(String resourceType, Map snapshot) { + if (snapshot == null || snapshot.isEmpty()) { + return Map.of(); + } + if (!STRICT_RESOURCE_TYPES.contains(normalizeResourceType(resourceType))) { + return copyMap(snapshot); + } + Map projected = new LinkedHashMap<>(); + snapshot.forEach((key, value) -> { + if ("resourceSnapshot".equals(key) && value instanceof Map resourceSnapshot) { + projected.put(key, projectMap(resourceSnapshot)); + return; + } + projected.put(key, copyValue(value)); + }); + return projected; + } + + /** + * 递归投影 L21 资源快照。 + * + * @param source 待投影 Map + * @return 保持原有顺序的安全摘要 Map + */ + private static Map projectMap(Map source) { + Map projected = new LinkedHashMap<>(); + source.forEach((rawKey, value) -> { + String key = String.valueOf(rawKey); + String normalizedKey = normalizeKey(key); + if ("resources".equals(normalizedKey)) { + putCollectionCount(projected, "resourceCount", value); + return; + } + if ("mcptoolmanifest".equals(normalizedKey) || "manifest".equals(normalizedKey)) { + putCollectionCount(projected, "manifestToolCount", value); + return; + } + if ("resourcesnapshot".equals(normalizedKey)) { + return; + } + if (isRemovedKey(normalizedKey)) { + return; + } + if (isConfigKey(normalizedKey)) { + return; + } + projected.put(key, projectValue(value)); + }); + return projected; + } + + /** + * 递归投影 Map、集合和数组值。 + * + * @param value 原始值 + * @return 安全副本 + */ + private static Object projectValue(Object value) { + if (value instanceof Map map) { + return projectMap(map); + } + if (value instanceof Collection collection) { + List projected = new ArrayList<>(collection.size()); + collection.forEach(item -> projected.add(projectValue(item))); + return projected; + } + if (value != null && value.getClass().isArray()) { + int length = Array.getLength(value); + List projected = new ArrayList<>(length); + for (int index = 0; index < length; index++) { + projected.add(projectValue(Array.get(value, index))); + } + return projected; + } + return value; + } + + /** + * 判断字段是否属于连接配置或其他配置正文。 + * + * @param normalizedKey 已规范化字段名 + * @return 需要移除时为 true + */ + private static boolean isConfigKey(String normalizedKey) { + return normalizedKey.endsWith("config") || normalizedKey.endsWith("configjson") + || normalizedKey.endsWith("configuration"); + } + + /** + * 判断字段是否需要从严格响应投影中移除。 + * + * @param normalizedKey 已规范化字段名 + * @return 需要移除时为 true + */ + private static boolean isRemovedKey(String normalizedKey) { + if (normalizedKey.endsWith("hash") || normalizedKey.endsWith("count")) { + return false; + } + return REMOVED_KEYS.contains(normalizedKey) + || normalizedKey.endsWith("content") + || normalizedKey.endsWith("contentref") + || normalizedKey.endsWith("storagepath") + || normalizedKey.endsWith("physicalpath") + || normalizedKey.endsWith("source") + || normalizedKey.contains("manifest") + || normalizedKey.contains("credential") + || normalizedKey.endsWith("secret") + || normalizedKey.endsWith("password") + || normalizedKey.endsWith("token") + || normalizedKey.endsWith("apikey") + || normalizedKey.endsWith("privatekey") + || normalizedKey.endsWith("schema") + || normalizedKey.endsWith("headers") + || normalizedKey.endsWith("environment") + || normalizedKey.endsWith("env") + || normalizedKey.endsWith("command") + || normalizedKey.endsWith("args") + || normalizedKey.endsWith("arguments") + || normalizedKey.endsWith("query") + || normalizedKey.endsWith("queryparams") + || normalizedKey.endsWith("queryparameters") + || normalizedKey.endsWith("endpoint") + || normalizedKey.endsWith("url") + || normalizedKey.endsWith("uri"); + } + + /** + * 在源值可计数时写入摘要数量,并避免覆盖已存在的显式数量。 + * + * @param projected 目标 Map + * @param countKey 数量字段名 + * @param value 待计数值 + */ + private static void putCollectionCount(Map projected, String countKey, Object value) { + if (projected.containsKey(countKey)) { + return; + } + if (value instanceof Collection collection) { + projected.put(countKey, collection.size()); + } else if (value != null && value.getClass().isArray()) { + projected.put(countKey, Array.getLength(value)); + } + } + + /** + * 为不采用严格 L21 投影的历史资源复制快照,避免响应方修改持久化 Map。 + * + * @param source 原始 Map + * @return 深复制 Map + */ + private static Map copyMap(Map source) { + Map copy = new LinkedHashMap<>(); + source.forEach((key, value) -> copy.put(String.valueOf(key), copyValue(value))); + return copy; + } + + /** + * 深复制 Map、集合和数组。 + * + * @param value 原始值 + * @return 深复制值 + */ + private static Object copyValue(Object value) { + if (value instanceof Map map) { + return copyMap(map); + } + if (value instanceof Collection collection) { + List copy = new ArrayList<>(collection.size()); + collection.forEach(item -> copy.add(copyValue(item))); + return copy; + } + if (value != null && value.getClass().isArray()) { + int length = Array.getLength(value); + List copy = new ArrayList<>(length); + for (int index = 0; index < length; index++) { + copy.add(copyValue(Array.get(value, index))); + } + return copy; + } + return value; + } + + /** + * 规范化资源类型。 + * + * @param resourceType 原始资源类型 + * @return 大写资源类型 + */ + private static String normalizeResourceType(String resourceType) { + return resourceType == null ? "" : resourceType.trim().toUpperCase(Locale.ROOT); + } + + /** + * 规范化字段名,忽略大小写和分隔符。 + * + * @param key 原始字段名 + * @return 仅含小写字母和数字的字段名 + */ + private static String normalizeKey(String key) { + return key == null ? "" : key.replaceAll("[^A-Za-z0-9]", "").toLowerCase(Locale.ROOT); + } +} diff --git a/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImplAccessTest.java b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImplAccessTest.java index ad79f5f2..b785caf9 100644 --- a/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImplAccessTest.java +++ b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/service/impl/ApprovalQueryServiceImplAccessTest.java @@ -39,7 +39,7 @@ import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -136,7 +136,9 @@ public class ApprovalQueryServiceImplAccessTest { try (MockedStatic saToken = mockStatic(SaTokenUtil.class)) { saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); ApprovalInstanceDetailVo detail = service.detail(INSTANCE_ID); - assertSame(snapshot, detail.getSnapshotJson()); + assertNotSame(snapshot, detail.getSnapshotJson()); + assertEquals(Map.of(), detail.getSnapshotJson().get("resourceSnapshot")); + assertTrue(detail.getSnapshotJson().containsKey("steps")); assertFalse(detail.isCanApprove()); assertFalse(detail.isCanReject()); assertTrue(detail.isCanRevoke()); diff --git a/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/support/ApprovalSnapshotProjectionTest.java b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/support/ApprovalSnapshotProjectionTest.java new file mode 100644 index 00000000..31ce9330 --- /dev/null +++ b/easyflow-modules/easyflow-module-approval/src/test/java/tech/easyflow/approval/support/ApprovalSnapshotProjectionTest.java @@ -0,0 +1,146 @@ +package tech.easyflow.approval.support; + +import org.junit.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertTrue; + +/** + * {@link ApprovalSnapshotProjection} 响应脱敏契约测试。 + */ +public class ApprovalSnapshotProjectionTest { + + /** + * 验证 Skill 审批仅保留标准包和 Tool 绑定摘要,不返回正文、资源正文或 MCP 连接信息。 + */ + @Test + public void shouldProjectSkillSnapshotToSafeSummary() { + Map snapshot = new LinkedHashMap<>(); + snapshot.put("previousStatus", "DRAFT"); + snapshot.put("resourceSnapshot", Map.of( + "schemaVersion", 2, + "name", "contract-review", + "description", "合同审查", + "skillContent", "# private instructions", + "resources", List.of(Map.of( + "path", "references/private.md", + "content", "private reference", + "contentRef", "internal-ref")), + "packageHash", "package-hash", + "contentSnapshotHash", "content-hash", + "platformToolBindings", Map.of( + "bindings", List.of(Map.of( + "toolType", "MCP", + "targetId", 9, + "displayName", "GitHub", + "toolCount", 2, + "mcpToolManifestHash", "manifest-hash", + "resourceSummary", Map.of( + "title", "GitHub", + "baseUrl", "https://internal.example?token=secret", + "requestHeaders", Map.of("Authorization", "secret")), + "mcpToolManifest", List.of(Map.of( + "name", "search", + "inputSchema", Map.of("token", "secret"))), + "resourceSnapshot", Map.of( + "configJson", "{\"env\":{\"TOKEN\":\"secret\"}}", + "headers", Map.of("Authorization", "secret"), + "environment", Map.of("TOKEN", "secret")))), + "snapshotHash", "tools-hash"), + "toolBindingsHash", "tools-hash", + "snapshotHash", "aggregate-hash")); + + Map projected = ApprovalSnapshotProjection.project("SKILL", snapshot); + Map resource = (Map) projected.get("resourceSnapshot"); + Map platformBindings = (Map) resource.get("platformToolBindings"); + Map binding = (Map) ((List) platformBindings.get("bindings")).get(0); + + assertEquals("contract-review", resource.get("name")); + assertEquals("合同审查", resource.get("description")); + assertEquals("aggregate-hash", resource.get("snapshotHash")); + assertEquals(1, resource.get("resourceCount")); + assertEquals("manifest-hash", binding.get("mcpToolManifestHash")); + assertEquals(2, binding.get("toolCount")); + assertEquals(1, binding.get("manifestToolCount")); + assertFalse(resource.containsKey("skillContent")); + assertFalse(resource.containsKey("resources")); + assertFalse(binding.containsKey("mcpToolManifest")); + assertFalse(binding.containsKey("resourceSnapshot")); + Map bindingSummary = (Map) binding.get("resourceSummary"); + assertEquals("GitHub", bindingSummary.get("title")); + assertFalse(bindingSummary.containsKey("baseUrl")); + assertFalse(bindingSummary.containsKey("requestHeaders")); + assertTrue(serialize(projected).indexOf("secret") < 0); + } + + /** + * 验证 Agent 审批保留绑定摘要与 hash,同时递归移除配置正文和嵌套 Skill 运行快照。 + */ + @Test + public void shouldProjectAgentSnapshotToSafeSummary() { + Map resourceSnapshot = new LinkedHashMap<>(); + resourceSnapshot.put("id", 7); + resourceSnapshot.put("name", "审查 Agent"); + resourceSnapshot.put("description", "用于审查合同"); + resourceSnapshot.put("promptConfigJson", Map.of("systemPrompt", "private prompt")); + resourceSnapshot.put("modelConfigJson", Map.of("apiKey", "secret")); + resourceSnapshot.put("basicSummary", Map.of("name", "审查 Agent", "status", 1)); + resourceSnapshot.put("skillBindings", List.of(Map.of( + "skillId", 21, + "sortNo", 0, + "resourceSummary", Map.of( + "displayName", "合同审查", + "snapshotHash", "skill-hash", + "textResourceCount", 3), + "resourceSnapshot", Map.of( + "skillContent", "private skill", + "resources", List.of(Map.of("content", "private reference")), + "source", "easyflow://internal")))); + + Map projected = ApprovalSnapshotProjection.project( + "agent", Map.of("resourceSnapshot", resourceSnapshot)); + Map resource = (Map) projected.get("resourceSnapshot"); + Map binding = (Map) ((List) resource.get("skillBindings")).get(0); + Map summary = (Map) binding.get("resourceSummary"); + + assertEquals("审查 Agent", resource.get("name")); + assertEquals("合同审查", summary.get("displayName")); + assertEquals("skill-hash", summary.get("snapshotHash")); + assertEquals(3, summary.get("textResourceCount")); + assertFalse(resource.containsKey("promptConfigJson")); + assertFalse(resource.containsKey("modelConfigJson")); + assertFalse(binding.containsKey("resourceSnapshot")); + assertTrue(serialize(projected).indexOf("private skill") < 0); + assertTrue(serialize(projected).indexOf("easyflow://internal") < 0); + } + + /** + * 验证历史审批资源保持兼容,但详情响应使用独立深复制 Map。 + */ + @Test + public void shouldKeepLegacyResourceSnapshotsCompatible() { + Map snapshot = Map.of( + "resourceSnapshot", Map.of("title", "流程", "content", "workflow source")); + + Map projected = ApprovalSnapshotProjection.project("WORKFLOW", snapshot); + + assertNotSame(snapshot, projected); + assertEquals(snapshot, projected); + } + + /** + * 将测试对象转成稳定字符串,便于断言敏感字面值未出现在任意嵌套层级。 + * + * @param value 待检查对象 + * @return 对象字符串 + */ + private String serialize(Object value) { + return String.valueOf(value); + } +} diff --git a/easyflow-modules/easyflow-module-chatlog/pom.xml b/easyflow-modules/easyflow-module-chatlog/pom.xml index 070d1afb..5583ea05 100644 --- a/easyflow-modules/easyflow-module-chatlog/pom.xml +++ b/easyflow-modules/easyflow-module-chatlog/pom.xml @@ -52,5 +52,11 @@ ${junit.version} test + + org.mockito + mockito-core + 5.12.0 + test + diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatPersistDispatcher.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatPersistDispatcher.java index e7900382..44605eb4 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatPersistDispatcher.java +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatPersistDispatcher.java @@ -129,7 +129,7 @@ public class ChatPersistDispatcher { payload.setUserId(userId); payload.setOperatorId(operatorId); payload.setOperateAt(operateAt); - eventProducer.send(buildEvent( + ChatPersistEvent event = buildEvent( UUID.randomUUID().toString(), ChatPersistEventType.SESSION_DELETED, sessionId, @@ -137,7 +137,9 @@ public class ChatPersistDispatcher { BigInteger.ZERO, operateAt, chatJsonSupport.toJson(payload) - )); + ); + persistImmediately(event); + eventProducer.send(event); } private void appendMessage(ChatAppendMessageCommand command, ChatPersistEventType eventType) { diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatRoundOperateService.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatRoundOperateService.java index d311f0c3..6fbb3b1d 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatRoundOperateService.java +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatRoundOperateService.java @@ -29,6 +29,26 @@ public interface ChatRoundOperateService { */ List listVariants(BigInteger sessionId, BigInteger roundId); + /** + * 查询轮次下未执行业务安全投影的答案版本,供完整会话批量投影使用。 + * + * @param sessionId 会话 ID + * @param roundId 轮次 ID + * @return 原始答案版本列表 + */ + default List listVariantsUnprojected(BigInteger sessionId, BigInteger roundId) { + return listVariants(sessionId, roundId); + } + + /** + * 对同一会话的答案版本执行一次批量业务安全投影。 + * + * @param sessionId 会话 ID + * @param records 待投影答案版本 + */ + default void projectVariants(BigInteger sessionId, List records) { + } + /** * 切换指定轮次当前选中的答案版本。 * diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatSessionExtension.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatSessionExtension.java new file mode 100644 index 00000000..d122f443 --- /dev/null +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatSessionExtension.java @@ -0,0 +1,50 @@ +package tech.easyflow.chatlog.service; + +import tech.easyflow.chatlog.domain.dto.ChatMessageRecord; +import tech.easyflow.chatlog.domain.dto.ChatSessionSummary; + +import java.math.BigInteger; +import java.util.List; + +/** + * 聊天会话删除与历史返回的业务扩展点。 + */ +public interface ChatSessionExtension { + + /** + * 判断扩展是否处理指定会话类型。 + * + * @param summary 会话摘要 + * @return 需要处理时为 true + */ + boolean supports(ChatSessionSummary summary); + + /** + * 在会话删除落库前同步处理关联资源。 + * + * @param summary 会话摘要 + * @param userId 会话用户 ID + * @param operatorId 操作人 ID + */ + default void beforeDelete(ChatSessionSummary summary, BigInteger userId, BigInteger operatorId) { + } + + /** + * 在会话删除分发成功后同步处理关联资源。 + * + * @param summary 会话摘要 + * @param userId 会话用户 ID + * @param operatorId 操作人 ID + */ + default void afterDelete(ChatSessionSummary summary, BigInteger userId, BigInteger operatorId) { + } + + /** + * 在历史消息返回前覆盖业务安全投影。 + * + * @param summary 会话摘要 + * @param records 本次返回的消息集合 + */ + default void projectMessages(ChatSessionSummary summary, List records) { + } +} diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatSessionExtensionDispatcher.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatSessionExtensionDispatcher.java new file mode 100644 index 00000000..58dfa1d9 --- /dev/null +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatSessionExtensionDispatcher.java @@ -0,0 +1,70 @@ +package tech.easyflow.chatlog.service; + +import org.springframework.stereotype.Component; +import tech.easyflow.chatlog.domain.dto.ChatMessageRecord; +import tech.easyflow.chatlog.domain.dto.ChatSessionSummary; + +import java.math.BigInteger; +import java.util.List; + +/** + * 按会话类型同步分发会话生命周期与历史投影扩展。 + */ +@Component +public class ChatSessionExtensionDispatcher { + + private final List extensions; + + /** + * 创建扩展分发器。 + * + * @param extensions 当前应用注册的会话扩展 + */ + public ChatSessionExtensionDispatcher(List extensions) { + this.extensions = extensions == null ? List.of() : List.copyOf(extensions); + } + + /** + * 在会话删除前同步执行匹配扩展。 + * + * @param summary 会话摘要 + * @param userId 会话用户 ID + * @param operatorId 操作人 ID + */ + public void beforeDelete(ChatSessionSummary summary, BigInteger userId, BigInteger operatorId) { + for (ChatSessionExtension extension : extensions) { + if (extension.supports(summary)) { + extension.beforeDelete(summary, userId, operatorId); + } + } + } + + /** + * 在会话删除分发成功后同步执行匹配扩展。 + * + * @param summary 会话摘要 + * @param userId 会话用户 ID + * @param operatorId 操作人 ID + */ + public void afterDelete(ChatSessionSummary summary, BigInteger userId, BigInteger operatorId) { + for (ChatSessionExtension extension : extensions) { + if (extension.supports(summary)) { + extension.afterDelete(summary, userId, operatorId); + } + } + } + + /** + * 在消息返回前同步执行匹配扩展。 + * + * @param summary 会话摘要 + * @param records 本次返回消息 + */ + public void projectMessages(ChatSessionSummary summary, List records) { + for (ChatSessionExtension extension : extensions) { + if (extension.supports(summary)) { + extension.projectMessages(summary, records); + } + } + } +} diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatHistoryQueryServiceImpl.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatHistoryQueryServiceImpl.java index 2a5af5f7..44ee5554 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatHistoryQueryServiceImpl.java +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatHistoryQueryServiceImpl.java @@ -5,6 +5,8 @@ import tech.easyflow.chatlog.domain.dto.ChatHistoryPage; import tech.easyflow.chatlog.domain.query.ChatPageQuery; import tech.easyflow.chatlog.repository.analyticaldb.ChatAnalyticalDBRepository; import tech.easyflow.chatlog.service.ChatHistoryQueryService; +import tech.easyflow.chatlog.service.ChatSessionExtensionDispatcher; +import tech.easyflow.chatlog.service.ChatSessionQueryService; import java.math.BigInteger; @@ -12,13 +14,29 @@ import java.math.BigInteger; public class ChatHistoryQueryServiceImpl implements ChatHistoryQueryService { private final ChatAnalyticalDBRepository chatAnalyticalDBRepository; + private final ChatSessionQueryService chatSessionQueryService; + private final ChatSessionExtensionDispatcher extensionDispatcher; - public ChatHistoryQueryServiceImpl(ChatAnalyticalDBRepository chatAnalyticalDBRepository) { + /** + * 创建归档历史查询服务。 + * + * @param chatAnalyticalDBRepository 分析库仓储 + * @param chatSessionQueryService 会话摘要查询服务 + * @param extensionDispatcher 会话业务扩展分发器 + */ + public ChatHistoryQueryServiceImpl(ChatAnalyticalDBRepository chatAnalyticalDBRepository, + ChatSessionQueryService chatSessionQueryService, + ChatSessionExtensionDispatcher extensionDispatcher) { this.chatAnalyticalDBRepository = chatAnalyticalDBRepository; + this.chatSessionQueryService = chatSessionQueryService; + this.extensionDispatcher = extensionDispatcher; } @Override public ChatHistoryPage queryHistoryMessages(BigInteger sessionId, ChatPageQuery query) { - return chatAnalyticalDBRepository.queryHistory(sessionId, query); + ChatHistoryPage page = chatAnalyticalDBRepository.queryHistory(sessionId, query); + extensionDispatcher.projectMessages( + chatSessionQueryService.getSessionSummary(sessionId), page.getRecords()); + return page; } } diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatRoundOperateServiceImpl.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatRoundOperateServiceImpl.java index 3fbc598a..de4e7ae6 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatRoundOperateServiceImpl.java +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatRoundOperateServiceImpl.java @@ -1,12 +1,16 @@ package tech.easyflow.chatlog.service.impl; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; import tech.easyflow.chatlog.domain.command.ChatRoundSelectCommand; import tech.easyflow.chatlog.domain.dto.ChatMessageRecord; import tech.easyflow.chatlog.domain.dto.ChatRoundRecord; import tech.easyflow.chatlog.service.ChatRoundCommandService; import tech.easyflow.chatlog.service.ChatRoundOperateService; import tech.easyflow.chatlog.service.ChatRoundQueryService; +import tech.easyflow.chatlog.service.ChatSessionExtensionDispatcher; +import tech.easyflow.chatlog.service.ChatSessionQueryService; import tech.easyflow.chatlog.support.ChatConstants; import tech.easyflow.common.web.exceptions.BusinessException; @@ -23,6 +27,8 @@ public class ChatRoundOperateServiceImpl implements ChatRoundOperateService { private final ChatRoundQueryService chatRoundQueryService; private final ChatRoundCommandService chatRoundCommandService; + private ChatSessionQueryService chatSessionQueryService; + private ChatSessionExtensionDispatcher extensionDispatcher; public ChatRoundOperateServiceImpl(ChatRoundQueryService chatRoundQueryService, ChatRoundCommandService chatRoundCommandService) { @@ -30,6 +36,20 @@ public class ChatRoundOperateServiceImpl implements ChatRoundOperateService { this.chatRoundCommandService = chatRoundCommandService; } + /** + * 延迟注入会话投影依赖,避免会话查询服务与轮次服务形成初始化环。 + * + * @param chatSessionQueryService 会话查询服务 + * @param extensionDispatcher 会话扩展分发器 + */ + @Autowired + @Lazy + public void setProjectionDependencies(ChatSessionQueryService chatSessionQueryService, + ChatSessionExtensionDispatcher extensionDispatcher) { + this.chatSessionQueryService = chatSessionQueryService; + this.extensionDispatcher = extensionDispatcher; + } + @Override public ChatRoundRecord requireRegeneratableRound(BigInteger sessionId, BigInteger roundId) { ChatRoundRecord round = requireLatestRound(sessionId, roundId); @@ -42,6 +62,13 @@ public class ChatRoundOperateServiceImpl implements ChatRoundOperateService { @Override public List listVariants(BigInteger sessionId, BigInteger roundId) { + List variants = listVariantsUnprojected(sessionId, roundId); + projectVariants(sessionId, variants); + return variants; + } + + @Override + public List listVariantsUnprojected(BigInteger sessionId, BigInteger roundId) { ChatRoundRecord round = chatRoundQueryService.getRound(sessionId, roundId); if (round == null) { throw new BusinessException("轮次不存在"); @@ -59,6 +86,17 @@ public class ChatRoundOperateServiceImpl implements ChatRoundOperateService { return variants; } + @Override + public void projectVariants(BigInteger sessionId, List records) { + if (records == null || records.isEmpty()) { + return; + } + if (chatSessionQueryService == null || extensionDispatcher == null) { + throw new IllegalStateException("聊天答案版本安全投影服务未初始化"); + } + extensionDispatcher.projectMessages(chatSessionQueryService.getSessionSummary(sessionId), records); + } + @Override public ChatMessageRecord selectVariant(BigInteger sessionId, BigInteger roundId, Integer variantIndex, BigInteger operatorId) { ChatRoundRecord round = requireLatestRound(sessionId, roundId); @@ -81,6 +119,7 @@ public class ChatRoundOperateServiceImpl implements ChatRoundOperateService { selected.setSelectedVariantIndex(variantIndex); selected.setVariantCount(round.getVariantCount()); selected.setSwitchable(true); + projectVariants(sessionId, List.of(selected)); return selected; } diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatSessionCommandServiceImpl.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatSessionCommandServiceImpl.java index 9fa604c1..a374cdab 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatSessionCommandServiceImpl.java +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatSessionCommandServiceImpl.java @@ -5,6 +5,8 @@ import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand; import tech.easyflow.chatlog.domain.dto.ChatSessionSummary; import tech.easyflow.chatlog.service.ChatPersistDispatcher; import tech.easyflow.chatlog.service.ChatSessionCommandService; +import tech.easyflow.chatlog.service.ChatSessionExtensionDispatcher; +import tech.easyflow.chatlog.service.ChatSessionQueryService; import java.math.BigInteger; @@ -12,9 +14,22 @@ import java.math.BigInteger; public class ChatSessionCommandServiceImpl implements ChatSessionCommandService { private final ChatPersistDispatcher chatPersistDispatcher; + private final ChatSessionQueryService chatSessionQueryService; + private final ChatSessionExtensionDispatcher extensionDispatcher; - public ChatSessionCommandServiceImpl(ChatPersistDispatcher chatPersistDispatcher) { + /** + * 创建会话命令服务。 + * + * @param chatPersistDispatcher 聊天持久化分发器 + * @param chatSessionQueryService 会话查询服务 + * @param extensionDispatcher 会话业务扩展分发器 + */ + public ChatSessionCommandServiceImpl(ChatPersistDispatcher chatPersistDispatcher, + ChatSessionQueryService chatSessionQueryService, + ChatSessionExtensionDispatcher extensionDispatcher) { this.chatPersistDispatcher = chatPersistDispatcher; + this.chatSessionQueryService = chatSessionQueryService; + this.extensionDispatcher = extensionDispatcher; } @Override @@ -29,6 +44,9 @@ public class ChatSessionCommandServiceImpl implements ChatSessionCommandService @Override public void deleteSession(BigInteger sessionId, BigInteger userId, BigInteger operatorId) { + ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId); + extensionDispatcher.beforeDelete(summary, userId, operatorId); chatPersistDispatcher.deleteSession(sessionId, userId, operatorId); + extensionDispatcher.afterDelete(summary, userId, operatorId); } } diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatSessionQueryServiceImpl.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatSessionQueryServiceImpl.java index 39714890..cea0ebd2 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatSessionQueryServiceImpl.java +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatSessionQueryServiceImpl.java @@ -1,6 +1,7 @@ package tech.easyflow.chatlog.service.impl; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; import tech.easyflow.chatlog.cache.ChatHotStateService; import tech.easyflow.chatlog.domain.dto.ChatHistoryPage; import tech.easyflow.chatlog.domain.dto.ChatMessageRecord; @@ -11,6 +12,7 @@ import tech.easyflow.chatlog.repository.mysql.MySqlChatLogRepository; import tech.easyflow.chatlog.repository.mysql.MySqlChatLogTableManager; import tech.easyflow.chatlog.repository.mysql.MySqlChatSessionRepository; import tech.easyflow.chatlog.service.ChatSessionQueryService; +import tech.easyflow.chatlog.service.ChatSessionExtensionDispatcher; import java.math.BigInteger; import java.util.*; @@ -22,6 +24,8 @@ public class ChatSessionQueryServiceImpl implements ChatSessionQueryService { private final MySqlChatLogRepository logRepository; private final MySqlChatLogTableManager tableManager; private final ChatHotStateService chatHotStateService; + private ChatSessionExtensionDispatcher extensionDispatcher = + new ChatSessionExtensionDispatcher(List.of()); public ChatSessionQueryServiceImpl(MySqlChatSessionRepository sessionRepository, MySqlChatLogRepository logRepository, @@ -33,6 +37,16 @@ public class ChatSessionQueryServiceImpl implements ChatSessionQueryService { this.chatHotStateService = chatHotStateService; } + /** + * 设置会话历史业务扩展分发器。 + * + * @param extensionDispatcher 扩展分发器 + */ + @Autowired + public void setExtensionDispatcher(ChatSessionExtensionDispatcher extensionDispatcher) { + this.extensionDispatcher = extensionDispatcher; + } + @Override public List listSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query) { return listSessions(userId, assistantId, null, query); @@ -97,22 +111,31 @@ public class ChatSessionQueryServiceImpl implements ChatSessionQueryService { ); page.setRecords(records); page.setTotal(Math.max(total, query.getOffset() + records.size())); + extensionDispatcher.projectMessages(summary, records); return page; } @Override public List listMainlineMessages(BigInteger sessionId) { - return logRepository.listMainlineMessages(sessionId, tableManager.listRecentExistingMonths(3)); + ChatSessionSummary summary = getSessionSummary(sessionId); + List records = + logRepository.listMainlineMessages(sessionId, tableManager.listRecentExistingMonths(3)); + extensionDispatcher.projectMessages(summary, records); + return records; } @Override public List getRecentTail(BigInteger sessionId, int limit) { + ChatSessionSummary summary = getSessionSummary(sessionId); List cached = chatHotStateService.getSessionTail(sessionId); if (cached != null && isTailReliable(cached)) { - return cached.subList(0, Math.min(limit, cached.size())); + List records = cached.subList(0, Math.min(limit, cached.size())); + extensionDispatcher.projectMessages(summary, records); + return records; } List records = logRepository.listRecentTail(sessionId, tableManager.listRecentExistingMonths(3), limit); chatHotStateService.setSessionTail(sessionId, records); + extensionDispatcher.projectMessages(summary, records); return records; } diff --git a/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/ChatPersistDispatcherTest.java b/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/ChatPersistDispatcherTest.java new file mode 100644 index 00000000..78702277 --- /dev/null +++ b/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/ChatPersistDispatcherTest.java @@ -0,0 +1,66 @@ +package tech.easyflow.chatlog.service; + +import org.junit.Assert; +import org.junit.Test; +import org.mockito.InOrder; +import org.mockito.Mockito; +import tech.easyflow.chatlog.cache.ChatHotStateService; +import tech.easyflow.chatlog.domain.event.ChatPersistEvent; +import tech.easyflow.chatlog.support.ChatJsonSupport; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.math.BigInteger; +import java.util.List; + +/** + * {@link ChatPersistDispatcher} 会话删除可靠持久化顺序测试。 + */ +public class ChatPersistDispatcherTest { + + /** + * 验证 MySQL 同步删除失败时不发送异步事件。 + */ + @Test + public void deleteShouldStopBeforeProducerWhenMysqlApplyFails() { + Fixture fixture = fixture(); + Mockito.doThrow(new IllegalStateException("mysql unavailable")) + .when(fixture.applyService).apply(Mockito.anyList()); + + Assert.assertThrows(BusinessException.class, () -> fixture.dispatcher.deleteSession( + BigInteger.ONE, BigInteger.TWO, BigInteger.TWO)); + + Mockito.verify(fixture.eventProducer, Mockito.never()).send(Mockito.any()); + } + + /** + * 验证消息发送失败发生在 MySQL 同步删除成功之后并继续向上抛出。 + */ + @Test + public void deleteShouldPersistBeforePropagatingProducerFailure() { + Fixture fixture = fixture(); + Mockito.doThrow(new IllegalStateException("mq unavailable")) + .when(fixture.eventProducer).send(Mockito.any()); + + Assert.assertThrows(IllegalStateException.class, () -> fixture.dispatcher.deleteSession( + BigInteger.ONE, BigInteger.TWO, BigInteger.TWO)); + + InOrder order = Mockito.inOrder(fixture.applyService, fixture.eventProducer); + order.verify(fixture.applyService).apply(Mockito.>any()); + order.verify(fixture.eventProducer).send(Mockito.any()); + } + + private Fixture fixture() { + ChatHotStateService hotStateService = Mockito.mock(ChatHotStateService.class); + ChatPersistEventProducer eventProducer = Mockito.mock(ChatPersistEventProducer.class); + ChatPersistMySqlApplyService applyService = Mockito.mock(ChatPersistMySqlApplyService.class); + ChatJsonSupport jsonSupport = Mockito.mock(ChatJsonSupport.class); + Mockito.when(jsonSupport.toJson(Mockito.any())).thenReturn("{}"); + return new Fixture(eventProducer, applyService, + new ChatPersistDispatcher(hotStateService, eventProducer, applyService, jsonSupport)); + } + + private record Fixture(ChatPersistEventProducer eventProducer, + ChatPersistMySqlApplyService applyService, + ChatPersistDispatcher dispatcher) { + } +} diff --git a/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatHistoryQueryServiceImplTest.java b/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatHistoryQueryServiceImplTest.java new file mode 100644 index 00000000..f3632264 --- /dev/null +++ b/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatHistoryQueryServiceImplTest.java @@ -0,0 +1,42 @@ +package tech.easyflow.chatlog.service.impl; + +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; +import tech.easyflow.chatlog.domain.dto.ChatHistoryPage; +import tech.easyflow.chatlog.domain.dto.ChatMessageRecord; +import tech.easyflow.chatlog.domain.dto.ChatSessionSummary; +import tech.easyflow.chatlog.domain.query.ChatPageQuery; +import tech.easyflow.chatlog.repository.analyticaldb.ChatAnalyticalDBRepository; +import tech.easyflow.chatlog.service.ChatSessionExtensionDispatcher; +import tech.easyflow.chatlog.service.ChatSessionQueryService; + +import java.math.BigInteger; +import java.util.List; + +/** + * {@link ChatHistoryQueryServiceImpl} 归档历史安全投影测试。 + */ +public class ChatHistoryQueryServiceImplTest { + + /** + * 验证 UserCenter/Admin 归档历史返回前统一执行一次会话批量投影。 + */ + @Test + public void shouldProjectAnalyticalHistoryOnce() { + ChatAnalyticalDBRepository repository = Mockito.mock(ChatAnalyticalDBRepository.class); + ChatSessionQueryService sessionQueryService = Mockito.mock(ChatSessionQueryService.class); + ChatSessionExtensionDispatcher dispatcher = Mockito.mock(ChatSessionExtensionDispatcher.class); + ChatHistoryPage page = new ChatHistoryPage(); + page.setRecords(List.of(new ChatMessageRecord())); + ChatSessionSummary summary = new ChatSessionSummary(); + Mockito.when(repository.queryHistory(Mockito.eq(BigInteger.ONE), Mockito.any())).thenReturn(page); + Mockito.when(sessionQueryService.getSessionSummary(BigInteger.ONE)).thenReturn(summary); + ChatHistoryQueryServiceImpl service = + new ChatHistoryQueryServiceImpl(repository, sessionQueryService, dispatcher); + + Assert.assertSame(page, service.queryHistoryMessages(BigInteger.ONE, new ChatPageQuery())); + + Mockito.verify(dispatcher, Mockito.times(1)).projectMessages(summary, page.getRecords()); + } +} diff --git a/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatRoundOperateServiceImplTest.java b/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatRoundOperateServiceImplTest.java index e5077cd3..4ef544b5 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatRoundOperateServiceImplTest.java +++ b/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatRoundOperateServiceImplTest.java @@ -8,6 +8,8 @@ import tech.easyflow.chatlog.domain.dto.ChatMessageRecord; import tech.easyflow.chatlog.domain.dto.ChatRoundRecord; import tech.easyflow.chatlog.service.ChatRoundCommandService; import tech.easyflow.chatlog.service.ChatRoundQueryService; +import tech.easyflow.chatlog.service.ChatSessionExtensionDispatcher; +import tech.easyflow.chatlog.service.ChatSessionQueryService; import tech.easyflow.chatlog.support.ChatConstants; import tech.easyflow.common.web.exceptions.BusinessException; @@ -32,7 +34,8 @@ public class ChatRoundOperateServiceImplTest { queryService.latestRound = queryService.round; queryService.targetVariant = message(BigInteger.valueOf(3002), 2); FakeRoundCommandService commandService = new FakeRoundCommandService(); - ChatRoundOperateServiceImpl service = new ChatRoundOperateServiceImpl(queryService, commandService); + ProjectionFixture projection = withProjection(new ChatRoundOperateServiceImpl(queryService, commandService)); + ChatRoundOperateServiceImpl service = projection.service; ChatMessageRecord selected = service.selectVariant( BigInteger.valueOf(1001), @@ -49,6 +52,8 @@ public class ChatRoundOperateServiceImplTest { Assert.assertEquals(0, queryService.listRoundVariantsCalls); Assert.assertNotNull(commandService.selectedCommand); Assert.assertEquals(BigInteger.valueOf(3002), commandService.selectedCommand.getSelectedAssistantMessageId()); + org.mockito.Mockito.verify(projection.dispatcher).projectMessages( + projection.summary, List.of(selected)); } /** @@ -60,7 +65,9 @@ public class ChatRoundOperateServiceImplTest { queryService.round = round(BigInteger.valueOf(1001), BigInteger.valueOf(2001), 2, ChatConstants.ROUND_STATUS_READY); queryService.latestRound = queryService.round; queryService.variants = List.of(message(BigInteger.valueOf(3001), 1), message(BigInteger.valueOf(3002), 2)); - ChatRoundOperateServiceImpl service = new ChatRoundOperateServiceImpl(queryService, new FakeRoundCommandService()); + ProjectionFixture projection = withProjection( + new ChatRoundOperateServiceImpl(queryService, new FakeRoundCommandService())); + ChatRoundOperateServiceImpl service = projection.service; List variants = service.listVariants(BigInteger.valueOf(1001), BigInteger.valueOf(2001)); @@ -70,6 +77,7 @@ public class ChatRoundOperateServiceImplTest { Assert.assertEquals(Integer.valueOf(2), variant.getSelectedVariantIndex()); Assert.assertEquals(Boolean.TRUE, variant.getSwitchable()); } + org.mockito.Mockito.verify(projection.dispatcher).projectMessages(projection.summary, variants); } /** @@ -134,6 +142,23 @@ public class ChatRoundOperateServiceImplTest { return round; } + private ProjectionFixture withProjection(ChatRoundOperateServiceImpl service) { + ChatSessionQueryService sessionQueryService = org.mockito.Mockito.mock(ChatSessionQueryService.class); + ChatSessionExtensionDispatcher dispatcher = org.mockito.Mockito.mock(ChatSessionExtensionDispatcher.class); + tech.easyflow.chatlog.domain.dto.ChatSessionSummary summary = + new tech.easyflow.chatlog.domain.dto.ChatSessionSummary(); + summary.setId(BigInteger.valueOf(1001)); + org.mockito.Mockito.when(sessionQueryService.getSessionSummary(BigInteger.valueOf(1001))) + .thenReturn(summary); + service.setProjectionDependencies(sessionQueryService, dispatcher); + return new ProjectionFixture(service, dispatcher, summary); + } + + private record ProjectionFixture(ChatRoundOperateServiceImpl service, + ChatSessionExtensionDispatcher dispatcher, + tech.easyflow.chatlog.domain.dto.ChatSessionSummary summary) { + } + private static ChatMessageRecord message(BigInteger id, int variantIndex) { ChatMessageRecord record = new ChatMessageRecord(); record.setId(id); diff --git a/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatSessionCommandServiceImplTest.java b/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatSessionCommandServiceImplTest.java new file mode 100644 index 00000000..ec29089c --- /dev/null +++ b/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatSessionCommandServiceImplTest.java @@ -0,0 +1,66 @@ +package tech.easyflow.chatlog.service.impl; + +import org.junit.Assert; +import org.junit.Test; +import org.mockito.InOrder; +import org.mockito.Mockito; +import tech.easyflow.chatlog.domain.dto.ChatSessionSummary; +import tech.easyflow.chatlog.service.ChatPersistDispatcher; +import tech.easyflow.chatlog.service.ChatSessionExtensionDispatcher; +import tech.easyflow.chatlog.service.ChatSessionQueryService; + +import java.math.BigInteger; + +/** + * {@link ChatSessionCommandServiceImpl} 删除扩展顺序与失败传播测试。 + */ +public class ChatSessionCommandServiceImplTest { + + /** + * 验证所有删除入口共享 before、删除、after 的固定顺序。 + */ + @Test + public void deleteShouldInvokeLifecycleHooksAroundPersistDispatch() { + Fixture fixture = fixture(); + + fixture.service.deleteSession(BigInteger.ONE, BigInteger.TWO, BigInteger.TWO); + + InOrder order = Mockito.inOrder(fixture.extensions, fixture.persistDispatcher); + order.verify(fixture.extensions).beforeDelete(fixture.summary, BigInteger.TWO, BigInteger.TWO); + order.verify(fixture.persistDispatcher).deleteSession(BigInteger.ONE, BigInteger.TWO, BigInteger.TWO); + order.verify(fixture.extensions).afterDelete(fixture.summary, BigInteger.TWO, BigInteger.TWO); + } + + /** + * 验证会话删除失败时不会提前执行 after hook 标记关联资源删除。 + */ + @Test + public void deleteFailureShouldNotInvokeAfterHook() { + Fixture fixture = fixture(); + Mockito.doThrow(new IllegalStateException("mq unavailable")) + .when(fixture.persistDispatcher).deleteSession(Mockito.any(), Mockito.any(), Mockito.any()); + + Assert.assertThrows(IllegalStateException.class, + () -> fixture.service.deleteSession(BigInteger.ONE, BigInteger.TWO, BigInteger.TWO)); + + Mockito.verify(fixture.extensions, Mockito.never()) + .afterDelete(Mockito.any(), Mockito.any(), Mockito.any()); + } + + private Fixture fixture() { + ChatPersistDispatcher persistDispatcher = Mockito.mock(ChatPersistDispatcher.class); + ChatSessionQueryService queryService = Mockito.mock(ChatSessionQueryService.class); + ChatSessionExtensionDispatcher extensions = Mockito.mock(ChatSessionExtensionDispatcher.class); + ChatSessionSummary summary = new ChatSessionSummary(); + summary.setId(BigInteger.ONE); + Mockito.when(queryService.getSessionSummary(BigInteger.ONE)).thenReturn(summary); + return new Fixture(persistDispatcher, extensions, summary, + new ChatSessionCommandServiceImpl(persistDispatcher, queryService, extensions)); + } + + private record Fixture(ChatPersistDispatcher persistDispatcher, + ChatSessionExtensionDispatcher extensions, + ChatSessionSummary summary, + ChatSessionCommandServiceImpl service) { + } +} diff --git a/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatSessionQueryServiceImplTest.java b/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatSessionQueryServiceImplTest.java index 2ce1068c..96b8a607 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatSessionQueryServiceImplTest.java +++ b/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/service/impl/ChatSessionQueryServiceImplTest.java @@ -14,6 +14,7 @@ import tech.easyflow.chatlog.repository.mysql.MySqlChatLogRepository; import tech.easyflow.chatlog.repository.mysql.MySqlChatLogTableManager; import tech.easyflow.chatlog.repository.mysql.MySqlChatSessionRepository; import tech.easyflow.chatlog.support.ChatJsonSupport; +import tech.easyflow.chatlog.service.ChatSessionExtensionDispatcher; import java.math.BigInteger; import java.time.YearMonth; @@ -144,6 +145,28 @@ public class ChatSessionQueryServiceImplTest { Assert.assertEquals(4, page.getTotal()); } + /** + * 验证消息分页返回前仅执行一次会话级批量安全投影。 + */ + @Test + public void pageMainlineMessagesShouldProjectRecordsOnce() { + FakeSessionRepository sessionRepository = new FakeSessionRepository(); + sessionRepository.summary = session(BigInteger.valueOf(2003), 1); + FakeLogRepository logRepository = new FakeLogRepository(); + logRepository.records = List.of(message(5001)); + ChatSessionQueryServiceImpl service = new ChatSessionQueryServiceImpl( + sessionRepository, logRepository, + new FakeTableManager(List.of(YearMonth.of(2026, 5))), new FakeHotStateService()); + ChatSessionExtensionDispatcher dispatcher = + org.mockito.Mockito.mock(ChatSessionExtensionDispatcher.class); + service.setExtensionDispatcher(dispatcher); + + ChatHistoryPage page = service.pageMainlineMessages(BigInteger.valueOf(2003), new ChatPageQuery()); + + org.mockito.Mockito.verify(dispatcher, org.mockito.Mockito.times(1)) + .projectMessages(sessionRepository.summary, page.getRecords()); + } + private static ChatSessionSummary session(BigInteger id, int messageCount) { ChatSessionSummary summary = new ChatSessionSummary(); summary.setId(id); diff --git a/easyflow-modules/easyflow-module-log/src/main/java/tech/easyflow/log/reporter/ActionLogReporterProperties.java b/easyflow-modules/easyflow-module-log/src/main/java/tech/easyflow/log/reporter/ActionLogReporterProperties.java index f7992d08..ec58b420 100644 --- a/easyflow-modules/easyflow-module-log/src/main/java/tech/easyflow/log/reporter/ActionLogReporterProperties.java +++ b/easyflow-modules/easyflow-module-log/src/main/java/tech/easyflow/log/reporter/ActionLogReporterProperties.java @@ -39,6 +39,7 @@ public class ActionLogReporterProperties { "/images/**", "/favicon.ico", "/api/v1/agent/media/**", + "/api/v1/agent/artifacts/*/content", "/actuator/**", "*.js", "*.css", diff --git a/easyflow-modules/easyflow-module-log/src/main/java/tech/easyflow/log/reporter/ResponseCachingFilter.java b/easyflow-modules/easyflow-module-log/src/main/java/tech/easyflow/log/reporter/ResponseCachingFilter.java index cef04de0..62a357b0 100644 --- a/easyflow-modules/easyflow-module-log/src/main/java/tech/easyflow/log/reporter/ResponseCachingFilter.java +++ b/easyflow-modules/easyflow-module-log/src/main/java/tech/easyflow/log/reporter/ResponseCachingFilter.java @@ -6,6 +6,7 @@ import jakarta.servlet.http.HttpServletResponse; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; import org.springframework.web.util.ContentCachingRequestWrapper; import org.springframework.web.util.ContentCachingResponseWrapper; @@ -26,6 +27,8 @@ import static org.springframework.core.Ordered.HIGHEST_PRECEDENCE; ) public class ResponseCachingFilter implements Filter { + private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher(); + private final ActionLogReporterProperties logProperties; /** @@ -121,18 +124,10 @@ public class ResponseCachingFilter implements Filter { * @return 是否匹配 */ private boolean match(String path, String pattern) { - if (pattern.equals("/**")) { - return true; + if (pattern.startsWith("/")) { + return PATH_MATCHER.match(pattern, path); } - if (pattern.endsWith("/**")) { - String prefix = pattern.substring(0, pattern.length() - 3); - return path.startsWith(prefix); - } - if (pattern.endsWith("*")) { - String prefix = pattern.substring(0, pattern.length() - 1); - return path.startsWith(prefix); - } - if (pattern.contains("*") && !pattern.contains("/**")) { + if (pattern.contains("*")) { // 支持 *.js, *.css String p = pattern.replace("*", "").replace(".", "\\."); return path.matches(".*" + p + ".*"); diff --git a/easyflow-modules/easyflow-module-log/src/test/java/tech/easyflow/log/reporter/ResponseCachingFilterTest.java b/easyflow-modules/easyflow-module-log/src/test/java/tech/easyflow/log/reporter/ResponseCachingFilterTest.java index 36546454..228f1e63 100644 --- a/easyflow-modules/easyflow-module-log/src/test/java/tech/easyflow/log/reporter/ResponseCachingFilterTest.java +++ b/easyflow-modules/easyflow-module-log/src/test/java/tech/easyflow/log/reporter/ResponseCachingFilterTest.java @@ -4,7 +4,10 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.junit.Test; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; +import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -32,4 +35,63 @@ public class ResponseCachingFilterTest { verify(chain).doFilter(request, response); } + + /** + * 验证 Artifact 正文下载保持原始响应,避免异步正文被空缓存提前完成。 + * + * @throws Exception Filter 执行失败 + */ + @Test + public void agentArtifactContentShouldBypassResponseCaching() throws Exception { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + when(request.getMethod()).thenReturn("GET"); + when(request.getRequestURI()).thenReturn("/api/v1/agent/artifacts/artifact-1/content"); + + ResponseCachingFilter filter = new ResponseCachingFilter(new ActionLogReporterProperties()); + filter.doFilter(request, response, chain); + + verify(chain).doFilter(request, response); + } + + /** + * 验证 Artifact 元数据接口仍经过日志正文包装。 + * + * @throws Exception Filter 执行失败 + */ + @Test + public void agentArtifactMetadataShouldRemainCacheable() throws Exception { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + when(request.getMethod()).thenReturn("GET"); + when(request.getRequestURI()).thenReturn("/api/v1/agent/artifacts/artifact-1"); + + ResponseCachingFilter filter = new ResponseCachingFilter(new ActionLogReporterProperties()); + filter.doFilter(request, response, chain); + + verify(chain).doFilter( + isA(ContentCachingRequestWrapper.class), isA(ContentCachingResponseWrapper.class)); + } + + /** + * 验证近似路径不会误命中 Artifact 正文下载排除规则。 + * + * @throws Exception Filter 执行失败 + */ + @Test + public void artifactContentChildPathShouldRemainCacheable() throws Exception { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + when(request.getMethod()).thenReturn("GET"); + when(request.getRequestURI()).thenReturn("/api/v1/agent/artifacts/artifact-1/content/preview"); + + ResponseCachingFilter filter = new ResponseCachingFilter(new ActionLogReporterProperties()); + filter.doFilter(request, response, chain); + + verify(chain).doFilter( + isA(ContentCachingRequestWrapper.class), isA(ContentCachingResponseWrapper.class)); + } } diff --git a/easyflow-modules/easyflow-module-skill/pom.xml b/easyflow-modules/easyflow-module-skill/pom.xml index f75aa3ac..1407c2eb 100644 --- a/easyflow-modules/easyflow-module-skill/pom.xml +++ b/easyflow-modules/easyflow-module-skill/pom.xml @@ -49,6 +49,10 @@ com.easyagents easy-agents-skill + + com.easyagents + easy-agents-agent-runtime + org.springframework.boot spring-boot-starter-web diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/Skill.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/Skill.java index bc2dcdf5..795b7052 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/Skill.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/Skill.java @@ -40,6 +40,8 @@ public class Skill extends DateEntity implements VisibilityResource, Serializabl private BigInteger currentApprovalInstanceId; @Column(typeHandler = FastjsonTypeHandler.class) private Map publishedSnapshotJson = new LinkedHashMap<>(); + @Column(typeHandler = FastjsonTypeHandler.class) + private Map publishedToolBindingsJson = new LinkedHashMap<>(); private Date publishedAt; private BigInteger publishedBy; private Date created; @@ -57,6 +59,8 @@ public class Skill extends DateEntity implements VisibilityResource, Serializabl private String createdByName; @Column(ignore = true) private List resources; + @Column(ignore = true) + private List toolBindings; public BigInteger getId() { return id; } public void setId(BigInteger id) { this.id = id; } @@ -86,6 +90,10 @@ public class Skill extends DateEntity implements VisibilityResource, Serializabl public void setCurrentApprovalInstanceId(BigInteger currentApprovalInstanceId) { this.currentApprovalInstanceId = currentApprovalInstanceId; } public Map getPublishedSnapshotJson() { return publishedSnapshotJson; } public void setPublishedSnapshotJson(Map publishedSnapshotJson) { this.publishedSnapshotJson = publishedSnapshotJson == null ? new LinkedHashMap<>() : publishedSnapshotJson; } + /** @return 平台 Tool 发布快照 */ + public Map getPublishedToolBindingsJson() { return publishedToolBindingsJson; } + /** @param publishedToolBindingsJson 平台 Tool 发布快照 */ + public void setPublishedToolBindingsJson(Map publishedToolBindingsJson) { this.publishedToolBindingsJson = publishedToolBindingsJson == null ? new LinkedHashMap<>() : publishedToolBindingsJson; } public Date getPublishedAt() { return publishedAt; } public void setPublishedAt(Date publishedAt) { this.publishedAt = publishedAt; } public BigInteger getPublishedBy() { return publishedBy; } @@ -108,4 +116,8 @@ public class Skill extends DateEntity implements VisibilityResource, Serializabl public void setCreatedByName(String createdByName) { this.createdByName = createdByName; } public List getResources() { return resources; } public void setResources(List resources) { this.resources = resources; } + /** @return 脱敏 Tool 草稿绑定摘要 */ + public List getToolBindings() { return toolBindings; } + /** @param toolBindings 脱敏 Tool 草稿绑定摘要 */ + public void setToolBindings(List toolBindings) { this.toolBindings = toolBindings; } } diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillToolBinding.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillToolBinding.java new file mode 100644 index 00000000..04a0cceb --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/entity/SkillToolBinding.java @@ -0,0 +1,102 @@ +package tech.easyflow.skill.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import tech.easyflow.common.entity.DateEntity; + +import java.io.Serializable; +import java.math.BigInteger; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Skill 平台 Tool 草稿绑定。 + * + *

该表只保存资源引用、调用前确认和 MCP 有界摘要;运行名、资源快照与 MCP Tool + * 明细仅在发布时生成。

+ */ +@Table("tb_skill_tool_binding") +public class SkillToolBinding extends DateEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id(keyType = KeyType.Generator, value = "snowFlakeId") + private BigInteger id; + @Column(tenantId = true) + private BigInteger tenantId; + private BigInteger skillId; + private String toolType; + private BigInteger targetId; + private Boolean hitlEnabled; + private Integer mcpToolCount; + private String mcpToolManifestHash; + private Integer sortNo; + private Date created; + private BigInteger createdBy; + private Date modified; + private BigInteger modifiedBy; + @Column(ignore = true) + private Map resourceSummary = new LinkedHashMap<>(); + + /** @return 绑定 ID */ + public BigInteger getId() { return id; } + /** @param id 绑定 ID */ + public void setId(BigInteger id) { this.id = id; } + /** @return 租户 ID */ + public BigInteger getTenantId() { return tenantId; } + /** @param tenantId 租户 ID */ + public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; } + /** @return Skill ID */ + public BigInteger getSkillId() { return skillId; } + /** @param skillId Skill ID */ + public void setSkillId(BigInteger skillId) { this.skillId = skillId; } + /** @return Tool 类型 */ + public String getToolType() { return toolType; } + /** @param toolType Tool 类型 */ + public void setToolType(String toolType) { this.toolType = toolType; } + /** @return 目标资源 ID */ + public BigInteger getTargetId() { return targetId; } + /** @param targetId 目标资源 ID */ + public void setTargetId(BigInteger targetId) { this.targetId = targetId; } + /** @return 是否调用前确认 */ + public Boolean getHitlEnabled() { return hitlEnabled; } + /** @param hitlEnabled 是否调用前确认 */ + public void setHitlEnabled(Boolean hitlEnabled) { this.hitlEnabled = hitlEnabled; } + /** @return MCP Tool 数量 */ + public Integer getMcpToolCount() { return mcpToolCount; } + /** @param mcpToolCount MCP Tool 数量 */ + public void setMcpToolCount(Integer mcpToolCount) { this.mcpToolCount = mcpToolCount; } + /** @return MCP Tool manifest hash */ + public String getMcpToolManifestHash() { return mcpToolManifestHash; } + /** @param mcpToolManifestHash MCP Tool manifest hash */ + public void setMcpToolManifestHash(String mcpToolManifestHash) { this.mcpToolManifestHash = mcpToolManifestHash; } + /** @return 排序号 */ + public Integer getSortNo() { return sortNo; } + /** @param sortNo 排序号 */ + public void setSortNo(Integer sortNo) { this.sortNo = sortNo; } + /** @return 创建时间 */ + @Override public Date getCreated() { return created; } + /** @param created 创建时间 */ + @Override public void setCreated(Date created) { this.created = created; } + /** @return 创建人 */ + public BigInteger getCreatedBy() { return createdBy; } + /** @param createdBy 创建人 */ + public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; } + /** @return 修改时间 */ + @Override public Date getModified() { return modified; } + /** @param modified 修改时间 */ + @Override public void setModified(Date modified) { this.modified = modified; } + /** @return 修改人 */ + public BigInteger getModifiedBy() { return modifiedBy; } + /** @param modifiedBy 修改人 */ + public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; } + /** @return 脱敏资源摘要 */ + public Map getResourceSummary() { return resourceSummary; } + /** @param resourceSummary 脱敏资源摘要 */ + public void setResourceSummary(Map resourceSummary) { + this.resourceSummary = resourceSummary == null ? new LinkedHashMap<>() : resourceSummary; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillToolType.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillToolType.java new file mode 100644 index 00000000..58d3a9b9 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/enums/SkillToolType.java @@ -0,0 +1,34 @@ +package tech.easyflow.skill.enums; + +import tech.easyflow.common.web.exceptions.BusinessException; + +/** + * Skill 可绑定的平台 Tool 类型。 + */ +public enum SkillToolType { + + /** 已发布工作流。 */ + WORKFLOW, + /** 已启用插件工具。 */ + PLUGIN, + /** 整组 MCP 服务。 */ + MCP; + + /** + * 解析 Tool 类型。 + * + * @param value Tool 类型编码 + * @return Tool 类型 + * @throws BusinessException 类型为空或不受支持时抛出 + */ + public static SkillToolType from(String value) { + if (value == null || value.isBlank()) { + throw new BusinessException("Skill 工具类型不能为空"); + } + try { + return valueOf(value.trim().toUpperCase()); + } catch (IllegalArgumentException exception) { + throw new BusinessException("不支持的 Skill 工具类型:" + value); + } + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillMapper.java index 5d870d21..eafa2982 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillMapper.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillMapper.java @@ -44,12 +44,14 @@ public interface SkillMapper extends BaseMapper { */ @Update("UPDATE tb_skill SET publish_status='PUBLISHED', " + "published_snapshot_json=#{snapshot,typeHandler=com.mybatisflex.core.handler.FastjsonTypeHandler}, " + + "published_tool_bindings_json=#{toolSnapshot,typeHandler=com.mybatisflex.core.handler.FastjsonTypeHandler}, " + "published_at=#{publishedAt}, published_by=#{publishedBy}, " + "snapshot_hash=#{snapshotHash}, current_approval_instance_id=NULL " + "WHERE id=#{id} AND tenant_id=#{tenantId}") int publish(@Param("id") BigInteger id, @Param("tenantId") BigInteger tenantId, @Param("snapshot") Map snapshot, + @Param("toolSnapshot") Map toolSnapshot, @Param("publishedAt") Date publishedAt, @Param("publishedBy") BigInteger publishedBy, @Param("snapshotHash") String snapshotHash); @@ -68,6 +70,7 @@ public interface SkillMapper extends BaseMapper { */ @Update("UPDATE tb_skill SET publish_status='PUBLISHED', " + "published_snapshot_json=#{snapshot,typeHandler=com.mybatisflex.core.handler.FastjsonTypeHandler}, " + + "published_tool_bindings_json=#{toolSnapshot,typeHandler=com.mybatisflex.core.handler.FastjsonTypeHandler}, " + "published_at=#{publishedAt}, published_by=#{publishedBy}, snapshot_hash=#{snapshotHash}, " + "current_approval_instance_id=#{approvalInstanceId} WHERE id=#{id} AND tenant_id=#{tenantId} " + "AND current_approval_instance_id=#{approvalInstanceId}") @@ -75,6 +78,7 @@ public interface SkillMapper extends BaseMapper { @Param("tenantId") BigInteger tenantId, @Param("approvalInstanceId") BigInteger approvalInstanceId, @Param("snapshot") Map snapshot, + @Param("toolSnapshot") Map toolSnapshot, @Param("publishedAt") Date publishedAt, @Param("publishedBy") BigInteger publishedBy, @Param("snapshotHash") String snapshotHash); diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillToolBindingMapper.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillToolBindingMapper.java new file mode 100644 index 00000000..c38d6d16 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/mapper/SkillToolBindingMapper.java @@ -0,0 +1,10 @@ +package tech.easyflow.skill.mapper; + +import com.mybatisflex.core.BaseMapper; +import tech.easyflow.skill.entity.SkillToolBinding; + +/** + * Skill Tool 绑定 Mapper。 + */ +public interface SkillToolBindingMapper extends BaseMapper { +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandler.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandler.java index f3459c0f..524808f0 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandler.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandler.java @@ -126,12 +126,26 @@ public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHand @Override protected Map getPublishedSnapshot(Skill resource) { - return resource.getPublishedSnapshotJson(); + Map content = resource.getPublishedSnapshotJson(); + if (content == null || content.isEmpty()) { + return content; + } + Map toolSnapshot = resource.getPublishedToolBindingsJson(); + if (toolSnapshot == null || toolSnapshot.isEmpty()) { + return content; + } + Map combined = new java.util.LinkedHashMap<>(content); + Object contentHash = combined.remove("snapshotHash"); + combined.put("contentSnapshotHash", contentHash); + combined.put("platformToolBindings", toolSnapshot); + combined.put("toolBindingsHash", toolSnapshot.get("snapshotHash")); + combined.put("snapshotHash", resource.getSnapshotHash()); + return combined; } @Override protected Map buildResourceSnapshot(Skill resource) { - return skillService.buildPublishSnapshot(resource); + return skillService.buildApprovalSnapshot(resource); } /** @@ -174,7 +188,10 @@ public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHand @Override protected void publishResource(BigInteger resourceId, Map resourceSnapshot, BigInteger operatorId) { Skill existing = requireResource(resourceId); - if (skillMapper.publish(resourceId, existing.getTenantId(), resourceSnapshot, new Date(), operatorId, + Map contentSnapshot = skillService.extractContentSnapshot(resourceSnapshot); + Map toolSnapshot = skillService.extractToolBindingsSnapshot(resourceSnapshot); + if (skillMapper.publish(resourceId, existing.getTenantId(), contentSnapshot, toolSnapshot, + new Date(), operatorId, stringValue(resourceSnapshot.get("snapshotHash"))) != 1) { throw new BusinessException(500, 500, "发布 Skill 失败,请稍后重试"); } @@ -217,8 +234,10 @@ public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHand } if (action == ApprovalActionType.PUBLISH) { skillService.assertSnapshotHash(resourceSnapshot); + Map contentSnapshot = skillService.extractContentSnapshot(resourceSnapshot); + Map toolSnapshot = skillService.extractToolBindingsSnapshot(resourceSnapshot); if (skillMapper.publishApproved(resourceId, existing.getTenantId(), approvalInstanceId, - resourceSnapshot, new Date(), operatorId, + contentSnapshot, toolSnapshot, new Date(), operatorId, stringValue(resourceSnapshot.get("snapshotHash"))) != 1) { throw new BusinessException(409, 4092, "Skill 发布状态已变化,请刷新后重试"); } @@ -226,6 +245,7 @@ public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHand return; } if (action == ApprovalActionType.OFFLINE) { + skillService.assertNoActiveReferences(resourceId); if (skillMapper.markOfflineApproved(resourceId, existing.getTenantId(), approvalInstanceId) != 1) { throw new BusinessException(409, 4092, "Skill 下线状态已变化,请刷新后重试"); } @@ -236,6 +256,7 @@ public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHand @Override protected void markResourceOffline(BigInteger resourceId) { + skillService.assertNoActiveReferences(resourceId); Skill existing = requireResource(resourceId); if (skillMapper.markOffline(resourceId, existing.getTenantId()) != 1) { throw new BusinessException(500, 500, "下线 Skill 失败,请稍后重试"); diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillReferenceProvider.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillReferenceProvider.java new file mode 100644 index 00000000..3501b081 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillReferenceProvider.java @@ -0,0 +1,18 @@ +package tech.easyflow.skill.service; + +import java.math.BigInteger; +import java.util.List; + +/** + * Skill 被上层资源引用的无反向依赖查询扩展点。 + */ +public interface SkillReferenceProvider { + + /** + * 查询草稿或有效发布快照中引用指定 Skill 的资源摘要。 + * + * @param skillId Skill ID + * @return 用户可识别的引用摘要 + */ + List listReferences(BigInteger skillId); +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillService.java index ccb66ab3..90955268 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillService.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillService.java @@ -104,6 +104,30 @@ public interface SkillService extends IService { */ Map buildPublishSnapshot(Skill skill); + /** + * 构建审批冻结用的内容与平台 Tool 组合快照。 + * + * @param skill Skill 草稿 + * @return 组合发布候选快照 + */ + Map buildApprovalSnapshot(Skill skill); + + /** + * 从组合发布候选中提取保持标准包语义的内容快照。 + * + * @param approvalSnapshot 组合发布候选 + * @return 标准 Skill 内容快照 + */ + Map extractContentSnapshot(Map approvalSnapshot); + + /** + * 从组合发布候选中提取平台 Tool 快照。 + * + * @param approvalSnapshot 组合发布候选 + * @return 平台 Tool 快照 + */ + Map extractToolBindingsSnapshot(Map approvalSnapshot); + /** * 校验发布快照中的哈希与实际内容一致。 * @@ -111,6 +135,20 @@ public interface SkillService extends IService { */ void assertSnapshotHash(Map snapshot); + /** + * 校验已发布 Skill 的内容、平台 Tool 与组合 hash。 + * + * @param skill 已发布 Skill + */ + void assertPublishedAggregateHash(Skill skill); + + /** + * 校验 Skill 没有被 Agent 草稿或有效发布快照引用。 + * + * @param skillId Skill ID + */ + void assertNoActiveReferences(BigInteger skillId); + /** * 构建删除审批使用的最小治理快照。 * diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillToolBindingService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillToolBindingService.java new file mode 100644 index 00000000..34360bd2 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillToolBindingService.java @@ -0,0 +1,69 @@ +package tech.easyflow.skill.service; + +import com.mybatisflex.core.service.IService; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillToolBinding; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; + +/** + * Skill 平台 Tool 绑定服务。 + */ +public interface SkillToolBindingService extends IService { + + /** + * 原子替换 Skill 的全部 Tool 草稿绑定。 + * + * @param skillId Skill ID + * @param bindings 客户端绑定引用 + * @return 规范化后的脱敏绑定摘要 + */ + List replaceBindings(BigInteger skillId, List bindings); + + /** + * 查询 Skill 的草稿绑定并补齐脱敏摘要。 + * + * @param skillId Skill ID + * @return 稳定排序的绑定摘要 + */ + List listSummaries(BigInteger skillId); + + /** + * 查询 Skill 的草稿绑定。 + * + * @param skillId Skill ID + * @return 稳定排序的草稿绑定 + */ + List listBindings(BigInteger skillId); + + /** + * 构建并完整复核 Skill 的平台 Tool 发布快照。 + * + * @param skill 已锁定的 Skill + * @return Tool 发布快照 + */ + Map buildPublishSnapshot(Skill skill); + + /** + * 复核已发布 Tool 快照中的目标资源、权限和 MCP manifest。 + * + * @param skill 已发布 Skill + */ + void assertPublishedSnapshotUsable(Skill skill); + + /** + * 校验 Tool 发布快照的内容 hash。 + * + * @param snapshot Tool 发布快照 + */ + void assertPublishedSnapshotHash(Map snapshot); + + /** + * 删除指定 Skill 的全部草稿 Tool 绑定。 + * + * @param skillId Skill ID + */ + void removeBySkillId(BigInteger skillId); +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillToolOptionQueryService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillToolOptionQueryService.java new file mode 100644 index 00000000..a43875c2 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillToolOptionQueryService.java @@ -0,0 +1,197 @@ +package tech.easyflow.skill.service; + +import com.mybatisflex.core.query.QueryWrapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import tech.easyflow.ai.entity.Mcp; +import tech.easyflow.ai.entity.Plugin; +import tech.easyflow.ai.entity.PluginItem; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.mapper.PluginMapper; +import tech.easyflow.ai.permission.McpAccessPermissionChecker; +import tech.easyflow.ai.service.McpService; +import tech.easyflow.ai.service.PluginItemService; +import tech.easyflow.ai.service.PluginVisibilityService; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillToolBinding; +import tech.easyflow.skill.vo.SkillMcpToolManifestView; +import tech.easyflow.skill.vo.SkillToolOptionPage; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.function.Predicate; + +/** + * Skill Studio 页面归属的 Tool 候选与 MCP 清单查询服务。 + */ +@Service +public class SkillToolOptionQueryService { + + private final WorkflowService workflowService; + private final PluginItemService pluginItemService; + private final PluginMapper pluginMapper; + private final PluginVisibilityService pluginVisibilityService; + private final McpService mcpService; + private final McpAccessPermissionChecker mcpAccessPermissionChecker; + private final SkillToolResourceService resourceService; + private final ResourceAccessService resourceAccessService; + + /** + * 创建候选查询服务。 + */ + public SkillToolOptionQueryService(WorkflowService workflowService, + PluginItemService pluginItemService, + PluginMapper pluginMapper, + PluginVisibilityService pluginVisibilityService, + McpService mcpService, + McpAccessPermissionChecker mcpAccessPermissionChecker, + SkillToolResourceService resourceService, + ResourceAccessService resourceAccessService) { + this.workflowService = workflowService; + this.pluginItemService = pluginItemService; + this.pluginMapper = pluginMapper; + this.pluginVisibilityService = pluginVisibilityService; + this.mcpService = mcpService; + this.mcpAccessPermissionChecker = mcpAccessPermissionChecker; + this.resourceService = resourceService; + this.resourceAccessService = resourceAccessService; + } + + /** + * 查询当前操作者可绑定的 Tool 候选。 + * + * @param keyword 名称或描述关键词 + * @param toolType 类型过滤 + * @param pageNum 页码 + * @param pageSize 每页数量 + * @return 安全候选分页 + */ + public SkillToolOptionPage page(String keyword, String toolType, long pageNum, long pageSize) { + LoginAccount account = requireAccount(); + String normalizedType = toolType == null ? "ALL" : toolType.trim().toUpperCase(Locale.ROOT); + if (!List.of("ALL", "WORKFLOW", "PLUGIN", "MCP").contains(normalizedType)) { + throw new BusinessException("不支持的 Tool 类型:" + toolType); + } + Predicate keywordFilter = item -> matches(item, keyword); + List candidates = new ArrayList<>(); + if ("ALL".equals(normalizedType) || "WORKFLOW".equals(normalizedType)) { + workflowService.list(QueryWrapper.create() + .eq(Workflow::getTenantId, account.getTenantId()) + .eq(Workflow::getPublishStatus, PublishStatus.PUBLISHED.getCode())) + .stream().filter(item -> resourceAccessService.canAccess( + CategoryResourceType.WORKFLOW, item, ResourceAction.USE)) + .map(item -> new SkillToolOptionPage.Item("WORKFLOW", item.getId(), item.getTitle(), + item.getDescription(), true, false, 1)) + .filter(keywordFilter).forEach(candidates::add); + } + if ("ALL".equals(normalizedType) || "PLUGIN".equals(normalizedType)) { + appendPlugins(account, keywordFilter, candidates); + } + if ("MCP".equals(normalizedType)) { + mcpAccessPermissionChecker.assertCanUseMcp(); + appendMcps(account, keywordFilter, candidates); + } else if ("ALL".equals(normalizedType) && mcpAccessPermissionChecker.canUseMcp()) { + // 聚合查询只展示当前用户可用的资源,不能让缺少 MCP 权限影响其他候选。 + appendMcps(account, keywordFilter, candidates); + } + candidates.sort(Comparator.comparing(SkillToolOptionPage.Item::title, + Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)) + .thenComparing(SkillToolOptionPage.Item::targetId)); + long safePage = Math.max(1, pageNum); + long safeSize = Math.max(1, Math.min(100, pageSize)); + int from = (int) Math.min(candidates.size(), (safePage - 1) * safeSize); + int to = (int) Math.min(candidates.size(), from + safeSize); + return new SkillToolOptionPage(candidates.subList(from, to), candidates.size(), safePage, safeSize); + } + + /** + * 追加当前租户可用的 MCP 候选。 + * + * @param account 当前账号 + * @param keywordFilter 关键词过滤器 + * @param candidates 候选集合 + */ + private void appendMcps(LoginAccount account, + Predicate keywordFilter, + List candidates) { + mcpService.list(QueryWrapper.create() + .eq(Mcp::getTenantId, account.getTenantId()) + .eq(Mcp::getStatus, true)) + .stream().map(item -> new SkillToolOptionPage.Item("MCP", item.getId(), item.getTitle(), + item.getDescription(), true, Boolean.TRUE.equals(item.getApprovalRequired()), null)) + .filter(keywordFilter).forEach(candidates::add); + } + + /** + * 发现指定 MCP 的 Tool 清单。 + * + * @param mcpId MCP ID + * @return 脱敏清单 + */ + @Transactional(rollbackFor = Exception.class) + public SkillMcpToolManifestView mcpTools(BigInteger mcpId) { + LoginAccount account = requireAccount(); + Skill pseudoSkill = new Skill(); + pseudoSkill.setTenantId(account.getTenantId()); + SkillToolBinding binding = new SkillToolBinding(); + binding.setToolType("MCP"); + binding.setTargetId(mcpId); + SkillToolResourceService.McpResource resource = resourceService.requireMcp(pseudoSkill, binding); + List tools = resource.manifest().stream() + .map(item -> new SkillMcpToolManifestView.Tool(item.getName(), item.getDescription(), + item.getInputSchema(), item.getOutputSchema())) + .toList(); + return new SkillMcpToolManifestView(resource.manifestHash(), tools.size(), tools); + } + + private void appendPlugins(LoginAccount account, + Predicate keywordFilter, + List candidates) { + Map plugins = pluginMapper.selectListByQuery(QueryWrapper.create() + .eq(Plugin::getTenantId, account.getTenantId())).stream() + .filter(plugin -> pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) + .collect(java.util.stream.Collectors.toMap(Plugin::getId, plugin -> plugin)); + if (plugins.isEmpty()) { + return; + } + pluginItemService.list(QueryWrapper.create() + .in(PluginItem::getPluginId, plugins.keySet()) + .eq(PluginItem::getStatus, 1)) + .stream().map(item -> new SkillToolOptionPage.Item("PLUGIN", item.getId(), item.getName(), + item.getDescription(), true, false, 1)) + .filter(keywordFilter).forEach(candidates::add); + } + + private boolean matches(SkillToolOptionPage.Item item, String keyword) { + if (keyword == null || keyword.isBlank()) { + return true; + } + String needle = keyword.trim().toLowerCase(Locale.ROOT); + return contains(item.title(), needle) || contains(item.description(), needle); + } + + private boolean contains(String value, String needle) { + return value != null && value.toLowerCase(Locale.ROOT).contains(needle); + } + + private LoginAccount requireAccount() { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null || account.getTenantId() == null) { + throw new BusinessException(401, 401, "当前登录状态失效,请重新登录后再试"); + } + return account; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillToolResourceService.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillToolResourceService.java new file mode 100644 index 00000000..89d37cd9 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/SkillToolResourceService.java @@ -0,0 +1,90 @@ +package tech.easyflow.skill.service; + +import com.easyagents.agent.runtime.mcp.McpToolManifestEntry; +import tech.easyflow.ai.entity.Mcp; +import tech.easyflow.ai.entity.PluginItem; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillToolBinding; + +import java.util.List; +import java.util.Map; + +/** + * Skill Tool 目标资源的权限校验、清单读取与安全快照服务。 + */ +public interface SkillToolResourceService { + + /** + * 校验并加载已发布工作流。 + * + * @param skill Skill + * @param binding 工作流绑定 + * @return 已发布工作流 + */ + Workflow requireWorkflow(Skill skill, SkillToolBinding binding); + + /** + * 校验并加载已启用插件工具。 + * + * @param skill Skill + * @param binding 插件绑定 + * @return 插件工具 + */ + PluginItem requirePlugin(Skill skill, SkillToolBinding binding); + + /** + * 校验并加载可用的单服务 MCP。 + * + * @param skill Skill + * @param binding MCP 绑定 + * @return MCP 与当前 Tool 清单 + */ + McpResource requireMcp(Skill skill, SkillToolBinding binding); + + /** + * 构建运行时所需的资源快照。 + * + * @param resource 资源实体 + * @return 包含插件项与父插件调用配置的服务端内部资源快照 + */ + Map snapshotWorkflow(Workflow workflow); + + /** + * 构建插件工具运行快照。 + * + * @param pluginItem 插件工具 + * @return 服务端内部资源快照 + */ + Map snapshotPlugin(PluginItem pluginItem); + + /** + * 构建 MCP 受控连接快照。 + * + *

该快照仅供服务端 Runtime 使用,字段使用显式白名单,禁止直接序列化 MCP 实体。

+ * + * @param mcp MCP 资源 + * @return 服务端内部连接快照 + */ + Map snapshotMcpConnection(Mcp mcp); + + /** + * 不连接外部服务地读取绑定资源脱敏摘要。 + * + * @param binding Tool 绑定 + * @return 可用于详情展示的摘要 + */ + Map currentSummary(SkillToolBinding binding); + + /** + * MCP 与已规范化 Tool 清单。 + * + * @param mcp MCP 资源 + * @param manifest 冻结清单 + * @param manifestHash 完整清单 hash + */ + record McpResource(Mcp mcp, + List manifest, + String manifestHash) { + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillServiceImpl.java index 63ef02b7..0af60c02 100644 --- a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillServiceImpl.java +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillServiceImpl.java @@ -13,6 +13,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.spring.service.impl.ServiceImpl; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.dao.DuplicateKeyException; import org.springframework.transaction.annotation.Transactional; import tech.easyflow.ai.enums.PublishStatus; @@ -25,6 +26,8 @@ import tech.easyflow.skill.mapper.SkillMapper; import tech.easyflow.skill.service.SkillCategoryService; import tech.easyflow.skill.service.SkillResourceService; import tech.easyflow.skill.service.SkillService; +import tech.easyflow.skill.service.SkillToolBindingService; +import tech.easyflow.skill.service.SkillReferenceProvider; import tech.easyflow.skill.store.DBSkillContentStore; import tech.easyflow.skill.support.SkillModelConverter; import tech.easyflow.skill.validation.SkillValidationIssue; @@ -59,33 +62,41 @@ public class SkillServiceImpl extends ServiceImpl implements private final DefaultSkillValidator skillValidator = new DefaultSkillValidator(); private final SkillCategoryService skillCategoryService; private final SkillResourceService skillResourceService; + private final SkillToolBindingService skillToolBindingService; private final DBSkillContentStore contentStore; private final ResourceAccessService resourceAccessService; private final CategoryPermissionService categoryPermissionService; private final ObjectMapper objectMapper; + private final ObjectProvider referenceProviders; /** * 创建 Skill 业务服务。 * * @param skillCategoryService Skill 分类服务 * @param skillResourceService 通用资源服务 + * @param skillToolBindingService 平台 Tool 绑定服务 * @param contentStore 二进制内容仓库 * @param resourceAccessService 资源访问服务 * @param categoryPermissionService 分类权限服务 * @param objectMapper JSON 映射器 + * @param referenceProviders 上层引用查询扩展点 */ public SkillServiceImpl(SkillCategoryService skillCategoryService, SkillResourceService skillResourceService, + SkillToolBindingService skillToolBindingService, DBSkillContentStore contentStore, ResourceAccessService resourceAccessService, CategoryPermissionService categoryPermissionService, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + ObjectProvider referenceProviders) { this.skillCategoryService = skillCategoryService; this.skillResourceService = skillResourceService; + this.skillToolBindingService = skillToolBindingService; this.contentStore = contentStore; this.resourceAccessService = resourceAccessService; this.categoryPermissionService = categoryPermissionService; this.objectMapper = objectMapper; + this.referenceProviders = referenceProviders; } /** @@ -107,6 +118,7 @@ public class SkillServiceImpl extends ServiceImpl implements Skill skill = requireSkill(id); resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限查看该 Skill"); fillResourceDescriptors(skill); + skill.setToolBindings(skillToolBindingService.listSummaries(id)); return skill; } @@ -338,7 +350,9 @@ public class SkillServiceImpl extends ServiceImpl implements Map snapshot = new LinkedHashMap<>(); snapshot.put("schemaVersion", 2); snapshot.put("name", detail.getName()); + snapshot.put("displayName", detail.getDisplayName()); snapshot.put("description", detail.getDescription()); + snapshot.put("visibilityScope", detail.getVisibilityScope()); snapshot.put("skillContent", detail.getSkillContent()); snapshot.put("packageHash", detail.getPackageHash()); snapshot.put("resources", buildResourceSnapshot(detail.getResources())); @@ -347,6 +361,53 @@ public class SkillServiceImpl extends ServiceImpl implements return snapshot; } + /** {@inheritDoc} */ + @Override + public Map buildApprovalSnapshot(Skill skill) { + Map contentSnapshot = buildPublishSnapshot(skill); + Map toolSnapshot = skillToolBindingService.buildPublishSnapshot(skill); + Map approvalSnapshot = new LinkedHashMap<>(contentSnapshot); + String contentHash = String.valueOf(contentSnapshot.get("snapshotHash")); + approvalSnapshot.remove("snapshotHash"); + approvalSnapshot.put("contentSnapshotHash", contentHash); + approvalSnapshot.put("platformToolBindings", toolSnapshot); + approvalSnapshot.put("toolBindingsHash", toolSnapshot.get("snapshotHash")); + approvalSnapshot.put("snapshotHash", hashJson(approvalSnapshot)); + return approvalSnapshot; + } + + /** {@inheritDoc} */ + @Override + public Map extractContentSnapshot(Map approvalSnapshot) { + if (approvalSnapshot == null || approvalSnapshot.isEmpty()) { + throw new BusinessException("Skill 发布快照为空"); + } + Map content = new LinkedHashMap<>(approvalSnapshot); + Object contentHash = content.remove("contentSnapshotHash"); + content.remove("platformToolBindings"); + content.remove("toolBindingsHash"); + content.remove("snapshotHash"); + if (contentHash == null) { + // 兼容 L13 仅含标准包内容的历史审批快照。 + return new LinkedHashMap<>(approvalSnapshot); + } + content.put("snapshotHash", String.valueOf(contentHash)); + assertSnapshotHash(content); + return content; + } + + /** {@inheritDoc} */ + @Override + public Map extractToolBindingsSnapshot(Map approvalSnapshot) { + Object value = approvalSnapshot == null ? null : approvalSnapshot.get("platformToolBindings"); + if (!(value instanceof Map source)) { + return new LinkedHashMap<>(); + } + Map result = new LinkedHashMap<>(); + source.forEach((key, item) -> result.put(String.valueOf(key), item)); + return result; + } + /** * {@inheritDoc} */ @@ -386,6 +447,49 @@ public class SkillServiceImpl extends ServiceImpl implements } } + /** {@inheritDoc} */ + @Override + public void assertPublishedAggregateHash(Skill skill) { + if (skill == null || skill.getPublishedSnapshotJson() == null + || skill.getPublishedSnapshotJson().isEmpty()) { + throw new BusinessException("Skill 发布快照为空"); + } + Map content = skill.getPublishedSnapshotJson(); + Map toolSnapshot = skill.getPublishedToolBindingsJson() == null + ? new LinkedHashMap<>() : skill.getPublishedToolBindingsJson(); + assertSnapshotHash(content); + if (toolSnapshot.isEmpty()) { + if (skill.getSnapshotHash() != null && !skill.getSnapshotHash().isBlank() + && !skill.getSnapshotHash().equals(String.valueOf(content.get("snapshotHash")))) { + throw new BusinessException("Skill 历史发布快照 hash 校验失败"); + } + return; + } + skillToolBindingService.assertPublishedSnapshotHash(toolSnapshot); + if (skill.getSnapshotHash() == null || skill.getSnapshotHash().isBlank()) { + return; + } + Map combined = new LinkedHashMap<>(content); + Object contentHash = combined.remove("snapshotHash"); + combined.put("contentSnapshotHash", contentHash); + combined.put("platformToolBindings", toolSnapshot); + combined.put("toolBindingsHash", toolSnapshot.get("snapshotHash")); + if (!skill.getSnapshotHash().equals(hashJson(combined))) { + throw new BusinessException("Skill 发布组合快照 hash 校验失败"); + } + } + + /** {@inheritDoc} */ + @Override + public void assertNoActiveReferences(BigInteger skillId) { + for (SkillReferenceProvider provider : referenceProviders.orderedStream().toList()) { + List references = provider.listReferences(skillId); + if (references != null && !references.isEmpty()) { + throw new BusinessException("Skill 仍被" + references.get(0) + "使用,请先取消绑定或重新发布后再操作"); + } + } + } + /** * {@inheritDoc} */ @@ -454,6 +558,7 @@ public class SkillServiceImpl extends ServiceImpl implements // 文件和资源更新同样先锁 Skill 行;删除必须持有该锁直到引用计数变更完成。 Skill skill = requireSkill(id, true); resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, "无权限删除该 Skill"); + assertNoActiveReferences(id); assertRemovableStatus(skill, lifecycleDelete); List resources = listResources(id); if (!skillResourceService.remove(QueryWrapper.create() @@ -463,6 +568,7 @@ public class SkillServiceImpl extends ServiceImpl implements throw new BusinessException(500, 500, "删除 Skill 资源失败,请稍后重试"); } } + skillToolBindingService.removeBySkillId(id); if (getMapper().deleteByQuery(tenantSkillQuery(id)) != 1) { throw new BusinessException(500, 500, "删除 Skill 失败,请稍后重试"); } @@ -776,7 +882,12 @@ public class SkillServiceImpl extends ServiceImpl implements if (value instanceof List list) { return list.stream().map(this::canonicalizeJson).toList(); } - return value; + if (value == null || value instanceof String || value instanceof Number + || value instanceof Boolean) { + return value; + } + // MCP Manifest 等对象在发布时是 POJO,持久化后会恢复为 Map,需先投影为同一 JSON 结构。 + return canonicalizeJson(objectMapper.convertValue(value, Object.class)); } /** diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillToolBindingServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillToolBindingServiceImpl.java new file mode 100644 index 00000000..0ac1ab73 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillToolBindingServiceImpl.java @@ -0,0 +1,492 @@ +package tech.easyflow.skill.service.impl; + +import com.easyagents.agent.runtime.mcp.McpToolManifestEntry; +import com.easyagents.skill.util.SkillHashes; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mybatisflex.core.query.QueryWrapper; +import com.mybatisflex.spring.service.impl.ServiceImpl; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import tech.easyflow.ai.entity.Mcp; +import tech.easyflow.ai.entity.PluginItem; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillToolBinding; +import tech.easyflow.skill.enums.SkillToolType; +import tech.easyflow.skill.mapper.SkillMapper; +import tech.easyflow.skill.mapper.SkillToolBindingMapper; +import tech.easyflow.skill.service.SkillToolBindingService; +import tech.easyflow.skill.service.SkillToolResourceService; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/** + * Skill 平台 Tool 绑定服务实现。 + */ +@Service +public class SkillToolBindingServiceImpl + extends ServiceImpl + implements SkillToolBindingService { + + private static final int MAX_TOOL_COUNT = 20; + + private final SkillMapper skillMapper; + private final SkillToolResourceService resourceService; + private final ResourceAccessService resourceAccessService; + private final ObjectMapper objectMapper; + + /** + * 创建 Skill Tool 绑定服务。 + * + * @param skillMapper Skill Mapper + * @param resourceService Tool 目标资源服务 + * @param resourceAccessService 资源权限服务 + * @param objectMapper JSON 映射器 + */ + public SkillToolBindingServiceImpl(SkillMapper skillMapper, + SkillToolResourceService resourceService, + ResourceAccessService resourceAccessService, + ObjectMapper objectMapper) { + this.skillMapper = skillMapper; + this.resourceService = resourceService; + this.resourceAccessService = resourceAccessService; + this.objectMapper = objectMapper; + } + + /** {@inheritDoc} */ + @Override + @Transactional(rollbackFor = Exception.class) + public List replaceBindings(BigInteger skillId, + List bindings) { + Skill skill = requireSkillForUpdate(skillId); + resourceAccessService.assertAccess( + CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, "无权限管理该 Skill"); + List normalized = normalizeBindings(skill, bindings, true); + remove(QueryWrapper.create() + .eq(SkillToolBinding::getTenantId, skill.getTenantId()) + .eq(SkillToolBinding::getSkillId, skill.getId())); + if (!normalized.isEmpty()) { + saveBatch(normalized); + } + return listSummaries(skillId); + } + + /** {@inheritDoc} */ + @Override + public List listSummaries(BigInteger skillId) { + Skill skill = skillMapper.selectOneById(skillId); + if (skill == null) { + throw new BusinessException(404, 404, "Skill 不存在"); + } + resourceAccessService.assertAccess( + CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限查看该 Skill"); + List bindings = listBindings(skillId); + for (SkillToolBinding binding : bindings) { + binding.setResourceSummary(buildCurrentSummary(skill, binding)); + } + return bindings; + } + + /** {@inheritDoc} */ + @Override + public List listBindings(BigInteger skillId) { + if (skillId == null) { + return Collections.emptyList(); + } + return list(QueryWrapper.create() + .eq(SkillToolBinding::getSkillId, skillId) + .orderBy(SkillToolBinding::getSortNo, true) + .orderBy(SkillToolBinding::getId, true)); + } + + /** {@inheritDoc} */ + @Override + @Transactional(rollbackFor = Exception.class) + public Map buildPublishSnapshot(Skill skill) { + if (skill == null || skill.getId() == null) { + throw new BusinessException("Skill ID 不能为空"); + } + List bindings = listBindings(skill.getId()); + assertUniqueBindings(bindings, "同一工具资源不能重复绑定"); + Map> snapshotsByResource = new LinkedHashMap<>(); + int toolCount = 0; + for (SkillToolBinding binding : stableResourceOrder(bindings)) { + Map item = buildBindingSnapshot(skill, binding); + toolCount += ((Number) item.get("toolCount")).intValue(); + if (toolCount > MAX_TOOL_COUNT) { + throw new BusinessException(409, 4092, "单个 Skill 最多可绑定 20 个实际 Tool"); + } + snapshotsByResource.put(bindingKey(binding), item); + } + List> snapshots = bindings.stream() + .map(binding -> snapshotsByResource.get(bindingKey(binding))) + .toList(); + Map snapshot = new LinkedHashMap<>(); + snapshot.put("schemaVersion", 1); + snapshot.put("bindings", snapshots); + snapshot.put("snapshotHash", hash(snapshot)); + return snapshot; + } + + /** {@inheritDoc} */ + @Override + public void assertPublishedSnapshotUsable(Skill skill) { + Map snapshot = skill == null ? null : skill.getPublishedToolBindingsJson(); + if (snapshot == null || snapshot.isEmpty()) { + return; + } + Object rawBindings = snapshot.get("bindings"); + if (!(rawBindings instanceof List items)) { + throw new BusinessException(409, 4092, "Skill Tool 发布快照格式错误"); + } + List bindings = new ArrayList<>(); + for (int index = 0; index < items.size(); index++) { + if (!(items.get(index) instanceof Map raw)) { + throw new BusinessException(409, 4092, "Skill Tool 发布快照格式错误"); + } + bindings.add(snapshotBinding(raw, index)); + } + assertUniqueBindings(bindings, "Skill Tool 发布快照包含重复资源"); + int toolCount = 0; + for (SkillToolBinding binding : stableResourceOrder(bindings)) { + SkillToolType type = SkillToolType.from(binding.getToolType()); + if (type == SkillToolType.WORKFLOW) { + resourceService.requireWorkflow(skill, binding); + toolCount++; + } else if (type == SkillToolType.PLUGIN) { + resourceService.requirePlugin(skill, binding); + toolCount++; + } else { + SkillToolResourceService.McpResource mcp = resourceService.requireMcp(skill, binding); + if (!mcp.manifestHash().equals(binding.getMcpToolManifestHash())) { + throw new BusinessException(409, 4092, "已发布 Skill 的 MCP Tool 清单已变化,请重新发布 Skill"); + } + toolCount += mcp.manifest().size(); + } + if (toolCount > MAX_TOOL_COUNT) { + throw new BusinessException(409, 4092, "已发布 Skill 的实际 Tool 数超过 20 个"); + } + } + } + + /** {@inheritDoc} */ + @Override + public void assertPublishedSnapshotHash(Map snapshot) { + if (snapshot == null || snapshot.isEmpty()) { + return; + } + Object declared = snapshot.get("snapshotHash"); + if (declared == null) { + throw new BusinessException("Skill Tool 发布快照缺少 hash"); + } + Map canonical = new LinkedHashMap<>(snapshot); + canonical.remove("snapshotHash"); + if (!String.valueOf(declared).equals(hash(canonical))) { + throw new BusinessException("Skill Tool 发布快照 hash 校验失败"); + } + } + + /** {@inheritDoc} */ + @Override + public void removeBySkillId(BigInteger skillId) { + if (skillId == null) { + return; + } + remove(QueryWrapper.create().eq(SkillToolBinding::getSkillId, skillId)); + } + + /** + * 规范化绑定并完成权限、MCP manifest 和数量复核。 + * + * @param skill Skill + * @param bindings 原始绑定 + * @param compareClientManifest 是否校验客户端看见的 MCP manifest + * @return 可持久化的稳定绑定 + */ + private List normalizeBindings(Skill skill, + List bindings, + boolean compareClientManifest) { + if (bindings == null || bindings.isEmpty()) { + return List.of(); + } + List normalized = new ArrayList<>(); + for (int i = 0; i < bindings.size(); i++) { + SkillToolBinding source = bindings.get(i); + if (source == null || source.getTargetId() == null) { + throw new BusinessException("Skill 工具绑定参数不完整"); + } + SkillToolType type = SkillToolType.from(source.getToolType()); + normalized.add(copyForPersistence(skill, source, type, i)); + } + assertUniqueBindings(normalized, "同一工具资源不能重复绑定"); + int toolCount = 0; + for (SkillToolBinding binding : stableResourceOrder(normalized)) { + SkillToolType type = SkillToolType.from(binding.getToolType()); + if (type == SkillToolType.WORKFLOW) { + resourceService.requireWorkflow(skill, binding); + toolCount++; + } else if (type == SkillToolType.PLUGIN) { + resourceService.requirePlugin(skill, binding); + toolCount++; + } else { + SkillToolResourceService.McpResource mcp = resourceService.requireMcp(skill, binding); + if (compareClientManifest && (binding.getMcpToolManifestHash() == null + || !binding.getMcpToolManifestHash().equals(mcp.manifestHash()))) { + throw new BusinessException(409, 4092, "MCP Tool 清单已变化,请刷新后重新确认"); + } + if (!compareClientManifest && binding.getMcpToolManifestHash() != null + && !binding.getMcpToolManifestHash().equals(mcp.manifestHash())) { + throw new BusinessException(409, 4092, "MCP Tool 清单已变化,请重新保存绑定后发布"); + } + binding.setMcpToolCount(mcp.manifest().size()); + binding.setMcpToolManifestHash(mcp.manifestHash()); + binding.setHitlEnabled(Boolean.TRUE.equals(binding.getHitlEnabled()) + || Boolean.TRUE.equals(mcp.mcp().getApprovalRequired())); + toolCount += mcp.manifest().size(); + } + if (toolCount > MAX_TOOL_COUNT) { + throw new BusinessException(409, 4092, "单个 Skill 最多可绑定 20 个实际 Tool"); + } + } + return normalized; + } + + /** + * 创建安全持久化副本并写入审计字段。 + * + * @param skill Skill + * @param source 原始绑定 + * @param type Tool 类型 + * @param index 稳定顺序 + * @return 持久化绑定 + */ + private SkillToolBinding copyForPersistence(Skill skill, + SkillToolBinding source, + SkillToolType type, + int index) { + LoginAccount account = requireCurrentAccount(); + Date now = new Date(); + SkillToolBinding binding = new SkillToolBinding(); + binding.setTenantId(skill.getTenantId()); + binding.setSkillId(skill.getId()); + binding.setToolType(type.name()); + binding.setTargetId(source.getTargetId()); + binding.setHitlEnabled(Boolean.TRUE.equals(source.getHitlEnabled())); + binding.setMcpToolCount(type == SkillToolType.MCP ? source.getMcpToolCount() : null); + binding.setMcpToolManifestHash(type == SkillToolType.MCP ? source.getMcpToolManifestHash() : null); + binding.setSortNo(index); + binding.setCreated(now); + binding.setCreatedBy(account.getId()); + binding.setModified(now); + binding.setModifiedBy(account.getId()); + return binding; + } + + /** + * 构建单条发布绑定快照。 + * + * @param skill Skill + * @param binding 规范化绑定 + * @return 冻结绑定 + */ + private Map buildBindingSnapshot(Skill skill, SkillToolBinding binding) { + SkillToolType type = SkillToolType.from(binding.getToolType()); + Map result = new LinkedHashMap<>(); + result.put("toolType", type.name()); + result.put("targetId", binding.getTargetId()); + result.put("hitlEnabled", Boolean.TRUE.equals(binding.getHitlEnabled())); + result.put("sortNo", binding.getSortNo()); + if (type == SkillToolType.WORKFLOW) { + Workflow workflow = resourceService.requireWorkflow(skill, binding); + result.put("displayName", workflow.getTitle()); + result.put("toolCount", 1); + result.put("resourceSnapshot", resourceService.snapshotWorkflow(workflow)); + return result; + } + if (type == SkillToolType.PLUGIN) { + PluginItem plugin = resourceService.requirePlugin(skill, binding); + result.put("displayName", plugin.getName()); + result.put("toolCount", 1); + result.put("resourceSnapshot", resourceService.snapshotPlugin(plugin)); + return result; + } + SkillToolResourceService.McpResource mcp = resourceService.requireMcp(skill, binding); + if (binding.getMcpToolManifestHash() == null + || !binding.getMcpToolManifestHash().equals(mcp.manifestHash())) { + throw new BusinessException(409, 4092, "MCP Tool 清单已变化,请重新保存绑定后发布"); + } + result.put("displayName", mcp.mcp().getTitle()); + result.put("toolCount", mcp.manifest().size()); + result.put("mcpToolManifestHash", mcp.manifestHash()); + result.put("mcpToolManifest", mcp.manifest()); + result.put("resourceSnapshot", resourceService.snapshotMcpConnection(mcp.mcp())); + return result; + } + + /** + * 校验资源引用不重复。 + * + * @param bindings 绑定列表 + * @param message 重复时的错误消息 + */ + private void assertUniqueBindings(List bindings, String message) { + Set unique = new LinkedHashSet<>(); + for (SkillToolBinding binding : bindings) { + if (!unique.add(bindingKey(binding))) { + throw new BusinessException(409, 4092, message); + } + } + } + + /** + * 按资源类型和 ID 生成稳定加锁顺序,同时保留原列表的展示顺序。 + * + * @param bindings 绑定列表 + * @return 稳定排序副本 + */ + private List stableResourceOrder(List bindings) { + return bindings.stream() + .sorted(Comparator.comparing((SkillToolBinding binding) -> + SkillToolType.from(binding.getToolType()).name()) + .thenComparing(SkillToolBinding::getTargetId)) + .toList(); + } + + /** + * 生成绑定资源唯一键。 + * + * @param binding 绑定 + * @return 类型与目标 ID 组合键 + */ + private String bindingKey(SkillToolBinding binding) { + if (binding == null || binding.getTargetId() == null) { + throw new BusinessException("Skill 工具绑定参数不完整"); + } + return SkillToolType.from(binding.getToolType()).name() + ":" + binding.getTargetId(); + } + + /** + * 构建当前资源的脱敏摘要。 + * + * @param skill Skill + * @param binding 绑定 + * @return 脱敏摘要 + */ + private Map buildCurrentSummary(Skill skill, SkillToolBinding binding) { + return resourceService.currentSummary(binding); + } + + /** + * 查询并锁定 Skill。 + * + * @param skillId Skill ID + * @return 已锁定 Skill + */ + private Skill requireSkillForUpdate(BigInteger skillId) { + if (skillId == null) { + throw new BusinessException("Skill ID 不能为空"); + } + Skill skill = skillMapper.selectOneByQuery(QueryWrapper.create() + .eq(Skill::getId, skillId) + .forUpdate()); + if (skill == null) { + throw new BusinessException(404, 404, "Skill 不存在"); + } + if (PublishStatus.from(skill.getPublishStatus()) == PublishStatus.DELETE_PENDING) { + throw new BusinessException(409, 4092, "Skill 正在删除审批中,不能修改工具绑定"); + } + return skill; + } + + /** + * 计算 Tool 发布快照 hash。 + * + * @param value 待计算值 + * @return SHA-256 + */ + private String hash(Map value) { + try { + return SkillHashes.sha256Hex(objectMapper.writeValueAsBytes(canonicalizeJson(value))); + } catch (JsonProcessingException exception) { + throw new BusinessException(500, 500, "Skill 工具快照序列化失败"); + } + } + + /** + * 将快照转换为与 JSON 持久化前后无关的稳定结构。 + * + * @param value 快照节点 + * @return 键有序且只含 JSON 基础类型的结构 + */ + private Object canonicalizeJson(Object value) { + if (value instanceof Map map) { + Map sorted = new TreeMap<>(); + map.forEach((key, item) -> sorted.put(String.valueOf(key), canonicalizeJson(item))); + return sorted; + } + if (value instanceof List list) { + return list.stream().map(this::canonicalizeJson).toList(); + } + if (value == null || value instanceof String || value instanceof Number + || value instanceof Boolean) { + return value; + } + // Manifest entries are POJOs before persistence and Maps after JSON loading. + return canonicalizeJson(objectMapper.convertValue(value, Object.class)); + } + + /** + * 获取当前登录账号。 + * + * @return 当前登录账号 + */ + private LoginAccount requireCurrentAccount() { + LoginAccount account = SaTokenUtil.getLoginAccount(); + if (account == null || account.getId() == null) { + throw new BusinessException(401, 401, "当前登录状态失效,请重新登录后再试"); + } + return account; + } + + /** + * 将冻结快照项转换为只含校验字段的绑定引用。 + * + * @param raw 冻结快照项 + * @param index 稳定顺序 + * @return 绑定引用 + */ + private SkillToolBinding snapshotBinding(Map raw, int index) { + SkillToolBinding binding = new SkillToolBinding(); + binding.setToolType(String.valueOf(raw.get("toolType"))); + Object targetId = raw.get("targetId"); + if (targetId == null) { + throw new BusinessException(409, 4092, "Skill Tool 发布快照缺少目标 ID"); + } + binding.setTargetId(new BigInteger(String.valueOf(targetId))); + binding.setHitlEnabled(Boolean.TRUE.equals(raw.get("hitlEnabled"))); + Object manifestHash = raw.get("mcpToolManifestHash"); + binding.setMcpToolManifestHash(manifestHash == null ? null : String.valueOf(manifestHash)); + Object count = raw.get("toolCount"); + binding.setMcpToolCount(count instanceof Number number ? number.intValue() : null); + binding.setSortNo(index); + return binding; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillToolReferenceProviderImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillToolReferenceProviderImpl.java new file mode 100644 index 00000000..ec89b0a3 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillToolReferenceProviderImpl.java @@ -0,0 +1,91 @@ +package tech.easyflow.skill.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import org.springframework.stereotype.Component; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.service.SkillToolReferenceProvider; +import tech.easyflow.ai.vo.OfflineImpactBindingVo; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillToolBinding; +import tech.easyflow.skill.enums.SkillToolType; +import tech.easyflow.skill.service.SkillService; +import tech.easyflow.skill.service.SkillToolBindingService; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Skill 草稿与有效发布快照中的平台 Tool 引用提供者。 + */ +@Component +public class SkillToolReferenceProviderImpl implements SkillToolReferenceProvider { + + private final SkillService skillService; + private final SkillToolBindingService bindingService; + + /** + * 创建 Skill Tool 引用提供者。 + * + * @param skillService Skill 服务 + * @param bindingService Skill Tool 绑定服务 + */ + public SkillToolReferenceProviderImpl(SkillService skillService, + SkillToolBindingService bindingService) { + this.skillService = skillService; + this.bindingService = bindingService; + } + + /** {@inheritDoc} */ + @Override public List listSkillsByWorkflowId(BigInteger id) { + return listReferences(SkillToolType.WORKFLOW, id); + } + + /** {@inheritDoc} */ + @Override public List listSkillsByPluginItemId(BigInteger id) { + return listReferences(SkillToolType.PLUGIN, id); + } + + /** {@inheritDoc} */ + @Override public List listSkillsByMcpId(BigInteger id) { + return listReferences(SkillToolType.MCP, id); + } + + private List listReferences(SkillToolType type, BigInteger targetId) { + Set ids = new LinkedHashSet<>(); + for (SkillToolBinding binding : bindingService.list(QueryWrapper.create() + .eq(SkillToolBinding::getToolType, type.name()) + .eq(SkillToolBinding::getTargetId, targetId))) { + ids.add(binding.getSkillId()); + } + for (Skill skill : skillService.list(QueryWrapper.create() + .select(Skill::getId, Skill::getPublishStatus, Skill::getPublishedToolBindingsJson) + .isNotNull(Skill::getPublishedToolBindingsJson))) { + if (PublishStatus.from(skill.getPublishStatus()).isExternallyVisible() + && contains(skill.getPublishedToolBindingsJson(), type, targetId)) { + ids.add(skill.getId()); + } + } + List result = new ArrayList<>(); + for (Skill skill : skillService.listByIds(ids)) { + OfflineImpactBindingVo item = new OfflineImpactBindingVo(); + item.setId(skill.getId()); + item.setTitle("Skill“" + (skill.getDisplayName() == null ? skill.getName() : skill.getDisplayName()) + "”"); + result.add(item); + } + return result; + } + + private boolean contains(Map snapshot, SkillToolType type, BigInteger targetId) { + Object raw = snapshot == null ? null : snapshot.get("bindings"); + if (!(raw instanceof List bindings)) { + return false; + } + return bindings.stream().anyMatch(item -> item instanceof Map binding + && type.name().equalsIgnoreCase(String.valueOf(binding.get("toolType"))) + && targetId.toString().equals(String.valueOf(binding.get("targetId")))); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillToolResourceServiceImpl.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillToolResourceServiceImpl.java new file mode 100644 index 00000000..1856c8a9 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/service/impl/SkillToolResourceServiceImpl.java @@ -0,0 +1,281 @@ +package tech.easyflow.skill.service.impl; + +import com.easyagents.agent.runtime.mcp.McpToolManifest; +import com.easyagents.agent.runtime.mcp.McpToolManifestEntry; +import com.easyagents.agent.runtime.mcp.McpClientFactory; +import com.easyagents.agent.runtime.mcp.McpSpec; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mybatisflex.core.query.QueryWrapper; +import io.agentscope.core.tool.mcp.McpClientWrapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import tech.easyflow.ai.entity.Mcp; +import tech.easyflow.ai.entity.Plugin; +import tech.easyflow.ai.entity.PluginItem; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.easyagentsflow.repository.AgentWorkflowSnapshotFactory; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.mapper.PluginMapper; +import tech.easyflow.ai.mcp.McpRuntimeSpecFactory; +import tech.easyflow.ai.mcp.McpConnectionSnapshotFactory; +import tech.easyflow.ai.plugin.PluginConnectionSnapshotFactory; +import tech.easyflow.ai.permission.McpAccessPermissionChecker; +import tech.easyflow.ai.service.McpService; +import tech.easyflow.ai.service.PluginItemService; +import tech.easyflow.ai.service.PluginVisibilityService; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillToolBinding; +import tech.easyflow.skill.enums.SkillToolType; +import tech.easyflow.skill.service.SkillToolResourceService; +import tech.easyflow.system.enums.CategoryResourceType; +import tech.easyflow.system.enums.ResourceAction; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Skill Tool 目标资源服务实现。 + */ +@Service +public class SkillToolResourceServiceImpl implements SkillToolResourceService { + + private static final Logger LOGGER = LoggerFactory.getLogger(SkillToolResourceServiceImpl.class); + private static final TypeReference> MAP_TYPE = new TypeReference<>() { }; + + private final WorkflowService workflowService; + private final PluginItemService pluginItemService; + private final PluginMapper pluginMapper; + private final PluginVisibilityService pluginVisibilityService; + private final McpService mcpService; + private final McpAccessPermissionChecker mcpAccessPermissionChecker; + private final McpRuntimeSpecFactory mcpRuntimeSpecFactory; + private final McpConnectionSnapshotFactory mcpConnectionSnapshotFactory; + private final PluginConnectionSnapshotFactory pluginConnectionSnapshotFactory; + private final AgentWorkflowSnapshotFactory agentWorkflowSnapshotFactory; + private final ResourceAccessService resourceAccessService; + private final ObjectMapper objectMapper; + + /** + * 创建 Skill Tool 目标资源服务。 + * + * @param workflowService 工作流服务 + * @param pluginItemService 插件工具服务 + * @param pluginMapper 插件 Mapper + * @param pluginVisibilityService 插件可见性服务 + * @param mcpService MCP 服务 + * @param mcpAccessPermissionChecker MCP 权限检查器 + * @param mcpRuntimeSpecFactory MCP 运行声明工厂 + * @param mcpConnectionSnapshotFactory MCP 受控连接快照工厂 + * @param pluginConnectionSnapshotFactory 插件受控连接快照工厂 + * @param agentWorkflowSnapshotFactory Agent Workflow 冻结快照工厂 + * @param resourceAccessService 资源权限服务 + * @param objectMapper JSON 映射器 + */ + public SkillToolResourceServiceImpl(WorkflowService workflowService, + PluginItemService pluginItemService, + PluginMapper pluginMapper, + PluginVisibilityService pluginVisibilityService, + McpService mcpService, + McpAccessPermissionChecker mcpAccessPermissionChecker, + McpRuntimeSpecFactory mcpRuntimeSpecFactory, + McpConnectionSnapshotFactory mcpConnectionSnapshotFactory, + PluginConnectionSnapshotFactory pluginConnectionSnapshotFactory, + AgentWorkflowSnapshotFactory agentWorkflowSnapshotFactory, + ResourceAccessService resourceAccessService, + ObjectMapper objectMapper) { + this.workflowService = workflowService; + this.pluginItemService = pluginItemService; + this.pluginMapper = pluginMapper; + this.pluginVisibilityService = pluginVisibilityService; + this.mcpService = mcpService; + this.mcpAccessPermissionChecker = mcpAccessPermissionChecker; + this.mcpRuntimeSpecFactory = mcpRuntimeSpecFactory; + this.mcpConnectionSnapshotFactory = mcpConnectionSnapshotFactory; + this.pluginConnectionSnapshotFactory = pluginConnectionSnapshotFactory; + this.agentWorkflowSnapshotFactory = agentWorkflowSnapshotFactory; + this.resourceAccessService = resourceAccessService; + this.objectMapper = objectMapper; + } + + /** {@inheritDoc} */ + @Override + public Workflow requireWorkflow(Skill skill, SkillToolBinding binding) { + BigInteger targetId = requireTargetId(binding); + Workflow workflow = workflowService.getOne(QueryWrapper.create() + .eq(Workflow::getId, targetId) + .forUpdate()); + if (workflow == null || PublishStatus.from(workflow.getPublishStatus()) != PublishStatus.PUBLISHED) { + throw new BusinessException("绑定工作流不存在或未发布"); + } + assertSameTenant(skill, workflow.getTenantId(), "无权限绑定该工作流"); + resourceAccessService.assertAccess( + CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE, "无权限绑定该工作流"); + return workflow; + } + + /** {@inheritDoc} */ + @Override + public PluginItem requirePlugin(Skill skill, SkillToolBinding binding) { + BigInteger targetId = requireTargetId(binding); + PluginItem current = pluginItemService.getById(targetId); + if (current == null || current.getPluginId() == null) { + throw new BusinessException("绑定插件不存在"); + } + Plugin plugin = pluginMapper.selectOneByQuery(QueryWrapper.create() + .eq(Plugin::getId, current.getPluginId()) + .forUpdate()); + PluginItem item = pluginItemService.getOne(QueryWrapper.create() + .eq(PluginItem::getId, targetId) + .forUpdate()); + if (plugin == null || item == null || !Objects.equals(plugin.getId(), item.getPluginId())) { + throw new BusinessException("绑定插件不存在"); + } + if (!Integer.valueOf(1).equals(item.getStatus())) { + throw new BusinessException("绑定插件未启用"); + } + assertSameTenant(skill, plugin.getTenantId(), "无权限绑定该插件"); + pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), plugin.getId(), "无权限绑定该插件"); + return item; + } + + /** {@inheritDoc} */ + @Override + public McpResource requireMcp(Skill skill, SkillToolBinding binding) { + mcpAccessPermissionChecker.assertCanUseMcp(); + BigInteger targetId = requireTargetId(binding); + Mcp mcp = mcpService.getOne(QueryWrapper.create() + .eq(Mcp::getId, targetId) + .forUpdate()); + if (mcp == null || !Boolean.TRUE.equals(mcp.getStatus())) { + throw new BusinessException("绑定 MCP 不存在或未启用"); + } + assertSameTenant(skill, mcp.getTenantId(), "无权限绑定该 MCP"); + McpSpec spec = mcpRuntimeSpecFactory.build(mcp, true); + McpClientWrapper client = null; + List tools; + try { + client = new McpClientFactory().create(spec); + // AgentScope checks initialization when listTools() is invoked, so the two remote + // operations must be sequenced at invocation time instead of eagerly assembling both. + client.initialize().block(); + tools = client.listTools().block(); + } catch (RuntimeException exception) { + LOGGER.error("读取 MCP Tool 清单失败,mcpId={}", targetId, exception); + throw new BusinessException(503, 503, "MCP 当前不可用,请稍后重试"); + } finally { + if (client != null) { + try { + client.close(); + } catch (RuntimeException ignored) { + // discovery client 无状态且不复用;关闭失败不覆盖真实的读取结果或连接异常。 + } + } + } + if (tools == null || tools.isEmpty()) { + throw new BusinessException(409, 4092, "MCP 未提供可绑定的 Tool"); + } + List manifest = McpToolManifest.fromTools(tools); + if (manifest.isEmpty()) { + throw new BusinessException(409, 4092, "MCP 未提供有效的 Tool 定义"); + } + return new McpResource(mcp, manifest, McpToolManifest.hash(manifest)); + } + + /** {@inheritDoc} */ + @Override + public Map snapshotWorkflow(Workflow workflow) { + return agentWorkflowSnapshotFactory.snapshot(workflow); + } + + /** {@inheritDoc} */ + @Override + public Map snapshotPlugin(PluginItem pluginItem) { + if (pluginItem == null || pluginItem.getPluginId() == null) { + throw new BusinessException("插件资源不能为空"); + } + Plugin plugin = pluginMapper.selectOneById(pluginItem.getPluginId()); + if (plugin == null) { + throw new BusinessException("绑定插件不存在"); + } + Map snapshot = new java.util.LinkedHashMap<>(); + snapshot.put("pluginItem", objectMapper.convertValue(pluginItem, MAP_TYPE)); + // 父插件持有基础地址、请求头和鉴权配置,必须与子工具一起冻结,避免旧 Agent 热读新配置。 + snapshot.put("plugin", pluginConnectionSnapshotFactory.snapshot(plugin)); + return snapshot; + } + + /** {@inheritDoc} */ + @Override + public Map snapshotMcpConnection(Mcp mcp) { + return mcpConnectionSnapshotFactory.snapshot(mcp); + } + + /** {@inheritDoc} */ + @Override + public Map currentSummary(SkillToolBinding binding) { + SkillToolType type = SkillToolType.from(binding == null ? null : binding.getToolType()); + Map summary = new java.util.LinkedHashMap<>(); + summary.put("toolType", type.name()); + summary.put("targetId", binding.getTargetId()); + summary.put("hitlEnabled", Boolean.TRUE.equals(binding.getHitlEnabled())); + if (type == SkillToolType.WORKFLOW) { + Workflow workflow = workflowService.getById(binding.getTargetId()); + summary.put("title", workflow == null ? "已失效工作流" : workflow.getTitle()); + summary.put("description", workflow == null ? null : workflow.getDescription()); + summary.put("toolCount", 1); + summary.put("available", workflow != null + && PublishStatus.from(workflow.getPublishStatus()) == PublishStatus.PUBLISHED); + return summary; + } + if (type == SkillToolType.PLUGIN) { + PluginItem plugin = pluginItemService.getById(binding.getTargetId()); + summary.put("title", plugin == null ? "已失效插件" : plugin.getName()); + summary.put("description", plugin == null ? null : plugin.getDescription()); + summary.put("toolCount", 1); + summary.put("available", plugin != null && Integer.valueOf(1).equals(plugin.getStatus())); + return summary; + } + Mcp mcp = mcpService.getById(binding.getTargetId()); + summary.put("title", mcp == null ? "已失效 MCP" : mcp.getTitle()); + summary.put("description", mcp == null ? null : mcp.getDescription()); + summary.put("toolCount", binding.getMcpToolCount() == null ? 0 : binding.getMcpToolCount()); + summary.put("approvalRequired", mcp != null && Boolean.TRUE.equals(mcp.getApprovalRequired())); + summary.put("available", mcp != null && Boolean.TRUE.equals(mcp.getStatus())); + return summary; + } + + /** + * 读取绑定目标 ID。 + * + * @param binding Tool 绑定 + * @return 目标 ID + * @throws BusinessException 目标为空时抛出 + */ + private BigInteger requireTargetId(SkillToolBinding binding) { + if (binding == null || binding.getTargetId() == null) { + throw new BusinessException("Skill 工具目标不能为空"); + } + return binding.getTargetId(); + } + + /** + * 校验 Tool 与 Skill 属于同一租户。 + * + * @param skill Skill + * @param resourceTenantId 资源租户 ID + * @param message 拒绝消息 + */ + private void assertSameTenant(Skill skill, Object resourceTenantId, String message) { + if (skill == null || skill.getTenantId() == null || resourceTenantId == null + || !skill.getTenantId().toString().equals(String.valueOf(resourceTenantId))) { + throw new BusinessException(message); + } + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/vo/SkillMcpToolManifestView.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/vo/SkillMcpToolManifestView.java new file mode 100644 index 00000000..4f6361d4 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/vo/SkillMcpToolManifestView.java @@ -0,0 +1,23 @@ +package tech.easyflow.skill.vo; + +import java.util.List; + +/** + * Skill Studio MCP Tool 脱敏清单。 + * + * @param manifestHash 规范化清单 hash + * @param toolCount Tool 数 + * @param tools Tool 摘要 + */ +public record SkillMcpToolManifestView(String manifestHash, int toolCount, List tools) { + + /** + * MCP Tool 最小展示项。 + * + * @param name 名称 + * @param description 描述 + * @param inputSchema 输入 Schema + * @param outputSchema 输出 Schema + */ + public record Tool(String name, String description, Object inputSchema, Object outputSchema) { } +} diff --git a/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/vo/SkillToolOptionPage.java b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/vo/SkillToolOptionPage.java new file mode 100644 index 00000000..efc9ec15 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/main/java/tech/easyflow/skill/vo/SkillToolOptionPage.java @@ -0,0 +1,29 @@ +package tech.easyflow.skill.vo; + +import java.math.BigInteger; +import java.util.List; + +/** + * Skill Studio Tool 候选分页。 + * + * @param records 候选项 + * @param total 总数 + * @param pageNum 页码 + * @param pageSize 每页数量 + */ +public record SkillToolOptionPage(List records, long total, long pageNum, long pageSize) { + + /** + * 最小 Tool 候选。 + * + * @param toolType 类型 + * @param targetId 目标 ID + * @param title 名称 + * @param description 描述 + * @param available 是否可用 + * @param approvalRequired MCP 是否强制确认 + * @param knownToolCount 已知 Tool 数,可为空 + */ + public record Item(String toolType, BigInteger targetId, String title, String description, + boolean available, boolean approvalRequired, Integer knownToolCount) { } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandlerContentReferenceTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandlerContentReferenceTest.java index afded2d2..9a12d599 100644 --- a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandlerContentReferenceTest.java +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/publish/SkillApprovalSubjectHandlerContentReferenceTest.java @@ -69,8 +69,10 @@ public class SkillApprovalSubjectHandlerContentReferenceTest { saToken = mockStatic(SaTokenUtil.class); saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account); when(skillMapper.updateApprovalState(any(), any(), any(), any())).thenReturn(1); - when(skillMapper.publish(any(), any(), any(), any(), any(), any())).thenReturn(1); - when(skillMapper.publishApproved(any(), any(), any(), any(), any(), any(), any())).thenReturn(1); + when(skillMapper.publish(any(), any(), any(), any(), any(), any(), any())).thenReturn(1); + when(skillMapper.publishApproved(any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(1); + when(skillService.extractContentSnapshot(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(skillService.extractToolBindingsSnapshot(any())).thenReturn(Map.of()); when(skillMapper.markOfflineApproved(any(), any(), any())).thenReturn(1); when(skillMapper.restoreApprovalState(any(), any(), any(), any())).thenReturn(1); handler = new SkillApprovalSubjectHandler( @@ -124,7 +126,7 @@ public class SkillApprovalSubjectHandlerContentReferenceTest { ApprovalActionType.PUBLISH.getCode(), SKILL_ID, candidate, OPERATOR_ID); verify(skillMapper).publish( - eq(SKILL_ID), eq(BigInteger.ONE), same(candidate), any(Date.class), + eq(SKILL_ID), eq(BigInteger.ONE), same(candidate), eq(Map.of()), any(Date.class), eq(OPERATOR_ID), isNull()); verify(skillService).releaseSnapshotContents(previous); verify(skillService, never()).releaseSnapshotContents(candidate); @@ -228,6 +230,7 @@ public class SkillApprovalSubjectHandlerContentReferenceTest { eq(BigInteger.ONE), eq(instanceId), same(candidate), + eq(Map.of()), any(Date.class), eq(OPERATOR_ID), eq("candidate-hash")); @@ -253,7 +256,7 @@ public class SkillApprovalSubjectHandlerContentReferenceTest { BigInteger.valueOf(99))); assertEquals(409, exception.getHttpStatus()); - verify(skillMapper, never()).publishApproved(any(), any(), any(), any(), any(), any(), any()); + verify(skillMapper, never()).publishApproved(any(), any(), any(), any(), any(), any(), any(), any()); } /** @@ -273,7 +276,7 @@ public class SkillApprovalSubjectHandlerContentReferenceTest { handler.applyApprovedAction( ApprovalActionType.PUBLISH.getCode(), SKILL_ID, candidate, OPERATOR_ID, instanceId); - verify(skillMapper, never()).publishApproved(any(), any(), any(), any(), any(), any(), any()); + verify(skillMapper, never()).publishApproved(any(), any(), any(), any(), any(), any(), any(), any()); verify(skillService, never()).releaseSnapshotContents(any()); } diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/SkillToolOptionQueryServiceTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/SkillToolOptionQueryServiceTest.java new file mode 100644 index 00000000..b1df8ffb --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/SkillToolOptionQueryServiceTest.java @@ -0,0 +1,128 @@ +package tech.easyflow.skill.service; + +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.MockedStatic; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.mapper.PluginMapper; +import tech.easyflow.ai.permission.McpAccessPermissionChecker; +import tech.easyflow.ai.service.McpService; +import tech.easyflow.ai.service.PluginItemService; +import tech.easyflow.ai.service.PluginVisibilityService; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.vo.SkillToolOptionPage; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Skill Tool 候选权限边界测试。 + */ +public class SkillToolOptionQueryServiceTest { + + /** + * 验证聚合查询在缺少 MCP 权限时仍返回其他已授权候选。 + */ + @Test + public void allShouldOmitMcpWithoutBlockingOtherCandidates() { + Dependencies dependencies = new Dependencies(); + LoginAccount account = account(); + Workflow workflow = new Workflow(); + workflow.setId(BigInteger.TEN); + workflow.setTenantId(account.getTenantId()); + workflow.setTitle("合同审批"); + workflow.setDescription("审批合同"); + workflow.setPublishStatus(PublishStatus.PUBLISHED.getCode()); + // tb_workflow.status 是历史字段,线上可用性以发布状态为准。 + workflow.setStatus(0); + when(dependencies.workflowService.list(any(QueryWrapper.class))).thenReturn(List.of(workflow)); + when(dependencies.resourceAccessService.canAccess(any(), any(), any())).thenReturn(true); + when(dependencies.pluginMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of()); + when(dependencies.mcpAccessPermissionChecker.canUseMcp()).thenReturn(false); + + try (MockedStatic login = mockStatic(SaTokenUtil.class)) { + login.when(SaTokenUtil::getLoginAccount).thenReturn(account); + + SkillToolOptionPage result = dependencies.service().page(null, "ALL", 1, 20); + + Assert.assertEquals(1L, result.total()); + Assert.assertEquals("WORKFLOW", result.records().get(0).toolType()); + verify(dependencies.mcpService, never()).list(any(QueryWrapper.class)); + } + } + + /** + * 验证显式查询 MCP 时仍严格要求 MCP 权限。 + */ + @Test + public void explicitMcpShouldRejectMissingPermission() { + Dependencies dependencies = new Dependencies(); + doThrow(new BusinessException(403, 403, "无权限查询或使用 MCP")) + .when(dependencies.mcpAccessPermissionChecker).assertCanUseMcp(); + + try (MockedStatic login = mockStatic(SaTokenUtil.class)) { + login.when(SaTokenUtil::getLoginAccount).thenReturn(account()); + try { + dependencies.service().page(null, "MCP", 1, 20); + Assert.fail("显式 MCP 查询必须校验权限"); + } catch (BusinessException exception) { + Assert.assertEquals(403, exception.getHttpStatus()); + } + } + } + + private LoginAccount account() { + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.ONE); + account.setTenantId(BigInteger.valueOf(42)); + return account; + } + + /** + * 查询服务依赖夹具。 + */ + private static final class Dependencies { + + private final WorkflowService workflowService = mock(WorkflowService.class); + private final PluginItemService pluginItemService = mock(PluginItemService.class); + private final PluginMapper pluginMapper = mock(PluginMapper.class); + private final PluginVisibilityService pluginVisibilityService = mock(PluginVisibilityService.class); + private final McpService mcpService = mock(McpService.class); + private final McpAccessPermissionChecker mcpAccessPermissionChecker = + mock(McpAccessPermissionChecker.class); + private final SkillToolResourceService resourceService = mock(SkillToolResourceService.class); + private final ResourceAccessService resourceAccessService = mock(ResourceAccessService.class); + + /** + * 创建待测服务。 + * + * @return 待测服务 + */ + private SkillToolOptionQueryService service() { + return new SkillToolOptionQueryService( + workflowService, + pluginItemService, + pluginMapper, + pluginVisibilityService, + mcpService, + mcpAccessPermissionChecker, + resourceService, + resourceAccessService + ); + } + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplSnapshotHashTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplSnapshotHashTest.java new file mode 100644 index 00000000..be729bd4 --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillServiceImplSnapshotHashTest.java @@ -0,0 +1,108 @@ +package tech.easyflow.skill.service.impl; + +import com.easyagents.agent.runtime.mcp.McpToolManifestEntry; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; +import org.springframework.beans.factory.ObjectProvider; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.service.SkillCategoryService; +import tech.easyflow.skill.service.SkillReferenceProvider; +import tech.easyflow.skill.service.SkillResourceService; +import tech.easyflow.skill.service.SkillToolBindingService; +import tech.easyflow.skill.store.DBSkillContentStore; +import tech.easyflow.system.service.CategoryPermissionService; +import tech.easyflow.system.service.ResourceAccessService; + +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.Mockito.mock; + +/** + * Skill 组合发布快照 hash 测试。 + */ +public class SkillServiceImplSnapshotHashTest { + + /** + * 含 MCP Manifest POJO 的组合快照经过 JSON 持久化后仍应通过校验。 + * + * @throws Exception JSON 或反射调用失败时抛出 + */ + @Test + @SuppressWarnings("unchecked") + public void shouldVerifyAggregateSnapshotAfterJsonRoundTrip() throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + SkillToolBindingService toolBindingService = mock(SkillToolBindingService.class); + SkillServiceImpl service = service(objectMapper, toolBindingService); + + Map contentSnapshot = new LinkedHashMap<>(); + contentSnapshot.put("schemaVersion", 2); + contentSnapshot.put("name", "l21-mcp-docs"); + contentSnapshot.put("snapshotHash", hash(service, contentSnapshot)); + + Map binding = new LinkedHashMap<>(); + binding.put("toolType", "MCP"); + McpToolManifestEntry manifestEntry = new McpToolManifestEntry(); + manifestEntry.setName("query-docs"); + manifestEntry.setDescription("查询文档"); + manifestEntry.setInputSchema(Map.of("type", "object")); + manifestEntry.setOutputSchema(Map.of()); + binding.put("mcpToolManifest", List.of(manifestEntry)); + Map toolSnapshot = new LinkedHashMap<>(); + toolSnapshot.put("schemaVersion", 1); + toolSnapshot.put("bindings", List.of(binding)); + toolSnapshot.put("snapshotHash", "verified-by-tool-service"); + + Map aggregate = new LinkedHashMap<>(contentSnapshot); + Object contentHash = aggregate.remove("snapshotHash"); + aggregate.put("contentSnapshotHash", contentHash); + aggregate.put("platformToolBindings", toolSnapshot); + aggregate.put("toolBindingsHash", toolSnapshot.get("snapshotHash")); + + Skill persistedSkill = new Skill(); + persistedSkill.setPublishedSnapshotJson(objectMapper.readValue( + objectMapper.writeValueAsBytes(contentSnapshot), Map.class)); + persistedSkill.setPublishedToolBindingsJson(objectMapper.readValue( + objectMapper.writeValueAsBytes(toolSnapshot), Map.class)); + persistedSkill.setSnapshotHash(hash(service, aggregate)); + + service.assertPublishedAggregateHash(persistedSkill); + } + + /** + * 创建仅用于快照校验的服务。 + * + * @param objectMapper JSON 映射器 + * @param toolBindingService Tool 快照服务 + * @return Skill 服务 + */ + @SuppressWarnings("unchecked") + private SkillServiceImpl service(ObjectMapper objectMapper, + SkillToolBindingService toolBindingService) { + return new SkillServiceImpl( + mock(SkillCategoryService.class), + mock(SkillResourceService.class), + toolBindingService, + mock(DBSkillContentStore.class), + mock(ResourceAccessService.class), + mock(CategoryPermissionService.class), + objectMapper, + mock(ObjectProvider.class)); + } + + /** + * 调用生产代码的统一快照 hash 算法。 + * + * @param service Skill 服务 + * @param value 待计算结构 + * @return SHA-256 hash + * @throws Exception 反射调用失败时抛出 + */ + private String hash(SkillServiceImpl service, Object value) throws Exception { + Method method = SkillServiceImpl.class.getDeclaredMethod("hashJson", Object.class); + method.setAccessible(true); + return (String) method.invoke(service, value); + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillToolBindingServiceImplTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillToolBindingServiceImplTest.java new file mode 100644 index 00000000..1abe645e --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillToolBindingServiceImplTest.java @@ -0,0 +1,200 @@ +package tech.easyflow.skill.service.impl; + +import com.easyagents.agent.runtime.mcp.McpToolManifestEntry; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.ai.entity.Mcp; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillToolBinding; +import tech.easyflow.skill.mapper.SkillMapper; +import tech.easyflow.skill.service.SkillToolResourceService; +import tech.easyflow.system.service.ResourceAccessService; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Skill Tool 绑定发布快照测试。 + */ +public class SkillToolBindingServiceImplTest { + + /** + * 发布快照应包含资源冻结数据,并拒绝任何后续篡改。 + */ + @Test + public void shouldBuildAndVerifyFrozenToolSnapshot() { + SkillToolResourceService resources = mock(SkillToolResourceService.class); + SkillToolBinding workflowBinding = binding("WORKFLOW", 100, null); + Workflow workflow = new Workflow(); + workflow.setId(BigInteger.valueOf(100)); + workflow.setTitle("合同审查流程"); + when(resources.requireWorkflow(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.eq(workflowBinding))).thenReturn(workflow); + when(resources.snapshotWorkflow(workflow)).thenReturn(Map.of( + "id", BigInteger.valueOf(100), + "content", "{\"nodes\":[]}")); + SkillToolBindingServiceImpl service = service(resources, List.of(workflowBinding)); + + Map snapshot = service.buildPublishSnapshot(skill()); + + service.assertPublishedSnapshotHash(snapshot); + verify(resources).requireWorkflow(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.eq(workflowBinding)); + Map tampered = new LinkedHashMap<>(snapshot); + tampered.put("schemaVersion", 2); + Assert.assertThrows(BusinessException.class, + () -> service.assertPublishedSnapshotHash(tampered)); + } + + /** + * 发布快照经过数据库 JSON 持久化后仍应保持同一 hash。 + * + * @throws Exception JSON 往返失败时抛出 + */ + @Test + @SuppressWarnings("unchecked") + public void shouldVerifyMcpSnapshotAfterJsonRoundTrip() throws Exception { + SkillToolResourceService resources = mock(SkillToolResourceService.class); + SkillToolBinding mcpBinding = binding("MCP", 200, "manifest-hash"); + Mcp mcp = mcp(); + when(resources.requireMcp(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.eq(mcpBinding))).thenReturn(new SkillToolResourceService.McpResource( + mcp, List.of(manifest("search")), "manifest-hash")); + when(resources.snapshotMcpConnection(mcp)).thenReturn(Map.of( + "id", mcp.getId(), + "configJson", "{\"mcpServers\":{}}")); + SkillToolBindingServiceImpl service = service(resources, List.of(mcpBinding)); + ObjectMapper mapper = new ObjectMapper(); + + Map snapshot = service.buildPublishSnapshot(skill()); + Map persisted = mapper.readValue( + mapper.writeValueAsBytes(snapshot), Map.class); + + service.assertPublishedSnapshotHash(persisted); + } + + /** + * MCP 清单变化后发布必须失败,要求用户重新保存并确认绑定。 + */ + @Test + public void shouldRejectChangedMcpManifestAtPublish() { + SkillToolResourceService resources = mock(SkillToolResourceService.class); + SkillToolBinding mcpBinding = binding("MCP", 200, "old-hash"); + when(resources.requireMcp(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.eq(mcpBinding))).thenReturn(new SkillToolResourceService.McpResource( + mcp(), List.of(manifest("search")), "new-hash")); + SkillToolBindingServiceImpl service = service(resources, List.of(mcpBinding)); + + BusinessException exception = Assert.assertThrows(BusinessException.class, + () -> service.buildPublishSnapshot(skill())); + + Assert.assertTrue(exception.getMessage().contains("清单已变化")); + } + + /** + * 单个 Skill 展开后的实际 MCP Tool 数量不得超过二十个。 + */ + @Test + public void shouldRejectMoreThanTwentyExpandedTools() { + SkillToolResourceService resources = mock(SkillToolResourceService.class); + SkillToolBinding mcpBinding = binding("MCP", 200, "manifest-hash"); + List manifest = new ArrayList<>(); + for (int index = 1; index <= 21; index++) { + manifest.add(manifest("tool-" + index)); + } + when(resources.requireMcp(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.eq(mcpBinding))).thenReturn(new SkillToolResourceService.McpResource( + mcp(), manifest, "manifest-hash")); + SkillToolBindingServiceImpl service = service(resources, List.of(mcpBinding)); + + BusinessException exception = Assert.assertThrows(BusinessException.class, + () -> service.buildPublishSnapshot(skill())); + + Assert.assertTrue(exception.getMessage().contains("20")); + verify(resources).requireMcp(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.eq(mcpBinding)); + } + + /** + * 创建可注入固定绑定列表的服务。 + * + * @param resources Tool 资源服务 + * @param bindings 固定绑定 + * @return 测试服务 + */ + private SkillToolBindingServiceImpl service(SkillToolResourceService resources, + List bindings) { + return new SkillToolBindingServiceImpl( + mock(SkillMapper.class), resources, mock(ResourceAccessService.class), new ObjectMapper()) { + @Override + public List listBindings(BigInteger skillId) { + return bindings; + } + }; + } + + /** + * 创建测试 Skill。 + * + * @return Skill + */ + private Skill skill() { + Skill skill = new Skill(); + skill.setId(BigInteger.ONE); + skill.setTenantId(BigInteger.ONE); + return skill; + } + + /** + * 创建 Tool 绑定。 + * + * @param type 类型 + * @param targetId 目标 ID + * @param manifestHash MCP 清单 hash + * @return 绑定 + */ + private SkillToolBinding binding(String type, long targetId, String manifestHash) { + SkillToolBinding binding = new SkillToolBinding(); + binding.setToolType(type); + binding.setTargetId(BigInteger.valueOf(targetId)); + binding.setMcpToolManifestHash(manifestHash); + binding.setSortNo(0); + return binding; + } + + /** + * 创建测试 MCP。 + * + * @return MCP + */ + private Mcp mcp() { + Mcp mcp = new Mcp(); + mcp.setId(BigInteger.valueOf(200)); + mcp.setTitle("测试 MCP"); + return mcp; + } + + /** + * 创建最小 MCP Tool 清单项。 + * + * @param name Tool 名称 + * @return 清单项 + */ + private McpToolManifestEntry manifest(String name) { + McpToolManifestEntry entry = new McpToolManifestEntry(); + entry.setName(name); + entry.setDescription("测试工具"); + entry.setInputSchema(Map.of("type", "object")); + return entry; + } +} diff --git a/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillToolReferenceProviderImplTest.java b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillToolReferenceProviderImplTest.java new file mode 100644 index 00000000..1d2056ae --- /dev/null +++ b/easyflow-modules/easyflow-module-skill/src/test/java/tech/easyflow/skill/service/impl/SkillToolReferenceProviderImplTest.java @@ -0,0 +1,92 @@ +package tech.easyflow.skill.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.skill.entity.Skill; +import tech.easyflow.skill.entity.SkillToolBinding; +import tech.easyflow.skill.service.SkillService; +import tech.easyflow.skill.service.SkillToolBindingService; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; + +/** + * Skill 对平台 Tool 的生命周期引用查询测试。 + */ +public class SkillToolReferenceProviderImplTest { + + /** + * 草稿绑定和有效发布快照都应参与 Workflow 下线与删除影响检查。 + */ + @Test + public void shouldIncludeDraftAndPublishedWorkflowReferences() { + SkillService skillService = Mockito.mock(SkillService.class); + SkillToolBindingService bindingService = Mockito.mock(SkillToolBindingService.class); + SkillToolBinding draftBinding = new SkillToolBinding(); + draftBinding.setSkillId(BigInteger.ONE); + draftBinding.setToolType("WORKFLOW"); + draftBinding.setTargetId(BigInteger.TEN); + Skill publishedProjection = skill(BigInteger.TWO, "线上 Skill"); + publishedProjection.setPublishStatus(PublishStatus.PUBLISHED.getCode()); + publishedProjection.setPublishedToolBindingsJson(Map.of( + "bindings", List.of(Map.of( + "toolType", "WORKFLOW", + "targetId", BigInteger.TEN)))); + Mockito.when(bindingService.list(Mockito.any(QueryWrapper.class))) + .thenReturn(List.of(draftBinding)); + Mockito.when(skillService.list(Mockito.any(QueryWrapper.class))) + .thenReturn(List.of(publishedProjection)); + Mockito.when(skillService.listByIds(Mockito.anyCollection())) + .thenReturn(List.of( + skill(BigInteger.ONE, "草稿 Skill"), + skill(BigInteger.TWO, "线上 Skill"))); + SkillToolReferenceProviderImpl provider = new SkillToolReferenceProviderImpl( + skillService, bindingService); + + var references = provider.listSkillsByWorkflowId(BigInteger.TEN); + + Assert.assertEquals(2, references.size()); + Assert.assertEquals("Skill“草稿 Skill”", references.get(0).getTitle()); + Assert.assertEquals("Skill“线上 Skill”", references.get(1).getTitle()); + } + + /** + * 已下线发布快照不应继续阻止 Tool 生命周期操作。 + */ + @Test + public void shouldIgnoreOfflinePublishedSnapshot() { + SkillService skillService = Mockito.mock(SkillService.class); + SkillToolBindingService bindingService = Mockito.mock(SkillToolBindingService.class); + Skill offline = skill(BigInteger.ONE, "已下线 Skill"); + offline.setPublishStatus(PublishStatus.OFFLINE.getCode()); + offline.setPublishedToolBindingsJson(Map.of( + "bindings", List.of(Map.of( + "toolType", "MCP", + "targetId", BigInteger.TEN)))); + Mockito.when(bindingService.list(Mockito.any(QueryWrapper.class))).thenReturn(List.of()); + Mockito.when(skillService.list(Mockito.any(QueryWrapper.class))).thenReturn(List.of(offline)); + SkillToolReferenceProviderImpl provider = new SkillToolReferenceProviderImpl( + skillService, bindingService); + + Assert.assertTrue(provider.listSkillsByMcpId(BigInteger.TEN).isEmpty()); + } + + /** + * 创建 Skill 摘要。 + * + * @param id Skill ID + * @param displayName 展示名 + * @return Skill + */ + private Skill skill(BigInteger id, String displayName) { + Skill skill = new Skill(); + skill.setId(id); + skill.setName("skill-" + id); + skill.setDisplayName(displayName); + return skill; + } +} diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml b/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml index 00d3bf26..530f5e91 100644 --- a/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml @@ -170,6 +170,20 @@ easyflow: app-key: xxx voice: siyue agent: + workspace: + root: ./agent-workspaces + max-total-size: 512MB + max-single-file-size: 100MB + max-file-count: 2000 + max-read-size: 2MB + retention: 24h + cleanup-interval: 30m + shell: + default-timeout: 60s + max-timeout: 300s + max-command-length: 4096 + max-output-size: 1MB + max-concurrent-per-instance: 2 runtime: instance-id: ${EASYFLOW_INSTANCE_ID:${HOSTNAME:${random.uuid}}} route-ttl: 24h @@ -264,6 +278,13 @@ dromara: end-point: http://127.0.0.1:39000 bucket-name: easyflow-agent-media base-path: agent-chat + - platform: minio-agent-artifacts + enable-storage: true + access-key: easyflowadmin + secret-key: easyflowadmin123 + end-point: http://127.0.0.1:39000 + bucket-name: easyflow-agent-artifacts + base-path: published # easy-agents 文档解析统一配置 easy-agents: diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V57__mysql_agent_artifact.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V57__mysql_agent_artifact.sql new file mode 100644 index 00000000..f705bf48 --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V57__mysql_agent_artifact.sql @@ -0,0 +1,37 @@ +CREATE TABLE `tb_agent_artifact` ( + `id` BIGINT NOT NULL COMMENT '内部主键', + `artifact_id` VARCHAR(64) NOT NULL COMMENT '对外稳定产物ID', + `tenant_id` BIGINT NOT NULL COMMENT '租户ID', + `agent_id` BIGINT NOT NULL COMMENT 'Agent ID', + `owner_user_id` BIGINT NOT NULL COMMENT '创建及下载归属用户', + `chat_mode` VARCHAR(16) NOT NULL COMMENT 'DRAFT或FORMAL', + `chat_session_id` BIGINT NULL COMMENT '正式聊天会话ID', + `runtime_session_id` VARCHAR(128) NOT NULL COMMENT 'Runtime会话标识', + `request_id` VARCHAR(128) NOT NULL COMMENT '运行请求标识', + `round_id` BIGINT NULL COMMENT '正式聊天轮次ID', + `variant_index` INT NULL COMMENT '正式聊天答案版本序号', + `tool_call_id` VARCHAR(128) NOT NULL COMMENT 'Artifact工具调用ID', + `file_name` VARCHAR(255) NOT NULL COMMENT '安全展示文件名', + `mime_type` VARCHAR(128) NOT NULL COMMENT '服务端识别MIME', + `size_bytes` BIGINT NOT NULL COMMENT '实际字节数', + `sha256` CHAR(64) NULL COMMENT '内容SHA-256', + `storage_platform` VARCHAR(64) NOT NULL COMMENT '内部存储平台', + `object_key` VARCHAR(1024) NOT NULL COMMENT '内部对象定位', + `storage_etag` VARCHAR(255) NULL COMMENT '对象ETag', + `status` VARCHAR(32) NOT NULL COMMENT '产物状态', + `expires_at` DATETIME NULL COMMENT '草稿产物过期时间', + `retry_count` INT NOT NULL DEFAULT 0 COMMENT '清理重试次数', + `next_retry_at` DATETIME NULL COMMENT '下次补偿时间', + `last_error_code` VARCHAR(64) NULL COMMENT '脱敏错误码', + `created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `created_by` BIGINT NULL COMMENT '创建人', + `modified` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + `modified_by` BIGINT NULL COMMENT '修改人', + `is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_agent_artifact_tenant_public_id` (`tenant_id`, `artifact_id`), + KEY `idx_agent_artifact_session_status` (`tenant_id`, `chat_session_id`, `status`, `id`), + KEY `idx_agent_artifact_draft_owner` (`tenant_id`, `owner_user_id`, `runtime_session_id`, `status`, `id`), + KEY `idx_agent_artifact_cleanup` (`status`, `expires_at`, `next_retry_at`, `id`), + KEY `idx_agent_artifact_retry` (`status`, `next_retry_at`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Agent产物状态账本'; diff --git a/easyflow-ui-admin/app/package.json b/easyflow-ui-admin/app/package.json index 9391616a..6a760945 100644 --- a/easyflow-ui-admin/app/package.json +++ b/easyflow-ui-admin/app/package.json @@ -13,6 +13,7 @@ "#/*": "./src/*" }, "dependencies": { + "@ag-ui/client": "0.0.57", "@codemirror/commands": "^6.10.2", "@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-python": "^6.2.1", diff --git a/easyflow-ui-admin/app/src/api/request.ts b/easyflow-ui-admin/app/src/api/request.ts index 1a2e58c4..8265b031 100644 --- a/easyflow-ui-admin/app/src/api/request.ts +++ b/easyflow-ui-admin/app/src/api/request.ts @@ -166,6 +166,32 @@ export interface SseOptions { onError?: (err: any) => void; onFinished?: () => void; } + +export function resolveApiUrl(url: string) { + return apiURL + url; +} + +export function createEventStreamHeaders( + requestUrl: string, + extraHeaders?: HeadersInit, +) { + const accessStore = useAccessStore(); + const headers: Record = { + Accept: 'text/event-stream', + 'Content-Type': 'application/json', + 'easyflow-token': accessStore.accessToken || '', + }; + if (extraHeaders) { + new Headers(extraHeaders).forEach((value, key) => { + headers[key] = value; + }); + } + return withWorkflowShareHeader(headers, { + requestMethod: 'POST', + requestUrl, + }); +} + export class SseClient { private controller: AbortController | null = null; private currentRequestId = 0; @@ -270,21 +296,7 @@ export class SseClient { } private getHeaders(requestUrl: string, extraHeaders?: HeadersInit) { - const accessStore = useAccessStore(); - const headers: Record = { - Accept: 'text/event-stream', - 'Content-Type': 'application/json', - 'easyflow-token': accessStore.accessToken || '', - }; - if (extraHeaders) { - new Headers(extraHeaders).forEach((value, key) => { - headers[key] = value; - }); - } - return withWorkflowShareHeader(headers, { - requestMethod: 'POST', - requestUrl, - }); + return createEventStreamHeaders(requestUrl, extraHeaders); } } diff --git a/easyflow-ui-admin/app/src/components/ai-chat/AiMessage.vue b/easyflow-ui-admin/app/src/components/ai-chat/AiMessage.vue index e500ffcf..4dec3b6b 100644 --- a/easyflow-ui-admin/app/src/components/ai-chat/AiMessage.vue +++ b/easyflow-ui-admin/app/src/components/ai-chat/AiMessage.vue @@ -43,8 +43,7 @@ const emit = defineEmits<{ /> -import type {AiToolApprovalPayload} from './types'; +import type { AiToolApprovalPayload } from './types'; -import {Check, Close, Key} from '@element-plus/icons-vue'; -import {ElButton, ElIcon} from 'element-plus'; +import { Check, Close, Key } from '@element-plus/icons-vue'; +import { ElButton, ElIcon } from 'element-plus'; const props = defineProps(); @@ -13,8 +13,7 @@ const emit = defineEmits<{ function payload(): AiToolApprovalPayload { return { - requestId: props.requestId, - resumeToken: props.resumeToken, + approvalId: props.approvalId, toolName: props.toolName, toolDisplayName: props.toolDisplayName, toolCallId: props.toolCallId, diff --git a/easyflow-ui-admin/app/src/components/ai-chat/mediaApi.test.ts b/easyflow-ui-admin/app/src/components/ai-chat/mediaApi.test.ts index 8ebc915a..f3e838eb 100644 --- a/easyflow-ui-admin/app/src/components/ai-chat/mediaApi.test.ts +++ b/easyflow-ui-admin/app/src/components/ai-chat/mediaApi.test.ts @@ -1,8 +1,11 @@ -import type { ChatDocumentAttachment } from '@easyflow/common-ui'; +import type { + ChatArtifactAttachment, + ChatDocumentAttachment, +} from '@easyflow/common-ui'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { loadAgentChatDocument } from './mediaApi'; +import { createAgentArtifactLoader, loadAgentChatDocument } from './mediaApi'; const requestApi = vi.hoisted(() => ({ download: vi.fn(), @@ -19,6 +22,14 @@ const documentAttachment: ChatDocumentAttachment = { status: 'ready', }; +const artifactAttachment: ChatArtifactAttachment = { + artifactId: '01JARTIFACT', + downloadUrl: + 'https://evil.example/report?agentId=999&mode=DRAFT&runtimeSessionId=forged', + fileName: '结果.txt', + status: 'available', +}; + describe('agent chat document download', () => { beforeEach(() => { vi.useFakeTimers(); @@ -63,3 +74,68 @@ describe('agent chat document download', () => { expect(HTMLAnchorElement.prototype.click).not.toHaveBeenCalled(); }); }); + +describe('agent Artifact download', () => { + beforeEach(() => { + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:artifact'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation( + () => undefined, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + requestApi.download.mockReset(); + }); + + it('用正式聊天的可信上下文构建同源下载地址', async () => { + requestApi.download.mockResolvedValue(new Blob(['artifact'])); + const loader = createAgentArtifactLoader(() => ({ + agentId: '42', + mode: 'FORMAL', + sessionId: '10001', + })); + + await loader(artifactAttachment); + + expect(requestApi.download).toHaveBeenCalledWith( + '/api/v1/agent/artifacts/01JARTIFACT/content?agentId=42&mode=FORMAL&sessionId=10001', + ); + expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(1); + }); + + it('草稿模式只传 runtimeSessionId 且忽略事件伪造 URL', async () => { + requestApi.download.mockResolvedValue(new Blob(['artifact'])); + const loader = createAgentArtifactLoader(() => ({ + agentId: '43', + mode: 'DRAFT', + runtimeSessionId: 'agent-draft-43', + })); + + await loader(artifactAttachment); + + expect(requestApi.download).toHaveBeenCalledWith( + '/api/v1/agent/artifacts/01JARTIFACT/content?agentId=43&mode=DRAFT&runtimeSessionId=agent-draft-43', + ); + expect(requestApi.download.mock.calls[0]?.[0]).not.toContain('sessionId='); + expect(requestApi.download.mock.calls[0]?.[0]).not.toContain( + 'evil.example', + ); + }); + + it.each([ + undefined, + { agentId: '', mode: 'FORMAL' as const, sessionId: '10001' }, + { agentId: '42', mode: 'FORMAL' as const, sessionId: '' }, + { agentId: '42', mode: 'DRAFT' as const, runtimeSessionId: '' }, + ])('上下文缺失时拒绝请求: %o', async (context) => { + const loader = createAgentArtifactLoader(() => context); + + await expect(loader(artifactAttachment)).rejects.toThrow( + '产物下载上下文无效', + ); + + expect(requestApi.download).not.toHaveBeenCalled(); + }); +}); diff --git a/easyflow-ui-admin/app/src/components/ai-chat/mediaApi.ts b/easyflow-ui-admin/app/src/components/ai-chat/mediaApi.ts index d81ccdbb..6de169dc 100644 --- a/easyflow-ui-admin/app/src/components/ai-chat/mediaApi.ts +++ b/easyflow-ui-admin/app/src/components/ai-chat/mediaApi.ts @@ -1,14 +1,29 @@ import type { + ChatArtifactLoader, ChatDocumentAttachment, ChatImageAttachment, } from '@easyflow/common-ui'; +import { downloadFileFromBlob } from '@easyflow/utils'; + import { api } from '#/api/request'; const DOCUMENT_URL_REVOKE_DELAY_MS = 1000; export type AgentComposerMode = 'DRAFT' | 'FORMAL'; +export type AgentArtifactDownloadContext = + | { + agentId: string; + mode: 'DRAFT'; + runtimeSessionId: string; + } + | { + agentId: string; + mode: 'FORMAL'; + sessionId: string; + }; + export interface AgentMediaUpload extends ChatImageAttachment { expiresAt?: string; height: number; @@ -177,3 +192,50 @@ export async function loadAgentChatDocument(item: ChatDocumentAttachment) { }, DOCUMENT_URL_REVOKE_DELAY_MS); } } + +export function createAgentArtifactLoader( + resolveContext: () => AgentArtifactDownloadContext | undefined, +): ChatArtifactLoader { + return async (item) => { + const downloadUrl = buildAgentArtifactDownloadUrl( + item.artifactId, + resolveContext(), + ); + const blob = await api.download(downloadUrl); + if (!(blob instanceof Blob)) { + throw new TypeError('产物下载失败,请重试'); + } + downloadFileFromBlob({ fileName: item.fileName || '产物', source: blob }); + }; +} + +function buildAgentArtifactDownloadUrl( + artifactIdValue: string, + context: AgentArtifactDownloadContext | undefined, +) { + const artifactId = String(artifactIdValue || '').trim(); + if (!artifactId || !context) { + throw new TypeError('产物下载上下文无效'); + } + const agentId = String(context.agentId || '').trim(); + if (!/^[1-9]\d*$/.test(agentId)) { + throw new TypeError('产物下载上下文无效'); + } + + const params = new URLSearchParams({ agentId, mode: context.mode }); + if (context.mode === 'DRAFT') { + const runtimeSessionId = String(context.runtimeSessionId || '').trim(); + if (!runtimeSessionId) { + throw new TypeError('产物下载上下文无效'); + } + params.set('runtimeSessionId', runtimeSessionId); + } else { + const sessionId = String(context.sessionId || '').trim(); + if (!sessionId) { + throw new TypeError('产物下载上下文无效'); + } + params.set('sessionId', sessionId); + } + + return `/api/v1/agent/artifacts/${encodeURIComponent(artifactId)}/content?${params.toString()}`; +} diff --git a/easyflow-ui-admin/app/src/components/ai-chat/types.ts b/easyflow-ui-admin/app/src/components/ai-chat/types.ts index cac11e6f..405e4c9a 100644 --- a/easyflow-ui-admin/app/src/components/ai-chat/types.ts +++ b/easyflow-ui-admin/app/src/components/ai-chat/types.ts @@ -11,8 +11,7 @@ export interface AiKnowledgeHit { } export interface AiToolApprovalPayload { - requestId: string; - resumeToken: string; + approvalId: string; toolName: string; toolDisplayName?: string; toolCallId?: string; diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json b/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json index ac09f410..21284f08 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json @@ -97,10 +97,12 @@ "submitOfflineApprovalConfirm": "Take the current workflow offline?", "submitDeleteApprovalConfirm": "Delete the current workflow?", "offlineImpactBoundAgentsIntro": "This workflow is currently bound to the following agents:", - "offlineImpactBoundAgentsFooter": "After the workflow goes offline, the system will automatically remove it from these agents.", + "offlineImpactBoundAgentsFooter": "Remove this workflow from these agents first.", + "offlineImpactBoundSkillsIntro": "This workflow is currently bound to the following Skills:", "offlineImpactBoundPluginsIntro": "This workflow is currently bound to the following plugins:", - "offlineImpactBoundPluginsFooter": "After offline approval succeeds, these plugins will automatically become unavailable and show the reason in plugin management.", - "offlineImpactBoundMixedFooter": "After offline approval succeeds, the system will remove the workflow from agents and mark the related plugins as unavailable.", + "offlineImpactBoundPluginsFooter": "Update the plugins that reference this workflow first.", + "offlineImpactBoundMixedFooter": "Remove all references before taking this workflow offline.", + "offlineImpactBlockedFooter": "Remove the references above before taking this workflow offline.", "publishPendingHint": "There is already an approval in progress for this workflow.", "deletePendingHint": "There is already an approval in progress for this workflow.", "check": "Check", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json index 1be951d2..62aacaee 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json @@ -97,10 +97,12 @@ "submitOfflineApprovalConfirm": "确认下线当前工作流吗?", "submitDeleteApprovalConfirm": "确认删除当前工作流吗?", "offlineImpactBoundAgentsIntro": "当前工作流被以下智能体绑定:", - "offlineImpactBoundAgentsFooter": "下线成功后,系统会自动从这些智能体中解绑该工作流。", + "offlineImpactBoundAgentsFooter": "请先从这些智能体中移除该工作流。", + "offlineImpactBoundSkillsIntro": "当前工作流被以下 Skill 绑定:", "offlineImpactBoundPluginsIntro": "当前工作流被以下插件绑定:", - "offlineImpactBoundPluginsFooter": "下线审批通过后,这些插件会自动变为不可用,并在插件页展示对应原因。", - "offlineImpactBoundMixedFooter": "下线审批通过后,系统会自动从智能体中解绑该工作流,同时让相关插件进入不可用状态。", + "offlineImpactBoundPluginsFooter": "请先调整引用该工作流的插件。", + "offlineImpactBoundMixedFooter": "请先取消所有引用后再下线。", + "offlineImpactBlockedFooter": "请先取消以上引用后再下线。", "publishPendingHint": "当前工作流已有进行中的审批,请等待处理完成。", "deletePendingHint": "当前工作流已有进行中的审批,请等待处理完成。", "check": "检查", diff --git a/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.test.ts b/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.test.ts index 2e9c4184..f8416b21 100644 --- a/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.test.ts @@ -1,14 +1,132 @@ -import type { ChatTimelineMessageItem } from '@easyflow/common-ui'; +import type { + ChatTimelineMessageItem, + ChatTimelineStatusItem, +} from '@easyflow/common-ui'; import { describe, expect, it } from 'vitest'; -import { - applyAgentSseEnvelope, - parseAgentSseMessage, - recordsToTimelineItems, -} from './agentTimelineAdapter'; +import { recordsToTimelineItems } from './agentTimelineAdapter'; describe('agentTimelineAdapter', () => { + it('restores successful turn duration from persisted message timestamps', () => { + const items = recordsToTimelineItems([ + { + created: '2026-08-15T10:00:00Z', + id: 'timer-user', + roundId: 'round-timer', + senderRole: 'user', + contentText: '开始计时', + }, + { + created: '2026-08-15T10:00:18Z', + id: 'timer-assistant', + roundId: 'round-timer', + senderRole: 'assistant', + contentText: '计时完成', + contentPayload: { + agentResult: { text: '计时完成' }, + terminalStatus: 'COMPLETED', + }, + }, + ]); + + const turnItems = items.filter((item) => item.roundId === 'round-timer'); + expect(turnItems).not.toHaveLength(0); + expect( + turnItems.every( + (item) => + item.turnStartedAt === Date.parse('2026-08-15T10:00:00Z') && + item.turnFinishedAt === Date.parse('2026-08-15T10:00:18Z'), + ), + ).toBe(true); + }); + + it('restores available and expired Artifacts with the shared safe projection', () => { + const items = recordsToTimelineItems([ + { + id: 'artifact-history', + senderRole: 'assistant', + contentText: '报告已生成', + roundId: 'round-artifact-history', + contentPayload: { + agentResult: { text: '报告已生成' }, + artifacts: [ + { + artifactId: '01JAVAILABLE', + bucket: 'easyflow-agent-artifacts', + fileName: '报告.pdf', + objectKey: 'formal/private', + sha256: 'b'.repeat(64), + size: 4096, + status: 'AVAILABLE', + }, + { + artifactId: '01JEXPIRED', + fileName: '旧报告.xlsx', + status: 'EXPIRED', + }, + ], + }, + }, + ]); + + const artifacts = items.filter((item) => item.type === 'artifact'); + expect(artifacts).toHaveLength(2); + expect(artifacts).toEqual([ + expect.objectContaining({ + downloadUrl: '/api/v1/agent/artifacts/01JAVAILABLE/content', + status: 'available', + }), + expect.objectContaining({ + downloadUrl: undefined, + status: 'expired', + }), + ]); + expect(JSON.stringify(artifacts)).not.toMatch( + /easyflow-agent-artifacts|objectKey|formal\/private/, + ); + }); + + it('uses refreshed direct Artifact status instead of stale runtime events', () => { + const items = recordsToTimelineItems([ + { + id: 'artifact-status-conflict', + senderRole: 'assistant', + contentText: '产物已过期', + roundId: 'round-artifact-status-conflict', + contentPayload: { + artifacts: [ + { + artifactId: '01JSTATUSCONFLICT', + fileName: '账本报告.pdf', + status: 'EXPIRED', + }, + ], + runtimeEvents: [ + { + name: 'easyflow.artifact.published', + value: { + artifactId: '01JSTATUSCONFLICT', + fileName: '旧事件报告.pdf', + status: 'AVAILABLE', + }, + }, + ], + }, + }, + ]); + + const artifacts = items.filter((item) => item.type === 'artifact'); + expect(artifacts).toEqual([ + expect.objectContaining({ + artifactId: '01JSTATUSCONFLICT', + downloadUrl: undefined, + fileName: '账本报告.pdf', + status: 'expired', + }), + ]); + }); + it('projects history records to chat timeline items', () => { const items = recordsToTimelineItems([ { @@ -82,6 +200,36 @@ describe('agentTimelineAdapter', () => { expect(assistant?.parts.some((part) => part.type === 'text')).toBe(true); expect(items.some((item) => item.type === 'tool')).toBe(true); expect(assistant?.knowledgeItems?.[0]?.documentName).toBe('手册'); + expect( + items + .filter((item) => item.type !== 'message' || item.role !== 'user') + .every((item) => item.roundId === 'r1' && item.turnSucceeded === true), + ).toBe(true); + }); + + it('keeps a cancelled partial assistant turn expanded after history restore', () => { + const items = recordsToTimelineItems([ + { + id: 'cancelled-assistant', + senderRole: 'assistant', + contentText: '取消前的部分输出', + created: '2026-08-15T10:00:00Z', + roundId: 'cancelled-round', + contentPayload: { + agentResult: { text: '取消前的部分输出' }, + terminalStatus: 'CANCELLED', + }, + }, + ]); + + expect( + items + .filter((item) => item.roundId === 'cancelled-round') + .every( + (item) => + item.turnSucceeded === false && item.turnFinishedAt !== undefined, + ), + ).toBe(true); }); it('keeps stable ids when history has reasoning, tools and final text', () => { @@ -310,335 +458,63 @@ describe('agentTimelineAdapter', () => { ).toBe(true); }); - it('parses raw SSE text as message delta', () => { - const envelope = parseAgentSseMessage({ - data: 'hello', - event: '', - id: '', - retry: undefined, - }); - - expect(envelope).toMatchObject({ - domain: 'LLM', - type: 'MESSAGE', - payload: { delta: 'hello' }, - }); - }); - - it('reconciles streamed text with the canonical final answer', () => { - const items: any[] = []; - - for (const delta of ['http://127.0.0.1:39', '0', '/easyflow/file.docx']) { - applyAgentSseEnvelope(items, { - domain: 'LLM', - type: 'MESSAGE', - payload: { delta }, - }); - } - applyAgentSseEnvelope(items, { - domain: 'SYSTEM', - type: 'DONE', - payload: { - finalText: 'http://127.0.0.1:39000/easyflow/file.docx', - }, - }); - - const assistant = items.find( - (item): item is ChatTimelineMessageItem => - item.type === 'message' && item.role === 'assistant', - ); - expect(assistant?.parts[0]?.content).toBe( - 'http://127.0.0.1:39000/easyflow/file.docx', - ); - expect(assistant?.status).toBe('done'); - }); - - it('applies streaming text, HITL approval and error envelopes', () => { - const items: any[] = []; - - applyAgentSseEnvelope(items, { - domain: 'LLM', - type: 'MESSAGE', - payload: { delta: '你好' }, - }); - applyAgentSseEnvelope(items, { - domain: 'TOOL', - type: 'FORM_REQUEST', - payload: { - requestId: 'req-1', - resumeToken: 'token-1', - toolCallId: 'tool-1', - toolName: 'workflow', - input: { name: 'demo' }, - }, - }); - applyAgentSseEnvelope(items, { - domain: 'ERROR', - type: 'ERROR', - payload: { message: '失败' }, - }); - - const assistant = items.find( - (item): item is ChatTimelineMessageItem => - item.type === 'message' && item.role === 'assistant', - ); - const tool = items.find((item) => item.type === 'tool'); - const error = items.find((item) => item.type === 'error'); - - expect(assistant?.parts[0]?.content).toBe('你好'); - expect(tool?.status).toBe('pending_approval'); - expect(tool?.approval?.resumeToken).toBe('token-1'); - expect(error?.message).toBe('失败'); - }); - - it('keeps async workflow polling events in the original approval card', () => { - const items: any[] = []; - - applyAgentSseEnvelope(items, { - domain: 'TOOL', - type: 'FORM_REQUEST', - payload: { - input: { user_input: '写一篇小作文' }, - requestId: 'req-async', - resumeToken: 'token-async', - toolCallId: 'submit-call-1', - toolName: '文档生成', - }, - }); - applyAgentSseEnvelope(items, { - domain: 'TOOL', - type: 'TOOL_RESULT', - payload: { - asyncTool: true, - phase: 'submit', - sourceToolCallId: 'submit-call-1', - status: 'RUNNING', - taskId: 'task-1', - toolCallId: 'task-1', - toolName: '文档生成', - }, - }); - for (const sourceToolCallId of ['observe-call-1', 'observe-call-2']) { - applyAgentSseEnvelope(items, { - domain: 'TOOL', - type: 'TOOL_CALL', - payload: { - asyncTool: true, - input: { taskId: 'task-1' }, - phase: 'observe', - sourceToolCallId, - status: 'RUNNING', - taskId: 'task-1', - toolCallId: 'task-1', - toolName: '文档生成', - }, - }); - } - applyAgentSseEnvelope(items, { - domain: 'TOOL', - type: 'TOOL_RESULT', - payload: { - asyncTool: true, - phase: 'result', - sourceToolCallId: 'result-call-1', - status: 'SUCCEEDED', - taskId: 'task-1', - toolCallId: 'task-1', - toolName: '文档生成', - }, - }); - - const tools = items.filter((item) => item.type === 'tool'); - expect(tools).toHaveLength(1); - expect(tools[0]).toMatchObject({ - mode: 'approval', - status: 'success', - taskId: 'task-1', - toolCallId: 'task-1', - toolName: '文档生成', - }); - }); - - it('keeps assistant text and approval card when a tool request is rejected', () => { - const items: any[] = []; - - applyAgentSseEnvelope(items, { - domain: 'LLM', - type: 'MESSAGE', - payload: { delta: '正在处理' }, - }); - applyAgentSseEnvelope(items, { - domain: 'TOOL', - type: 'FORM_REQUEST', - payload: { - requestId: 'req-2', - resumeToken: 'token-2', - toolCallId: 'tool-2', - toolName: '审批工具', - input: { name: 'demo' }, - }, - }); - applyAgentSseEnvelope(items, { - domain: 'TOOL', - type: 'FORM_REJECTED', - payload: { - requestId: 'req-2', - resumeToken: 'token-2', - toolCallId: 'tool-2', - reason: '用户拒绝执行', - }, - }); - - const assistant = items.find( - (item): item is ChatTimelineMessageItem => - item.type === 'message' && item.role === 'assistant', - ); - const tool = items.find((item) => item.type === 'tool'); - - expect(assistant?.parts[0]?.content).toBe('正在处理'); - expect(items).toHaveLength(2); - expect(tool?.status).toBe('rejected'); - expect(tool?.rejectReason).toBe('用户拒绝执行'); - }); - - it('applies streaming round metadata to assistant messages for action toolbar anchoring', () => { - const items: any[] = []; - - applyAgentSseEnvelope( - items, + it('restores only terminal Skill invocation states from safe history fields', () => { + const items = recordsToTimelineItems([ { - domain: 'LLM', - type: 'MESSAGE', - payload: { delta: '准备调用工具' }, - }, - { roundId: 'runtime-round-1' }, - ); - - const assistant = items.find( - (item): item is ChatTimelineMessageItem => - item.type === 'message' && item.role === 'assistant', - ); - - expect(assistant?.roundId).toBe('runtime-round-1'); - expect(assistant?.parts[0]?.content).toBe('准备调用工具'); - }); - - it('updates memory compression status within the current round', () => { - const items: any[] = []; - - applyAgentSseEnvelope( - items, - { - domain: 'BUSINESS', - type: 'STATUS', - payload: { - label: '正在整理上下文', - phase: 'started', - status: 'running', - statusKey: 'memory-compression', + id: 'skill-history', + senderRole: 'assistant', + contentText: '处理结束', + roundId: 'round-skill-history', + contentPayload: { + agentResult: { text: '处理结束' }, + skillInvocationStatuses: [ + { + input: { private: true }, + path: 'references/private.md', + skillContent: 'private body', + skillDisplayName: '不会恢复的运行态', + status: 'RUNNING', + statusKey: 'skill-invocation:round-skill-history:running', + }, + { + configJson: { token: 'secret' }, + skillDisplayName: '合同审查助手', + status: 'SUCCESS', + statusKey: 'skill-invocation:round-skill-history:101', + }, + { + skillDisplayName: '数据分析助手', + status: 'FAILED', + statusKey: 'skill-invocation:round-skill-history:102', + }, + { + skillDisplayName: '流程检查助手', + status: 'CANCELLED', + statusKey: 'skill-invocation:round-skill-history:103', + }, + { + skillDisplayName: '规范化助手', + status: 'INCOMPLETE', + statusKey: 'skill-invocation:round-skill-history:104', + }, + ], }, }, - { roundId: 'round-a' }, + ]); + + const skillStatuses = items.filter( + (item): item is ChatTimelineStatusItem => + item.type === 'status' && item.icon === 'skill', ); - applyAgentSseEnvelope( - items, - { - domain: 'BUSINESS', - type: 'STATUS', - payload: { - compressed: true, - label: '已整理上下文', - phase: 'completed', - status: 'done', - statusKey: 'memory-compression', - }, - }, - { roundId: 'round-a' }, + expect(skillStatuses).toHaveLength(4); + expect(skillStatuses.map((item) => item.status)).toEqual([ + 'done', + 'error', + 'cancelled', + 'incomplete', + ]); + expect(JSON.stringify(skillStatuses)).not.toMatch( + /不会恢复的运行态|skillContent|private body|private\.md|configJson|token/, ); - - const statuses = items.filter((item) => item.type === 'status'); - expect(statuses).toHaveLength(1); - expect(statuses[0]?.label).toBe('已整理上下文'); - expect(statuses[0]?.status).toBe('done'); - expect(statuses[0]?.statusKey).toBe('memory-compression:round-a'); - }); - - it('keeps memory compression statuses isolated by round', () => { - const items: any[] = []; - - applyAgentSseEnvelope( - items, - { - domain: 'BUSINESS', - type: 'STATUS', - payload: { - compressed: true, - label: '已整理上下文', - phase: 'completed', - status: 'done', - statusKey: 'memory-compression', - }, - }, - { roundId: 'round-a' }, - ); - applyAgentSseEnvelope( - items, - { - domain: 'BUSINESS', - type: 'STATUS', - payload: { - compressed: false, - label: '无需压缩上下文', - phase: 'completed', - status: 'done', - statusKey: 'memory-compression', - }, - }, - { roundId: 'round-b' }, - ); - - const statuses = items.filter((item) => item.type === 'status'); - expect(statuses).toHaveLength(1); - expect(statuses[0]?.statusKey).toBe('memory-compression:round-a'); - expect(statuses[0]?.label).toBe('已整理上下文'); - }); - - it('does not show no-compression status before a later compression run', () => { - const items: any[] = []; - - applyAgentSseEnvelope( - items, - { - domain: 'BUSINESS', - type: 'STATUS', - payload: { - compressed: false, - label: '无需压缩上下文', - phase: 'completed', - status: 'done', - statusKey: 'memory-compression', - }, - }, - { roundId: 'round-a' }, - ); - applyAgentSseEnvelope( - items, - { - domain: 'BUSINESS', - type: 'STATUS', - payload: { - label: '正在整理上下文', - phase: 'started', - status: 'running', - statusKey: 'memory-compression', - }, - }, - { roundId: 'round-a' }, - ); - - const statuses = items.filter((item) => item.type === 'status'); - expect(statuses).toHaveLength(1); - expect(statuses[0]?.label).toBe('正在整理上下文'); - expect(statuses[0]?.status).toBe('running'); }); }); diff --git a/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.ts b/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.ts index 3642de20..2d00d31b 100644 --- a/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.ts +++ b/easyflow-ui-admin/app/src/views/ai/agent-chat/adapters/agentTimelineAdapter.ts @@ -1,24 +1,18 @@ -import type { ServerSentEventMessage } from 'fetch-event-stream'; - import type { ChatDocumentAttachment, ChatImageAttachment, ChatTimelineItem, ChatTimelineKnowledgeHit, ChatTimelineMessageItem, - ChatTimelineToolApprovalPayload, - ChatTimelineToolStatus, + ChatTimelineSkillInvocationStatus, } from '@easyflow/common-ui'; import type { AgentChatMessageRecord } from '../api'; import { ChatTimelineBuilder } from '@easyflow/common-ui'; -export interface AgentSseEnvelope { - domain: string; - payload: Record; - type: string; -} +import { projectArtifactPayload } from '../../shared/agent-agui/artifact-projection'; +import { easyFlowAguiCustomEvent } from '../../shared/agent-agui/custom-events'; function asText(value: unknown) { return value === null || value === undefined ? '' : String(value); @@ -34,17 +28,6 @@ function asArray(value: unknown): any[] { return Array.isArray(value) ? value : []; } -function asyncToolTimelineStatus( - payload: Record, -): ChatTimelineToolStatus { - const status = asText(payload.status).toUpperCase(); - if (status === 'SUCCEEDED') return 'success'; - if (status === 'FAILED' || status === 'TIMEOUT' || status === 'CANCELLED') { - return 'error'; - } - return 'running'; -} - function asTimestamp(value: unknown) { if (!value) { return Date.now(); @@ -53,6 +36,44 @@ function asTimestamp(value: unknown) { return Number.isFinite(timestamp) ? timestamp : Date.now(); } +function optionalTimestamp(value: unknown) { + if (!value) return undefined; + const timestamp = new Date(String(value)).getTime(); + return Number.isFinite(timestamp) ? timestamp : undefined; +} + +function applyHistoryTurnTimings( + items: ChatTimelineItem[], + records: AgentChatMessageRecord[], +) { + const timings = new Map< + string, + { finishedAt?: number; firstAt: number; startedAt?: number } + >(); + for (const record of records) { + const roundId = asText(record.roundId).trim(); + const createdAt = optionalTimestamp(record.created); + if (!roundId || createdAt === undefined) continue; + const current = timings.get(roundId) || { firstAt: createdAt }; + current.firstAt = Math.min(current.firstAt, createdAt); + const role = normalizeRole(record.senderRole); + if (role === 'user') { + current.startedAt = Math.min(current.startedAt ?? createdAt, createdAt); + } else { + current.finishedAt = Math.max(current.finishedAt ?? createdAt, createdAt); + } + timings.set(roundId, current); + } + for (const item of items) { + const timing = item.roundId ? timings.get(item.roundId) : undefined; + if (!timing) continue; + item.turnStartedAt = timing.startedAt ?? timing.firstAt; + if (timing.finishedAt !== undefined) { + item.turnFinishedAt = timing.finishedAt; + } + } +} + function normalizeRole(value: unknown): 'assistant' | 'system' | 'user' { const role = asText(value).toLowerCase(); if (role === 'assistant' || role === 'system' || role === 'user') { @@ -102,6 +123,112 @@ function statusKeyForProjection( return roundId ? `${statusKey}:${roundId}` : statusKey; } +const terminalSkillInvocationStatuses = + new Set([ + 'CANCELLED', + 'FAILED', + 'INCOMPLETE', + 'SUCCESS', + ]); + +function statusFromRuntimeEventType( + eventType: string, +): ChatTimelineSkillInvocationStatus | undefined { + if (eventType === 'SKILL_RESULT') return 'SUCCESS'; + if (eventType === 'SKILL_FAILED') return 'FAILED'; + return undefined; +} + +function normalizeHistorySkillInvocation(value: unknown, direct: boolean) { + const event = asRecord(value); + const eventName = asText(event.name ?? event.eventName); + const eventType = asText(event.eventType ?? event.type).toUpperCase(); + const isSkillRuntimeEvent = + eventType === 'SKILL_FAILED' || eventType === 'SKILL_RESULT'; + if ( + (!direct && + eventName !== easyFlowAguiCustomEvent.skillInvocationStatus && + !isSkillRuntimeEvent) || + (eventName && eventName !== easyFlowAguiCustomEvent.skillInvocationStatus) + ) { + return undefined; + } + const payload = asRecord( + event.value ?? event.payload ?? event.payloadJson ?? event, + ); + const rawStatus = asText(payload.status).toUpperCase(); + const status = (rawStatus || statusFromRuntimeEventType(eventType)) as + | ChatTimelineSkillInvocationStatus + | undefined; + if (!status || !terminalSkillInvocationStatuses.has(status)) { + return undefined; + } + const statusKey = asText(payload.statusKey).trim(); + if (!statusKey) { + return undefined; + } + return { + displayName: + asText(payload.skillDisplayName).trim() || + asText(payload.skillName).trim() || + '技能', + status, + statusKey, + }; +} + +function projectHistorySkillInvocations( + items: ChatTimelineItem[], + payload: Record, + metadata: Partial, +) { + const directSource = [ + ...asArray(payload.skillInvocationStatuses), + ...asArray(payload.skillInvocations), + ]; + const source = + directSource.length > 0 ? directSource : asArray(payload.runtimeEvents); + for (const value of source) { + const invocation = normalizeHistorySkillInvocation( + value, + directSource.length > 0, + ); + if (invocation) { + ChatTimelineBuilder.upsertSkillInvocationStatus(items, { + ...metadata, + ...invocation, + }); + } + } +} + +function projectHistoryArtifacts( + items: ChatTimelineItem[], + payload: Record, + metadata: Partial, +) { + let source: any[]; + if (Array.isArray(payload.artifacts)) { + source = payload.artifacts; + } else if (Array.isArray(payload.artifactPublishedEvents)) { + source = payload.artifactPublishedEvents; + } else { + source = asArray(payload.runtimeEvents) + .map((value) => asRecord(value)) + .filter( + (event) => + asText(event.name ?? event.eventName) === + easyFlowAguiCustomEvent.artifactPublished, + ) + .map((event) => + asRecord(event.value ?? event.payload ?? event.payloadJson), + ); + } + for (const artifact of source) { + projectArtifactPayload(items, asRecord(artifact), metadata); + } +} + function normalizeMetadata(record: AgentChatMessageRecord) { return { createdAt: asTimestamp(record.created), @@ -202,20 +329,6 @@ function normalizeDocuments(payload: Record) { .filter((item): item is ChatDocumentAttachment => item !== undefined); } -function buildApprovalPayload(payload: Record) { - return { - expiresAt: asText(payload.expiresAt), - input: payload.input, - metadata: payload.metadata, - requestId: asText(payload.requestId), - resumeToken: asText(payload.resumeToken), - toolCallId: normalizeToolCallId(payload), - toolDisplayName: asText(payload.toolDisplayName), - toolName: normalizeToolName(payload.toolName ?? payload.name) || '工具调用', - toolType: asText(payload.toolType), - } satisfies ChatTimelineToolApprovalPayload; -} - function appendAssistantText( items: ChatTimelineItem[], record: AgentChatMessageRecord, @@ -276,6 +389,7 @@ function projectHistoryChain( } if (toolName && !shouldSkipToolProjection(toolName)) { ChatTimelineBuilder.upsertToolCall(items, { + ...normalizeMetadata(record), input: item.arguments ?? item.input, output: item.result ?? item.output, status: asText(item.status) === 'TOOL_RESULT' ? 'success' : 'running', @@ -314,6 +428,7 @@ function projectHistoryChain( continue; } ChatTimelineBuilder.upsertToolCall(items, { + ...normalizeMetadata(record), input: normalizeToolCallInput(tool), status: 'running', statusKey: statusKeyForProjection( @@ -335,6 +450,7 @@ function projectHistoryChain( continue; } ChatTimelineBuilder.upsertToolCall(items, { + ...normalizeMetadata(record), output: item.content ?? item.result, status: 'success', statusKey: statusKeyForProjection( @@ -368,12 +484,18 @@ function appendHistoryRecord( return; } if (role === 'system') { - ChatTimelineBuilder.appendError(items, record.contentText || '系统消息'); + ChatTimelineBuilder.appendError( + items, + record.contentText || '系统消息', + metadata, + ); return; } const payload = asRecord(record.contentPayload); const agentResult = asRecord(payload.agentResult); + projectHistoryArtifacts(items, payload, metadata); + projectHistorySkillInvocations(items, payload, metadata); const chainProjection = projectHistoryChain(items, record); if (!chainProjection.hasAssistantThinking) { appendAssistantThinking( @@ -399,9 +521,16 @@ function appendHistoryRecord( payload.knowledgeReferences, }); if (knowledgeItems.length > 0) { - ChatTimelineBuilder.appendKnowledge(items, knowledgeItems); + ChatTimelineBuilder.appendKnowledge(items, knowledgeItems, metadata); } - ChatTimelineBuilder.finalize(items); + const terminalStatus = asText(payload.terminalStatus).toUpperCase(); + const turnSucceeded = !terminalStatus || terminalStatus === 'COMPLETED'; + ChatTimelineBuilder.finalize(items, { + ...metadata, + roundCompleted: turnSucceeded, + turnFinishedAt: turnSucceeded ? undefined : metadata.createdAt, + turnSucceeded, + }); } export function recordsToTimelineItems(records: AgentChatMessageRecord[] = []) { @@ -410,166 +539,6 @@ export function recordsToTimelineItems(records: AgentChatMessageRecord[] = []) { appendHistoryRecord(items, record); } ChatTimelineBuilder.finalize(items); + applyHistoryTurnTimings(items, records); return items; } - -export function parseAgentSseMessage(message: ServerSentEventMessage) { - const raw = message.data || ''; - if (!raw) { - return undefined; - } - try { - const data = JSON.parse(raw); - return { - domain: asText( - data.domain ?? data.eventDomain ?? data.typeDomain, - ).toUpperCase(), - payload: asRecord(data.payload ?? data.data ?? data), - type: asText( - data.type ?? data.eventType ?? data.chatType ?? data.event, - ).toUpperCase(), - } satisfies AgentSseEnvelope; - } catch { - return { - domain: 'LLM', - payload: { delta: raw }, - type: 'MESSAGE', - } satisfies AgentSseEnvelope; - } -} - -export function applyAgentSseEnvelope( - items: ChatTimelineItem[], - envelope: AgentSseEnvelope, - metadata?: Partial, -) { - const { domain, payload, type } = envelope; - if (domain === 'LLM' && type === 'MESSAGE') { - ChatTimelineBuilder.appendMessageDelta( - items, - payload.delta ?? payload.text, - metadata, - ); - return; - } - if (domain === 'LLM' && type === 'THINKING') { - ChatTimelineBuilder.appendThinkingDelta( - items, - payload.reasoning ?? payload.delta ?? payload.text, - metadata, - ); - return; - } - if (domain === 'TOOL' && type === 'FORM_REQUEST') { - ChatTimelineBuilder.appendToolApproval( - items, - buildApprovalPayload(payload), - ); - return; - } - if (domain === 'TOOL' && type === 'FORM_APPROVING') { - ChatTimelineBuilder.markToolApproving(items, { - requestId: asText(payload.requestId), - resumeToken: asText(payload.resumeToken), - toolCallId: normalizeToolCallId(payload), - }); - return; - } - if (domain === 'TOOL' && type === 'FORM_REJECTED') { - ChatTimelineBuilder.markToolRejected(items, { - reason: asText(payload.reason), - requestId: asText(payload.requestId), - resumeToken: asText(payload.resumeToken), - toolCallId: normalizeToolCallId(payload), - }); - return; - } - if (domain === 'TOOL' && (type === 'TOOL_CALL' || type === 'TOOL_RESULT')) { - const asyncTool = payload.asyncTool === true; - const taskInput = asRecord(payload.input ?? payload.toolInput); - const toolName = normalizeToolName( - payload.toolDisplayName ?? payload.toolName ?? payload.name, - ); - let status: ChatTimelineToolStatus = 'running'; - if (asyncTool) { - status = asyncToolTimelineStatus(payload); - } else if (type === 'TOOL_RESULT') { - status = 'success'; - } - ChatTimelineBuilder.upsertToolCall(items, { - input: payload.input ?? payload.toolInput, - output: asyncTool - ? (payload.summary ?? - payload.label ?? - payload.output ?? - payload.result ?? - payload.text) - : (payload.output ?? payload.result ?? payload.text), - status, - statusKey: statusKeyForProjection( - payload, - metadata, - 'knowledge-retrieval', - ), - sourceToolCallId: asyncTool - ? asText(payload.sourceToolCallId ?? payload.source_tool_call_id) - : undefined, - taskId: asyncTool - ? asText(payload.taskId ?? taskInput.taskId ?? taskInput.task_id) - : undefined, - toolCallId: asyncTool - ? asText(payload.toolCallId ?? payload.taskId ?? payload.id) - : normalizeToolCallId(payload), - toolName, - }); - return; - } - if (domain === 'BUSINESS' && type === 'CITATIONS') { - ChatTimelineBuilder.appendKnowledge( - items, - normalizeKnowledgeItems(payload), - ); - return; - } - if (domain === 'BUSINESS' && type === 'STATUS') { - if (asText(payload.statusKey) === 'memory-compression') { - ChatTimelineBuilder.upsertMemoryCompressionStatus(items, { - compressed: - typeof payload.compressed === 'boolean' - ? payload.compressed - : undefined, - label: asText(payload.label), - phase: asText(payload.phase), - status: asText(payload.status), - statusKey: statusKeyForProjection( - payload, - metadata, - 'memory-compression', - ), - }); - return; - } - if (asText(payload.statusKey) === 'knowledge-retrieval') { - ChatTimelineBuilder.upsertKnowledgeRetrievalStatus( - items, - asText(payload.status) === 'running' ? 'running' : 'done', - statusKeyForProjection(payload, metadata, 'knowledge-retrieval'), - ); - } - return; - } - if (domain === 'SYSTEM' && type === 'DONE') { - const finalText = asText(payload.finalText ?? payload.text); - if (finalText) { - ChatTimelineBuilder.replaceMessageContent(items, finalText); - } - ChatTimelineBuilder.finalize(items); - return; - } - if (domain === 'ERROR' || type === 'ERROR') { - ChatTimelineBuilder.appendError( - items, - payload.message ?? payload.error ?? '请求失败', - ); - } -} diff --git a/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.test.ts b/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.test.ts index de24016d..3872a0dd 100644 --- a/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.test.ts @@ -1,19 +1,32 @@ // @vitest-environment happy-dom +import type { EasyFlowAguiRunOptions } from '../shared/agent-agui/client'; + import { useUserStore } from '@easyflow/stores'; +import { EventType } from '@ag-ui/client'; import { createPinia, setActivePinia } from 'pinia'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { clearAgentChatBrowserCache } from '#/utils/agent-chat-cache'; +import { easyFlowAguiCustomEvent } from '../shared/agent-agui/custom-events'; import { agentChatRuntimeManager } from './agentChatRuntimeManager'; -import { sendAgentChat } from './api'; + +const aguiMocks = vi.hoisted(() => ({ + abort: vi.fn(), + run: vi.fn(), +})); + +vi.mock('../shared/agent-agui/client', () => ({ + EasyFlowAguiClient: class { + abort = aguiMocks.abort; + run = aguiMocks.run; + }, +})); vi.mock('./api', () => ({ generateAgentSessionId: vi.fn(), - sendAgentChat: vi.fn(), - stopAgentChatStream: vi.fn(), })); describe('agentChatRuntimeManager', () => { @@ -27,11 +40,43 @@ describe('agentChatRuntimeManager', () => { vi.useRealTimers(); }); - it('replaces accepted attachments and isolates snapshots by account', async () => { - let callbacks: any; - vi.mocked(sendAgentChat).mockImplementation((_data, options) => { - callbacks = options; - return Promise.resolve() as any; + it('发起请求后立即创建带起始时间的助手轮次', async () => { + vi.useFakeTimers(); + vi.setSystemTime(12_000); + aguiMocks.run.mockImplementation(() => new Promise(() => {})); + useUserStore().setUserInfo({ + avatar: '', + id: 'timer-user', + loginName: 'timer-user', + nickname: '计时用户', + tenantId: 'tenant-1', + }); + + await agentChatRuntimeManager.start({ + agentId: 'agent-1', + prompt: '计时测试', + sessionId: 'timer-session', + }); + + const assistant = agentChatRuntimeManager + .getSnapshot('timer-session') + ?.items.find( + (item) => item.type === 'message' && item.role === 'assistant', + ); + expect(assistant).toEqual( + expect.objectContaining({ + roundId: expect.any(String), + status: 'streaming', + turnStartedAt: 12_000, + }), + ); + }); + + it('投影 AG-UI 输入确认并按账号隔离快照', async () => { + let runOptions: EasyFlowAguiRunOptions | undefined; + aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => { + runOptions = options; + return new Promise(() => {}); }); const userStore = useUserStore(); const firstAccount = { @@ -46,78 +91,55 @@ describe('agentChatRuntimeManager', () => { await agentChatRuntimeManager.start({ agentId: 'agent-1', documentUploadIds: ['document-upload-1'], - documents: [ - { - downloadUrl: - '/api/v1/agent/media/document/content?reference=draft%3Adocument-upload-1', - name: 'draft.docx', - status: 'ready', - uploadId: 'document-upload-1', - }, - ], + documents: [{ name: 'draft.docx', status: 'ready' }], images: [ { name: 'draft.png', - previewUrl: '/api/v1/agent/media/content?reference=draft%3Aupload-1', + previewUrl: '/draft.png', status: 'ready', - uploadId: 'upload-1', }, ], prompt: '识别图片', sessionId: '101', }); - callbacks.onMessage({ - data: JSON.stringify({ - domain: 'SYSTEM', - payload: { - attachments: [ - { - attachmentRef: 'formal:101:201:document:0', - downloadUrl: - '/api/v1/agent/media/document/content?reference=formal:101:201:document:0', - name: 'draft.docx', - readSnapshotId: 'snapshot-1', - size: 2048, - }, - ], - images: [ - { - imageRef: 'formal:101:201:0:png', - name: 'draft.png', - previewUrl: - '/api/v1/agent/media/content?reference=formal:101:201:0:png', - }, - ], - }, - type: 'INPUT_ACCEPTED', - }), + runOptions?.onEvent({ + name: easyFlowAguiCustomEvent.inputAccepted, + type: EventType.CUSTOM, + value: { + attachments: [ + { + attachmentRef: 'formal:101:201:document:0', + name: 'draft.docx', + readSnapshotId: 'snapshot-1', + }, + ], + images: [ + { + imageRef: 'formal:101:201:0:png', + name: 'draft.png', + previewUrl: '/formal.png', + }, + ], + }, }); const accepted = agentChatRuntimeManager.getSnapshot('101'); const userMessage = accepted?.items.find( (item) => item.type === 'message' && item.role === 'user', ); - expect( - userMessage?.type === 'message' ? userMessage.images?.[0] : null, - ).toEqual( - expect.objectContaining({ - imageRef: 'formal:101:201:0:png', - previewUrl: - '/api/v1/agent/media/content?reference=formal:101:201:0:png', - }), + expect(userMessage?.type === 'message' && userMessage.images?.[0]).toEqual( + expect.objectContaining({ imageRef: 'formal:101:201:0:png' }), ); expect( - userMessage?.type === 'message' ? userMessage.documents?.[0] : null, - ).toEqual( + userMessage?.type === 'message' && userMessage.documents?.[0], + ).toEqual(expect.objectContaining({ readSnapshotId: 'snapshot-1' })); + expect(runOptions?.forwardedProps).toEqual( expect.objectContaining({ - attachmentRef: 'formal:101:201:document:0', - readSnapshotId: 'snapshot-1', - status: 'ready', - }), - ); - expect(vi.mocked(sendAgentChat).mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ - documentUploadIds: ['document-upload-1'], + easyflow: expect.objectContaining({ + input: expect.objectContaining({ + documentUploadIds: ['document-upload-1'], + }), + }), }), ); @@ -125,7 +147,6 @@ describe('agentChatRuntimeManager', () => { ...firstAccount, id: 'user-2', loginName: 'other', - nickname: '其他用户', }); expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined(); @@ -134,12 +155,15 @@ describe('agentChatRuntimeManager', () => { expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined(); }); - it('coalesces streaming notifications and persists terminal state immediately', async () => { + it('合并流式通知并在 AG-UI 终态立即持久化', async () => { vi.useFakeTimers(); - let callbacks: any; - vi.mocked(sendAgentChat).mockImplementation((_data, options) => { - callbacks = options; - return Promise.resolve() as any; + let runOptions: EasyFlowAguiRunOptions | undefined; + let resolveRun: (() => void) | undefined; + aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => { + runOptions = options; + return new Promise((resolve) => { + resolveRun = resolve; + }); }); const account = { avatar: '', @@ -160,17 +184,13 @@ describe('agentChatRuntimeManager', () => { const storageSpy = vi.spyOn(sessionStorage, 'setItem'); for (const delta of ['A', 'B', 'C']) { - callbacks.onMessage({ - data: JSON.stringify({ - domain: 'LLM', - payload: { delta }, - type: 'MESSAGE', - }), + runOptions?.onEvent({ + delta, + messageId: 'assistant-1', + type: EventType.TEXT_MESSAGE_CONTENT, }); } - expect(listener).not.toHaveBeenCalled(); - expect(storageSpy).not.toHaveBeenCalled(); expect( JSON.stringify( agentChatRuntimeManager.getSnapshot('stream-session')?.items, @@ -179,21 +199,19 @@ describe('agentChatRuntimeManager', () => { await vi.advanceTimersByTimeAsync(50); expect(listener).toHaveBeenCalledTimes(1); - expect(storageSpy).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(250); expect(storageSpy).toHaveBeenCalledTimes(2); - callbacks.onMessage({ - data: JSON.stringify({ - domain: 'LLM', - payload: { delta: 'D' }, - type: 'MESSAGE', - }), - }); listener.mockClear(); storageSpy.mockClear(); - callbacks.onFinished(); + runOptions?.onEvent({ + runId: 'run-1', + threadId: 'stream-session', + type: EventType.RUN_FINISHED, + }); + resolveRun?.(); + await Promise.resolve(); + await Promise.resolve(); expect(listener).toHaveBeenCalledTimes(1); expect(storageSpy).toHaveBeenCalledTimes(2); @@ -205,4 +223,141 @@ describe('agentChatRuntimeManager', () => { storageSpy.mockRestore(); clearAgentChatBrowserCache(account); }); + + it('工具开始事件立即通知页面并保留调用中快照', async () => { + vi.useFakeTimers(); + let runOptions: EasyFlowAguiRunOptions | undefined; + aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => { + runOptions = options; + return new Promise(() => {}); + }); + const account = { + avatar: '', + id: 'tool-status-user', + loginName: 'tool-status-user', + nickname: '工具状态用户', + tenantId: 'tenant-1', + }; + useUserStore().setUserInfo(account); + + await agentChatRuntimeManager.start({ + agentId: 'agent-1', + prompt: '生成文件', + sessionId: 'tool-status-session', + }); + const listener = vi.fn(); + const unsubscribe = agentChatRuntimeManager.subscribe(listener); + + runOptions?.onEvent({ + toolCallId: 'write-call-1', + toolCallName: 'write_text_file', + type: EventType.TOOL_CALL_START, + }); + + expect(listener).toHaveBeenCalledTimes(1); + expect( + agentChatRuntimeManager + .getSnapshot('tool-status-session') + ?.items.find( + (item) => item.type === 'tool' && item.toolCallId === 'write-call-1', + ), + ).toEqual(expect.objectContaining({ status: 'running' })); + + unsubscribe(); + clearAgentChatBrowserCache(account); + }); + + it('停止后同会话重发不会接收旧运行的迟到事件', async () => { + const runs: EasyFlowAguiRunOptions[] = []; + aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => { + runs.push(options); + return new Promise(() => {}); + }); + useUserStore().setUserInfo({ + avatar: '', + id: 'race-user', + loginName: 'race-user', + nickname: '竞态用户', + tenantId: 'tenant-1', + }); + + await agentChatRuntimeManager.start({ + agentId: 'agent-1', + prompt: '旧问题', + sessionId: 'race-session', + }); + agentChatRuntimeManager.stop('race-session'); + await agentChatRuntimeManager.start({ + agentId: 'agent-1', + prompt: '新问题', + sessionId: 'race-session', + }); + + runs[0]?.onEvent({ + delta: '旧流迟到正文', + messageId: 'old-assistant', + type: EventType.TEXT_MESSAGE_CONTENT, + }); + runs[1]?.onEvent({ + delta: '新流正文', + messageId: 'new-assistant', + type: EventType.TEXT_MESSAGE_CONTENT, + }); + + const snapshot = JSON.stringify( + agentChatRuntimeManager.getSnapshot('race-session')?.items, + ); + expect(snapshot).toContain('新流正文'); + expect(snapshot).not.toContain('旧流迟到正文'); + }); + + it('用户取消的 AG-UI 终态不会被异步完成回调改写为成功', async () => { + let runOptions: EasyFlowAguiRunOptions | undefined; + let resolveRun: (() => void) | undefined; + aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => { + runOptions = options; + return new Promise((resolve) => { + resolveRun = resolve; + }); + }); + useUserStore().setUserInfo({ + avatar: '', + id: 'cancel-user', + loginName: 'cancel-user', + nickname: '取消用户', + tenantId: 'tenant-1', + }); + + await agentChatRuntimeManager.start({ + agentId: 'agent-1', + prompt: '需要审批的任务', + sessionId: 'cancel-session', + }); + runOptions?.onEvent({ + delta: '准备执行', + messageId: 'assistant-1', + type: EventType.TEXT_MESSAGE_CONTENT, + }); + runOptions?.onEvent({ + code: 'RUN_CANCELLED', + message: '用户拒绝执行', + runId: 'run-1', + threadId: 'cancel-session', + type: EventType.RUN_ERROR, + }); + resolveRun?.(); + await Promise.resolve(); + await Promise.resolve(); + + const assistant = agentChatRuntimeManager + .getSnapshot('cancel-session') + ?.items.find( + (item) => item.type === 'message' && item.role === 'assistant', + ); + expect(assistant).toEqual( + expect.objectContaining({ + turnSucceeded: false, + }), + ); + }); }); diff --git a/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.ts b/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.ts index 896c86bd..98a2206e 100644 --- a/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.ts +++ b/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.ts @@ -10,21 +10,20 @@ import type { AgentChatCapabilityPayload } from './api'; import { ChatTimelineBuilder } from '@easyflow/common-ui'; import { useUserStore } from '@easyflow/stores'; +import { EventType } from '@ag-ui/client'; + import { onAgentChatCacheClear, resolveAgentChatIdentity, RUNTIME_STORAGE_PREFIX, } from '#/utils/agent-chat-cache'; +import { EasyFlowAguiClient } from '../shared/agent-agui/client'; import { - applyAgentSseEnvelope, - parseAgentSseMessage, -} from './adapters/agentTimelineAdapter'; -import { - generateAgentSessionId, - sendAgentChat, - stopAgentChatStream, -} from './api'; + applyAguiEventToTimeline, + createAguiTimelineProjectionState, +} from '../shared/agent-agui/projection'; +import { generateAgentSessionId } from './api'; interface RuntimeSessionState { agentId: string; @@ -76,6 +75,7 @@ const sessions = new Map(); const listeners = new Set<() => void>(); const latestSessionIds = new Map(); const persistTimers = new Map>(); +const runClients = new Map(); let notifyTimer: ReturnType | undefined; function clone(value: T): T { @@ -221,7 +221,7 @@ function touchState(state: RuntimeSessionState) { sessions.set(sessionKey(state.identity, state.sessionId), state); } -function scheduleStateUpdate(state: RuntimeSessionState) { +function scheduleStateUpdate(state: RuntimeSessionState, immediate = false) { touchState(state); const scopedSessionKey = sessionKey(state.identity, state.sessionId); if (!persistTimers.has(scopedSessionKey)) { @@ -236,7 +236,11 @@ function scheduleStateUpdate(state: RuntimeSessionState) { }, STREAM_PERSIST_INTERVAL_MS), ); } - scheduleNotify(); + if (immediate) { + notifyNow(); + } else { + scheduleNotify(); + } } function restoreSession(identity: string, sessionId: string) { @@ -454,6 +458,7 @@ export const agentChatRuntimeManager = { } const sessionId = await resolveSessionId(options.sessionId); const roundId = createRoundId(); + const startedAt = Date.now(); const state: RuntimeSessionState = { agentId: options.agentId, agentName: options.agentName, @@ -464,72 +469,121 @@ export const agentChatRuntimeManager = { roundId, sending: true, sessionId, - updatedAt: Date.now(), + updatedAt: startedAt, }; ChatTimelineBuilder.appendUserMessage(state.items, options.prompt, { documents: options.documents, images: options.images, roundId, }); + ChatTimelineBuilder.ensureAssistantTurn(state.items, { + id: `turn-${roundId}`, + roundId, + turnStartedAt: startedAt, + }); upsertState(state); - void sendAgentChat( - { - agentId: options.agentId, - capabilities: options.capabilities, - documentUploadIds: options.documentUploadIds, - imageUploadIds: options.imageUploadIds, - prompt: options.prompt, - sessionId, - }, - { - onError(error) { - const current = sessions.get(sessionKey(identity, sessionId)); - if (!current || !current.sending) { - return; - } - current.error = errorMessage(error); - current.sending = false; - current.completed = true; - ChatTimelineBuilder.appendError(current.items, current.error); - ChatTimelineBuilder.finalize(current.items); - upsertState(current); + const scopedSessionKey = sessionKey(identity, sessionId); + const projectionState = createAguiTimelineProjectionState(startedAt); + const runClient = new EasyFlowAguiClient(); + runClients.set(scopedSessionKey, runClient); + void runClient + .run({ + forwardedProps: { + easyflow: { + input: { + capabilities: options.capabilities, + documentUploadIds: options.documentUploadIds, + imageUploadIds: options.imageUploadIds, + }, + }, }, - onFinished() { + onEvent(event) { const current = sessions.get(sessionKey(identity, sessionId)); - if (!current) { - return; - } - current.sending = false; - current.completed = true; - ChatTimelineBuilder.finalize(current.items); - upsertState(current); - }, - onMessage(message) { - const current = sessions.get(sessionKey(identity, sessionId)); - if (!current || !current.sending) { - return; - } - const envelope = parseAgentSseMessage(message); - if (!envelope) { + if (!current || current !== state || !current.sending) { return; } if ( - envelope.domain === 'SYSTEM' && - envelope.type === 'INPUT_ACCEPTED' + event.type === EventType.RUN_ERROR && + event.code !== 'RUN_CANCELLED' ) { - replaceAcceptedAttachments( - current.items, - roundId, - envelope.payload, - ); - void options.onInputAccepted?.(); + current.error = event.message || '发送失败,请稍后再试'; } - applyAgentSseEnvelope(current.items, envelope, { roundId }); - scheduleStateUpdate(current); + applyAguiEventToTimeline( + current.items, + event, + { + onInputAccepted(payload) { + replaceAcceptedAttachments(current.items, roundId, payload); + void options.onInputAccepted?.(); + }, + roundId, + startedAt, + }, + projectionState, + ); + scheduleStateUpdate( + current, + event.type === EventType.TOOL_CALL_START, + ); }, - }, - ); + threadId: sessionId, + url: `/api/v1/agent/${encodeURIComponent(options.agentId)}/agui/run`, + userMessage: { + content: options.prompt, + id: `user-${roundId}`, + role: 'user', + }, + }) + .then(() => { + const current = sessions.get(scopedSessionKey); + if (!current || current !== state || !current.sending) { + return; + } + current.sending = false; + current.completed = true; + const cancelled = current.items.some( + (item) => + item.roundId === roundId && + item.turnFinishedAt !== undefined && + item.turnSucceeded === false, + ); + if (!cancelled) { + ChatTimelineBuilder.finalize(current.items, { + roundCompleted: true, + roundId, + turnFinishedAt: Date.now(), + turnSucceeded: true, + }); + } + upsertState(current); + }) + .catch((error) => { + const current = sessions.get(scopedSessionKey); + if (!current || current !== state || !current.sending) { + return; + } + current.error = errorMessage(error); + current.sending = false; + current.completed = true; + const last = current.items[current.items.length - 1]; + if (last?.type !== 'error') { + ChatTimelineBuilder.appendError(current.items, current.error, { + roundId, + }); + } + ChatTimelineBuilder.finalize(current.items, { + roundId, + turnFinishedAt: Date.now(), + turnSucceeded: false, + }); + upsertState(current); + }) + .finally(() => { + if (runClients.get(scopedSessionKey) === runClient) { + runClients.delete(scopedSessionKey); + } + }); return sessionId; }, @@ -542,10 +596,15 @@ export const agentChatRuntimeManager = { if (!state || !state.sending) { return; } - stopAgentChatStream(); + runClients.get(sessionKey(identity, state.sessionId))?.abort(); + runClients.delete(sessionKey(identity, state.sessionId)); state.sending = false; state.completed = true; - ChatTimelineBuilder.finalize(state.items); + ChatTimelineBuilder.finalize(state.items, { + roundId: state.roundId, + turnFinishedAt: Date.now(), + turnSucceeded: false, + }); upsertState(state); }, diff --git a/easyflow-ui-admin/app/src/views/ai/agent-chat/api.ts b/easyflow-ui-admin/app/src/views/ai/agent-chat/api.ts index f42b8cc1..8fb70258 100644 --- a/easyflow-ui-admin/app/src/views/ai/agent-chat/api.ts +++ b/easyflow-ui-admin/app/src/views/ai/agent-chat/api.ts @@ -1,10 +1,6 @@ -import type { ServerSentEventMessage } from 'fetch-event-stream'; - import type { AgentInfo } from '../agents/types'; -import { api, SseClient } from '#/api/request'; - -const agentChatSseClient = new SseClient(); +import { api } from '#/api/request'; export interface RequestResult { data: T; @@ -144,43 +140,17 @@ export function deleteAgentSession(sessionId: number | string) { return api.post(`/api/v1/agent/session/${sessionId}/delete`); } -export function approveAgentRun(requestId: string, resumeToken: string) { - return api.post('/api/v1/agent/run/approve', { - requestId, - resumeToken, +export function approveAgentRun(approvalId: string) { + return api.post('/api/v1/agent/agui/hitl/resolve', { + approvalId, + decision: 'APPROVE', }); } -export function rejectAgentRun( - requestId: string, - resumeToken: string, - reason?: string, -) { - return api.post('/api/v1/agent/run/reject', { - requestId, - resumeToken, +export function rejectAgentRun(approvalId: string, reason?: string) { + return api.post('/api/v1/agent/agui/hitl/resolve', { + approvalId, + decision: 'REJECT', reason, }); } - -export function sendAgentChat( - data: { - agentId: number | string; - capabilities?: AgentChatCapabilityPayload[]; - documentUploadIds?: string[]; - imageUploadIds?: string[]; - prompt: string; - sessionId?: number | string; - }, - options: { - onError?: (error: unknown) => void; - onFinished?: () => void; - onMessage?: (message: ServerSentEventMessage) => void; - }, -) { - return agentChatSseClient.post('/api/v1/agent/chat', data, options); -} - -export function stopAgentChatStream() { - agentChatSseClient.abort(); -} diff --git a/easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue b/easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue index ce395511..f6ead5d0 100644 --- a/easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue +++ b/easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue @@ -51,6 +51,7 @@ import { } from 'element-plus'; import { + createAgentArtifactLoader, loadAgentChatDocument, loadAgentChatImage, } from '#/components/ai-chat/mediaApi'; @@ -91,6 +92,11 @@ const selectedAgentId = ref(''); const currentSessionId = ref(''); const composer = useAgentComposerDraft('FORMAL'); const promptText = composer.text; +const loadCurrentAgentArtifact = createAgentArtifactLoader(() => ({ + agentId: selectedAgentId.value, + mode: 'FORMAL', + sessionId: currentSessionId.value, +})); const promptInputRef = ref(); const attachmentFileInputRef = ref(); const composerDragActive = ref(false); @@ -1043,11 +1049,11 @@ async function handleDeleteSession(session: AgentChatSessionView) { } async function handleApprove(payload: ChatTimelineToolApprovalPayload) { - approvalLoadingKey.value = payload.toolCallId || payload.requestId; + approvalLoadingKey.value = payload.toolCallId || payload.approvalId; ChatTimelineBuilder.markToolApproving(timelineItems.value, payload); persistCurrentRuntimeItems(); try { - const res = await approveAgentRun(payload.requestId, payload.resumeToken); + const res = await approveAgentRun(payload.approvalId); if (res.errorCode !== 0) { throw new Error(res.message || '批准失败'); } @@ -1062,15 +1068,11 @@ async function handleApprove(payload: ChatTimelineToolApprovalPayload) { } async function handleReject(payload: ChatTimelineToolApprovalPayload) { - approvalLoadingKey.value = payload.toolCallId || payload.requestId; + approvalLoadingKey.value = payload.toolCallId || payload.approvalId; ChatTimelineBuilder.markToolApproving(timelineItems.value, payload); persistCurrentRuntimeItems(); try { - const res = await rejectAgentRun( - payload.requestId, - payload.resumeToken, - '用户拒绝执行', - ); + const res = await rejectAgentRun(payload.approvalId, '用户拒绝执行'); if (res.errorCode !== 0) { throw new Error(res.message || '拒绝失败'); } @@ -1242,6 +1244,7 @@ onBeforeUnmount(() => { /* cspell:ignore tryit */ import type { + AgentBuiltinToolCapabilities, AgentCapabilityKind, AgentOption, AgentValidationIssue, } from './types'; -import { computed, onActivated, onDeactivated, onMounted, ref } from 'vue'; +import { + computed, + onActivated, + onDeactivated, + onMounted, + ref, + watch, +} from 'vue'; import { useRoute, useRouter } from 'vue-router'; +import { useUserStore } from '@easyflow/stores'; + import { ArrowLeft } from '@element-plus/icons-vue'; import { ElButton, ElMessage, ElMessageBox } from 'element-plus'; import { tryit } from 'radash'; @@ -26,34 +36,40 @@ import { getAgentDetail, getAgentMcpToolOptions, getAgentResourceOptions, - saveAgent, + saveAgentDraft, submitAgentOfflineApproval, submitAgentPublishApproval, - updateAgent, - updateAgentKnowledgeBindings, - updateAgentToolBindings, } from './api'; import AgentStudioCanvas from './components/agent-studio/AgentStudioCanvas.vue'; import AgentCommandBar from './components/AgentCommandBar.vue'; import AgentInspectorPanel from './components/AgentInspectorPanel.vue'; +import AgentSkillSelectorDialog from './components/AgentSkillSelectorDialog.vue'; import { useAgentDesignerState } from './composables/useAgentDesignerState'; import { resolveAgentCompressionTokenThreshold } from './compression-threshold'; import { createMcpToolLoader } from './mcpToolLoader'; const route = useRoute(); const router = useRouter(); +const userStore = useUserStore(); const AGENT_TAB_PAGE_KEY = '/ai/agents'; const DEFAULT_AGENT_TITLE = '未命名智能体'; const { state, addKnowledgeNode, + appendSkillOptions, addToolNode, buildKnowledgePayload, buildPayloadAgent, + buildSkillPayload, buildToolPayload, + commitBindingBaseline, + getBindingChanges, markDirty, + moveSkill, openTryout, removeSelectedCapability, + removeSkill, + replaceSkillBindings, reset, selectBase, selectNode, @@ -66,6 +82,9 @@ const canvasActive = ref(true); const saveLoading = ref(false); const offlineLoading = ref(false); const publishLoading = ref(false); +const skillOptionsLoading = ref(true); +const skillOptionsError = ref(''); +const skillSelectorOpen = ref(false); const mcpToolsLoading = ref>({}); const issues = ref([]); const categories = ref([]); @@ -74,12 +93,41 @@ const knowledges = ref([]); const workflows = ref([]); const pluginTools = ref([]); const mcps = ref([]); +const skills = ref([]); +const builtinToolCapabilities = ref({}); const fetchMcpToolResource = createMcpToolLoader(async (id) => { const res = await getAgentMcpToolOptions(id); return res.errorCode === 0 ? res.data : undefined; }); const isNew = computed(() => String(route.params.id || '') === 'new'); +const canDisableShellApproval = computed(() => { + const serverCapability = + state.agent.builtinToolCapabilities?.canDisableShellApproval ?? + builtinToolCapabilities.value.canDisableShellApproval; + if (typeof serverCapability === 'boolean') { + return serverCapability; + } + return ( + String(userStore.userInfo?.id || '') === '1' || + userStore.userRoles.includes('super_admin') + ); +}); + +watch( + [ + canDisableShellApproval, + () => state.agent.executionConfigJson?.builtinTools, + ], + ([canDisable]) => { + const builtinTools = state.agent.executionConfigJson?.builtinTools; + if (!canDisable && builtinTools) { + builtinTools.shell.approvalRequired = true; + delete builtinTools.shellApprovalRiskConfirmed; + } + }, + { immediate: true }, +); const publishText = computed(() => { if ( canAiResourceRepublish( @@ -164,6 +212,7 @@ async function refreshAgentLifecycleState() { } const agentState = { ...res.data }; delete agentState.knowledgeBindings; + delete agentState.skillBindings; delete agentState.toolBindings; state.agent = { ...state.agent, @@ -211,11 +260,11 @@ function syncNavTitle(title: string, options: { force?: boolean } = {}) { } async function loadCriticalOptions() { - const [categoryResult, resourceResult] = await Promise.allSettled([ + const [categoryResult] = await Promise.allSettled([ api.get('/api/v1/agentCategory/visibleList', { params: { sortKey: 'sortNo', sortType: 'asc' }, }), - getAgentResourceOptions(), + loadResourceOptions(), ]); if (categoryResult.status === 'fulfilled') { @@ -225,11 +274,19 @@ async function loadCriticalOptions() { raw: item, })); } - if (resourceResult.status === 'fulfilled') { - const resources = resourceResult.value.data; - if (resourceResult.value.errorCode !== 0 || !resources) { +} + +async function loadResourceOptions() { + skillOptionsLoading.value = true; + skillOptionsError.value = ''; + try { + const res = await getAgentResourceOptions(); + const resources = res.data; + if (res.errorCode !== 0 || !resources) { + skillOptionsError.value = res.message || '技能列表加载失败'; return; } + builtinToolCapabilities.value = resources.capabilities || {}; models.value = (resources.models || []).map((item: any) => ({ label: item.title || item.name, value: String(item.id), @@ -240,6 +297,11 @@ async function loadCriticalOptions() { value: String(item.id), raw: item, })); + skills.value = (resources.skills || []).map((item: any) => ({ + label: item.displayName || item.name || '技能', + value: String(item.id), + raw: item, + })); workflows.value = (resources.workflows || []).map((item: any) => ({ label: item.title || item.name, value: String(item.id), @@ -251,6 +313,11 @@ async function loadCriticalOptions() { raw: item, })); mcps.value = mapMcpOptions(resources.mcps || []); + } catch (error) { + console.error('加载 Agent 资源选项失败', error); + skillOptionsError.value = '技能列表加载失败'; + } finally { + skillOptionsLoading.value = false; } } @@ -319,6 +386,14 @@ async function loadMcpToolsForOption(id: number | string) { } async function handleAdd(kind: AgentCapabilityKind) { + if (kind === 'skill') { + if (state.skillBindings.length > 0) { + selectNode('skills'); + } else { + skillSelectorOpen.value = true; + } + return; + } if (kind === 'knowledge') { addKnowledgeNode(); return; @@ -326,6 +401,10 @@ async function handleAdd(kind: AgentCapabilityKind) { addToolNode(kind); } +function handleAddSkills(options: AgentOption[]) { + appendSkillOptions(options); +} + function handleSelectNode(nodeId: string) { selectNode(nodeId); } @@ -365,35 +444,39 @@ async function handleSave(showMessage = true) { syncAgentCompressionThreshold(); saveLoading.value = true; try { - const agentPayload = buildPayloadAgent(); - const agentRes = state.agent.id - ? await updateAgent(agentPayload) - : await saveAgent(agentPayload); + const agentPayload = buildPayloadAgent({ + canDisableShellApproval: canDisableShellApproval.value, + }); + const bindingChanges = getBindingChanges(); + const agentRes = await saveAgentDraft({ + agent: agentPayload, + knowledgeBindings: buildKnowledgePayload(state.agent.id), + replaceKnowledgeBindings: bindingChanges.knowledge, + replaceSkillBindings: bindingChanges.skill, + replaceToolBindings: bindingChanges.tool, + skillBindings: buildSkillPayload(), + toolBindings: buildToolPayload(state.agent.id), + }); if (agentRes.errorCode !== 0 || !agentRes.data?.id) { return false; } const id = agentRes.data.id; - const toolBindingRes = await updateAgentToolBindings( - id, - buildToolPayload(id), - ); - if (toolBindingRes.errorCode !== 0) { - return false; + if (bindingChanges.skill) { + replaceSkillBindings(agentRes.data.skillBindings || []); } - const knowledgeBindingRes = await updateAgentKnowledgeBindings( - id, - buildKnowledgePayload(id), - ); - if (knowledgeBindingRes.errorCode !== 0) { - return false; - } - + const { + knowledgeBindings: _knowledgeBindings, + skillBindings: _skillBindings, + toolBindings: _toolBindings, + ...savedAgent + } = agentRes.data; state.agent = { ...state.agent, - ...agentRes.data, + ...savedAgent, id, }; + commitBindingBaseline(); state.dirty = false; const title = resolveAgentTitle(); if (isNew.value) { @@ -513,6 +596,7 @@ async function handleBack() { @select="handleSelectNode" /> + { + it('keeps the inspector usable at the 768px breakpoint', () => { + expect(inspectorSource).toContain('@media (max-width: 900px)'); + expect(inspectorSource).toMatch( + /@media \(max-width: 900px\)[\s\S]*?left: var\(--space-4\);[\s\S]*?width: auto;/, + ); + }); + + it('keeps the inspector and builtin tool controls usable at 375px', () => { + expect(inspectorSource).toMatch( + /@media \(max-width: 480px\)[\s\S]*?right: var\(--space-2\);[\s\S]*?left: var\(--space-2\);/, + ); + expect(baseFormSource).toMatch( + /@media \(max-width: 480px\)[\s\S]*?--agent-tool-approval-column: 72px;/, + ); + expect(baseFormSource).toContain('white-space: normal;'); + expect(baseFormSource).toContain('-webkit-line-clamp: 2;'); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/ai/agents/api.ts b/easyflow-ui-admin/app/src/views/ai/agents/api.ts index af69bbe5..dd085b15 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/api.ts +++ b/easyflow-ui-admin/app/src/views/ai/agents/api.ts @@ -1,6 +1,8 @@ import type { + AgentBuiltinToolCapabilities, AgentInfo, AgentKnowledgeBinding, + AgentSkillBinding, AgentToolBinding, } from './types'; @@ -26,6 +28,23 @@ export function updateAgent(agent: AgentInfo) { return api.post>('/api/v1/agent/update', agent); } +export interface AgentDraftSavePayload { + agent: AgentInfo; + knowledgeBindings: AgentKnowledgeBinding[]; + replaceKnowledgeBindings: boolean; + replaceSkillBindings: boolean; + replaceToolBindings: boolean; + skillBindings: AgentSkillBinding[]; + toolBindings: AgentToolBinding[]; +} + +export function saveAgentDraft(payload: AgentDraftSavePayload) { + return api.post>( + '/api/v1/agent/draft/save', + payload, + ); +} + export function updateAgentVisibilityScope( id: number | string, visibilityScope: string, @@ -56,6 +75,22 @@ export function updateAgentKnowledgeBindings( ); } +export function updateAgentSkillBindings( + agentId: number | string, + bindings: AgentSkillBinding[], +) { + return api.post>( + '/api/v1/agent/skillBinding/update', + { + agentId, + bindings: bindings.map((binding, index) => ({ + skillId: binding.skillId, + sortNo: index + 1, + })), + }, + ); +} + export function submitAgentPublishApproval(id: number | string) { return api.post>( '/api/v1/agent/submitPublishApproval', @@ -77,21 +112,17 @@ export function submitAgentDeleteApproval(id: number | string) { ); } -export function approveAgentRun(requestId: string, resumeToken: string) { - return api.post('/api/v1/agent/run/approve', { - requestId, - resumeToken, +export function approveAgentRun(approvalId: string) { + return api.post('/api/v1/agent/agui/hitl/resolve', { + approvalId, + decision: 'APPROVE', }); } -export function rejectAgentRun( - requestId: string, - resumeToken: string, - reason?: string, -) { - return api.post('/api/v1/agent/run/reject', { - requestId, - resumeToken, +export function rejectAgentRun(approvalId: string, reason?: string) { + return api.post('/api/v1/agent/agui/hitl/resolve', { + approvalId, + decision: 'REJECT', reason, }); } @@ -109,10 +140,12 @@ export function getAgentCategories() { } export interface AgentResourceOptions { + capabilities?: AgentBuiltinToolCapabilities; knowledges: any[]; mcps: any[]; models: any[]; pluginTools: any[]; + skills: any[]; workflows: any[]; } diff --git a/easyflow-ui-admin/app/src/views/ai/agents/builtin-tools.test.ts b/easyflow-ui-admin/app/src/views/ai/agents/builtin-tools.test.ts new file mode 100644 index 00000000..da4ec295 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/builtin-tools.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildAgentExecutionConfig, + createDefaultBuiltinTools, + normalizeBuiltinTools, +} from './builtin-tools'; + +describe('agent 内置工具配置', () => { + it('新建 Agent 默认启用五项工具且仅 Shell 要求确认', () => { + expect(createDefaultBuiltinTools()).toEqual({ + artifactPublish: { approvalRequired: false, enabled: true }, + patch: { approvalRequired: false, enabled: true }, + read: { approvalRequired: false, enabled: true }, + schemaVersion: 1, + shell: { approvalRequired: true, enabled: true }, + write: { approvalRequired: false, enabled: true }, + }); + }); + + it('旧草稿仅在构造保存载荷时补齐完整配置', () => { + const source = { documentContextBudgetTokens: 32_000 }; + + const normalized = buildAgentExecutionConfig(source, false); + + expect(source).not.toHaveProperty('builtinTools'); + expect(normalized.builtinTools).toEqual(createDefaultBuiltinTools()); + expect(normalized.documentContextBudgetTokens).toBe(32_000); + }); + + it('保留显式开关,并强制普通用户开启 Shell 调用确认', () => { + const builtinTools = normalizeBuiltinTools({ + read: { enabled: false }, + shell: { approvalRequired: false, enabled: true }, + shellApprovalRiskConfirmed: true, + }); + + expect( + buildAgentExecutionConfig({ builtinTools }, false).builtinTools, + ).toMatchObject({ + read: { approvalRequired: false, enabled: false }, + shell: { approvalRequired: true, enabled: true }, + }); + expect( + buildAgentExecutionConfig({ builtinTools }, false).builtinTools, + ).not.toHaveProperty('shellApprovalRiskConfirmed'); + expect( + buildAgentExecutionConfig({ builtinTools }, true).builtinTools?.shell + .approvalRequired, + ).toBe(false); + expect( + buildAgentExecutionConfig({ builtinTools }, true).builtinTools + ?.shellApprovalRiskConfirmed, + ).toBe(true); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/ai/agents/builtin-tools.ts b/easyflow-ui-admin/app/src/views/ai/agents/builtin-tools.ts new file mode 100644 index 00000000..16b9b5b6 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/builtin-tools.ts @@ -0,0 +1,106 @@ +import type { + AgentBuiltinToolConfig, + AgentBuiltinToolsConfig, + AgentExecutionConfig, +} from './types'; + +export const AGENT_BUILTIN_TOOL_KEYS = [ + 'read', + 'write', + 'patch', + 'shell', + 'artifactPublish', +] as const; + +export type AgentBuiltinToolKey = (typeof AGENT_BUILTIN_TOOL_KEYS)[number]; + +type AgentBuiltinToolsInput = Partial< + Record> +> & { + schemaVersion?: number; + shellApprovalRiskConfirmed?: boolean; +}; + +const DEFAULT_BUILTIN_TOOLS: AgentBuiltinToolsConfig = { + schemaVersion: 1, + read: { approvalRequired: false, enabled: true }, + write: { approvalRequired: false, enabled: true }, + patch: { approvalRequired: false, enabled: true }, + shell: { approvalRequired: true, enabled: true }, + artifactPublish: { approvalRequired: false, enabled: true }, +}; + +/** + * 创建新 Agent 使用的内置工具默认配置。 + * + * @returns 五项工具全部启用、仅 Shell 默认确认的独立配置副本。 + */ +export function createDefaultBuiltinTools(): AgentBuiltinToolsConfig { + return { + artifactPublish: { ...DEFAULT_BUILTIN_TOOLS.artifactPublish }, + patch: { ...DEFAULT_BUILTIN_TOOLS.patch }, + read: { ...DEFAULT_BUILTIN_TOOLS.read }, + schemaVersion: 1, + shell: { ...DEFAULT_BUILTIN_TOOLS.shell }, + write: { ...DEFAULT_BUILTIN_TOOLS.write }, + }; +} + +/** + * 将接口中的内置工具配置补全为稳定的五项结构。 + * + * @param value 接口返回的可选配置。 + * @returns 可直接用于表单和保存载荷的规范化配置。 + */ +export function normalizeBuiltinTools( + value?: AgentBuiltinToolsInput, +): AgentBuiltinToolsConfig { + const defaults = createDefaultBuiltinTools(); + const normalizeTool = (key: AgentBuiltinToolKey) => ({ + approvalRequired: + value?.[key]?.approvalRequired ?? defaults[key].approvalRequired, + enabled: value?.[key]?.enabled ?? defaults[key].enabled, + }); + const normalized: AgentBuiltinToolsConfig = { + artifactPublish: normalizeTool('artifactPublish'), + patch: normalizeTool('patch'), + read: normalizeTool('read'), + schemaVersion: 1, + shell: normalizeTool('shell'), + write: normalizeTool('write'), + }; + if (value && 'shellApprovalRiskConfirmed' in value) { + normalized.shellApprovalRiskConfirmed = Boolean( + value.shellApprovalRiskConfirmed, + ); + } + return normalized; +} + +/** + * 构造保存和草稿试用共用的执行配置。 + * + * @param value 当前执行配置。 + * @param canDisableShellApproval 当前用户是否可关闭 Shell 调用确认。 + * @returns 已补齐默认值并落实 Shell 权限边界的配置。 + */ +export function buildAgentExecutionConfig( + value: AgentExecutionConfig | undefined, + canDisableShellApproval: boolean, +): AgentExecutionConfig { + const builtinTools = normalizeBuiltinTools(value?.builtinTools); + if (!canDisableShellApproval) { + builtinTools.shell.approvalRequired = true; + } + if (builtinTools.shell.approvalRequired) { + delete builtinTools.shellApprovalRiskConfirmed; + } + return { + ...value, + builtinTools, + documentContextBudgetTokens: Math.max( + 1, + Math.trunc(Number(value?.documentContextBudgetTokens) || 20_000), + ), + }; +} diff --git a/easyflow-ui-admin/app/src/views/ai/agents/components/AgentBaseForm.vue b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentBaseForm.vue index 1049df74..0439a0f0 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/components/AgentBaseForm.vue +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentBaseForm.vue @@ -1,5 +1,6 @@ + + + + diff --git a/easyflow-ui-admin/app/src/views/ai/agents/components/AgentSkillSelectorDialog.test.ts b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentSkillSelectorDialog.test.ts new file mode 100644 index 00000000..3822c831 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentSkillSelectorDialog.test.ts @@ -0,0 +1,105 @@ +// @vitest-environment happy-dom + +import { mount } from '@vue/test-utils'; +import { defineComponent, h } from 'vue'; + +import { describe, expect, it } from 'vitest'; + +import AgentSkillSelectorDialog from './AgentSkillSelectorDialog.vue'; + +const dialogStub = defineComponent({ + name: 'ElDialog', + props: { + modelValue: Boolean, + title: { default: '', type: String }, + }, + emits: ['update:modelValue'], + setup(props, { slots }) { + return () => + props.modelValue + ? h('section', [ + h('h2', props.title), + slots.default?.(), + slots.footer?.(), + ]) + : null; + }, +}); + +const options = [ + { + label: '合同审查助手', + value: 'skill-1', + raw: { + description: '识别风险条款并生成审查意见', + textResourceCount: 2, + toolCount: 1, + visibilityScope: 'DEPARTMENT', + }, + }, + { + label: '季度经营分析', + value: 'skill-2', + raw: { + description: '汇总经营数据', + textResourceCount: 1, + toolCount: 0, + visibilityScope: 'PRIVATE', + }, + }, +]; + +function mountDialog(props: Record = {}) { + return mount(AgentSkillSelectorDialog, { + global: { + directives: { loading: {} }, + stubs: { ElDialog: dialogStub }, + }, + props: { + modelValue: true, + options, + ...props, + } as never, + }); +} + +describe('agent skill selector dialog', () => { + it('fuzzy searches multiple terms and supports keyboard selection', async () => { + const wrapper = mountDialog(); + + await wrapper.get('input[aria-label="搜索技能"]').setValue('合同 意见'); + expect(wrapper.text()).toContain('合同审查助手'); + expect(wrapper.text()).not.toContain('季度经营分析'); + + await wrapper.get('[role="option"]').trigger('keydown.enter'); + const addButton = wrapper + .findAll('button') + .find((button) => button.text().trim() === '添加'); + await addButton?.trigger('click'); + + expect(wrapper.emitted('add')?.[0]?.[0]).toEqual([options[0]]); + }); + + it('disables new selections after reaching the twenty-skill limit', () => { + const wrapper = mountDialog({ + boundSkillIds: Array.from({ length: 20 }, (_, index) => `bound-${index}`), + }); + + expect(wrapper.text()).toContain('已达到绑定上限'); + expect(wrapper.get('[role="option"]').attributes('aria-disabled')).toBe( + 'true', + ); + }); + + it('shows a retry action when options fail to load', async () => { + const wrapper = mountDialog({ error: '加载失败' }); + const retryButton = wrapper + .findAll('button') + .find((button) => button.text().trim() === '重新加载'); + + await retryButton?.trigger('click'); + + expect(wrapper.text()).toContain('技能列表加载失败'); + expect(wrapper.emitted('retry')).toHaveLength(1); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/ai/agents/components/AgentSkillSelectorDialog.vue b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentSkillSelectorDialog.vue new file mode 100644 index 00000000..c89eecc7 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentSkillSelectorDialog.vue @@ -0,0 +1,397 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/ai/agents/components/AgentTryoutPanel.vue b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentTryoutPanel.vue index 79330129..71895b82 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/components/AgentTryoutPanel.vue +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/AgentTryoutPanel.vue @@ -9,6 +9,7 @@ import type { import type { AgentInfo, AgentKnowledgeBinding, + AgentSkillBinding, AgentToolBinding, } from '../types'; @@ -26,6 +27,7 @@ import { ElButton, ElMessage } from 'element-plus'; import AiChatPanel from '#/components/ai-chat/AiChatPanel.vue'; import { + createAgentArtifactLoader, loadAgentChatDocument, loadAgentChatImage, } from '#/components/ai-chat/mediaApi'; @@ -41,6 +43,7 @@ const props = defineProps<{ agent: AgentInfo; imageEnabled?: boolean; knowledgeBindings: AgentKnowledgeBinding[]; + skillBindings: AgentSkillBinding[]; toolBindings: AgentToolBinding[]; }>(); @@ -61,6 +64,11 @@ const { } = useAgentTryoutStream(); const approvalLoading = ref(false); const composer = useAgentComposerDraft('DRAFT'); +const loadDraftAgentArtifact = createAgentArtifactLoader(() => ({ + agentId: String(props.agent.id || ''), + mode: 'DRAFT', + runtimeSessionId: composer.sessionId.value, +})); const interactionDisplay = computed(() => resolveInteractionDisplay(props.agent), ); @@ -68,6 +76,7 @@ const interactionDisplay = computed(() => function getDraftContext() { return { agent: props.agent, + skillBindings: props.skillBindings, toolBindings: props.toolBindings, knowledgeBindings: props.knowledgeBindings, }; @@ -101,7 +110,12 @@ watch( ); watch( - () => [props.agent, props.knowledgeBindings, props.toolBindings], + () => [ + props.agent, + props.knowledgeBindings, + props.skillBindings, + props.toolBindings, + ], () => { syncCurrentDraftContext(); }, @@ -312,7 +326,7 @@ async function handleApprove(payload: ChatTimelineToolApprovalPayload) { approvalLoading.value = true; markToolApproving(payload); try { - const res = await approveAgentRun(payload.requestId, payload.resumeToken); + const res = await approveAgentRun(payload.approvalId); if (res.errorCode === 0) { ElMessage.success('已批准'); } @@ -334,11 +348,7 @@ async function handleReject(payload: ChatTimelineToolApprovalPayload) { reason: '用户拒绝执行', }); try { - await rejectAgentRun( - payload.requestId, - payload.resumeToken, - '用户拒绝执行', - ); + await rejectAgentRun(payload.approvalId, '用户拒绝执行'); } finally { approvalLoading.value = false; } @@ -397,6 +407,7 @@ async function handleReject(payload: ChatTimelineToolApprovalPayload) { { const icons = { base: Cpu, - knowledge: Files, + knowledge: KnowledgeIcon, mcp: Link, plugin: Connection, + skill: SkillIcon, workflow: Share, }; return icons[props.data.iconKey]; @@ -45,6 +48,12 @@ const iconComponent = computed(() => { {{ data.detail }} + + {{ item }} + + +{{ data.remainingCount }} + + @@ -119,6 +128,12 @@ const iconComponent = computed(() => { min-height: 78px; } +.agent-studio-node--skill { + align-items: flex-start; + width: 240px; + min-height: 120px; +} + .agent-studio-node__icon { display: inline-flex; flex: 0 0 auto; @@ -200,6 +215,30 @@ const iconComponent = computed(() => { -webkit-box-orient: vertical; } +.agent-studio-node__preview { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding-top: var(--space-2); + margin-top: var(--space-2); + overflow: hidden; + font-size: 12px; + line-height: 18px; + color: var(--el-text-color-regular); + border-top: 1px solid hsl(var(--line-subtle)); +} + +.agent-studio-node__preview > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-studio-node__remaining { + font-weight: 650; + color: var(--el-color-primary); +} + @supports not (color: color-mix(in srgb, red, blue)) { .agent-studio-node:hover { border-color: var(--el-color-primary-light-7); diff --git a/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/types.ts b/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/types.ts index af46b054..abf02b65 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/types.ts +++ b/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/types.ts @@ -1,4 +1,4 @@ -import type {AgentCapabilityKind} from '../../types'; +import type { AgentCapabilityKind } from '../../types'; export type AgentStudioNodeKind = 'base' | AgentCapabilityKind; @@ -8,6 +8,8 @@ export interface AgentStudioNodeData { iconKey: AgentStudioNodeKind; id: string; kind: AgentStudioNodeKind; + previewItems?: string[]; + remainingCount?: number; selected: boolean; title: string; } diff --git a/easyflow-ui-admin/app/src/views/ai/agents/composables/agent-studio/useAgentStudioModel.test.ts b/easyflow-ui-admin/app/src/views/ai/agents/composables/agent-studio/useAgentStudioModel.test.ts index 4b0b19f5..75f2272f 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/composables/agent-studio/useAgentStudioModel.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/agents/composables/agent-studio/useAgentStudioModel.test.ts @@ -1,6 +1,9 @@ -import {describe, expect, it} from 'vitest'; +import { describe, expect, it } from 'vitest'; -import {useAgentStudioModel, resolveCapabilityNodePosition} from './useAgentStudioModel'; +import { + resolveCapabilityNodePosition, + useAgentStudioModel, +} from './useAgentStudioModel'; describe('resolveCapabilityNodePosition', () => { it('无视口信息时沿用默认左侧列位置', () => { @@ -59,7 +62,7 @@ describe('resolveCapabilityNodePosition', () => { }); describe('useAgentStudioModel', () => { - it('MCP 绑定缺少资源快照时从选项中回显节点信息', () => { + it('mcp 绑定缺少资源快照时从选项中回显节点信息', () => { const model = useAgentStudioModel( { agent: { @@ -67,6 +70,7 @@ describe('useAgentStudioModel', () => { }, dirty: false, knowledgeBindings: [], + skillBindings: [], panelMode: 'capability', selectedNodeId: 'tool:mcp-1', toolBindings: [ @@ -110,6 +114,7 @@ describe('useAgentStudioModel', () => { }, dirty: false, knowledgeBindings: [], + skillBindings: [], panelMode: 'capability', selectedNodeId: 'tool:plugin-1', toolBindings: [ @@ -141,4 +146,37 @@ describe('useAgentStudioModel', () => { expect(model.value.nodes).toHaveLength(3); expect(optionReads).toBe(1); }); + + it('将多个技能收敛为一个聚合节点', () => { + const model = useAgentStudioModel( + { + agent: { name: '技能智能体' }, + dirty: false, + knowledgeBindings: [], + panelMode: 'capability', + selectedNodeId: 'skills', + skillBindings: [ + { skillId: '1', resourceSummary: { displayName: '合同审查' } }, + { skillId: '2', resourceSummary: { displayName: '经营分析' } }, + { skillId: '3', resourceSummary: { displayName: '知识问答' } }, + { skillId: '4', resourceSummary: { displayName: '客户沟通' } }, + ], + toolBindings: [], + }, + () => 'skills', + ); + + const skillNodes = model.value.nodes.filter( + (node) => node.data.kind === 'skill', + ); + expect(skillNodes).toHaveLength(1); + expect(skillNodes[0]?.data).toEqual( + expect.objectContaining({ + detail: '4 / 20', + previewItems: ['合同审查', '经营分析', '知识问答'], + remainingCount: 1, + selected: true, + }), + ); + }); }); diff --git a/easyflow-ui-admin/app/src/views/ai/agents/composables/agent-studio/useAgentStudioModel.ts b/easyflow-ui-admin/app/src/views/ai/agents/composables/agent-studio/useAgentStudioModel.ts index 99f8de9d..b7f67792 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/composables/agent-studio/useAgentStudioModel.ts +++ b/easyflow-ui-admin/app/src/views/ai/agents/composables/agent-studio/useAgentStudioModel.ts @@ -142,6 +142,28 @@ function buildToolDetail( return resourceName || toolName || fallback; } +function resolveToolNodePresentation(toolType: string) { + if (toolType === 'WORKFLOW') { + return { + fallback: '待选择工作流', + kind: 'workflow' as const, + title: '工作流', + }; + } + if (toolType === 'MCP') { + return { + fallback: '待选择 MCP', + kind: 'mcp' as const, + title: 'MCP', + }; + } + return { + fallback: '待选择插件工具', + kind: 'plugin' as const, + title: '插件', + }; +} + function toFlowPoint( point: { x: number; y: number }, viewport: NonNullable, @@ -296,19 +318,13 @@ export function useAgentStudioModel( const toolNodes = state.toolBindings.map((binding, index) => { const nodeId = `tool:${binding.localId}`; const toolType = String(binding.toolType || '').toUpperCase(); - const isWorkflow = toolType === 'WORKFLOW'; - const isMcp = toolType === 'MCP'; - const matchedOptions = isWorkflow - ? toolOptionLookups.workflow - : isMcp - ? toolOptionLookups.mcp - : toolOptionLookups.plugin; - const fallback = isWorkflow - ? '待选择工作流' - : isMcp - ? '待选择 MCP' - : '待选择插件工具'; - const detail = buildToolDetail(binding, fallback, matchedOptions); + const presentation = resolveToolNodePresentation(toolType); + const matchedOptions = toolOptionLookups[presentation.kind]; + const detail = buildToolDetail( + binding, + presentation.fallback, + matchedOptions, + ); const position = resolveCapabilityNodePosition({ canvasSize: size, fallbackIndex: state.knowledgeBindings.length + index, @@ -324,25 +340,54 @@ export function useAgentStudioModel( width: CAPABILITY_NODE_WIDTH, height: CAPABILITY_NODE_HEIGHT, data: { - badge: isWorkflow ? '工作流' : isMcp ? 'MCP' : '插件', + badge: presentation.title, detail, - iconKey: isWorkflow ? 'workflow' : isMcp ? 'mcp' : 'plugin', + iconKey: presentation.kind, id: nodeId, - kind: isWorkflow ? 'workflow' : isMcp ? 'mcp' : 'plugin', + kind: presentation.kind, selected: selectedNodeId() === nodeId, - title: - detail === fallback - ? isWorkflow - ? '工作流' - : isMcp - ? 'MCP' - : '插件' - : detail, + title: detail === presentation.fallback ? presentation.title : detail, } satisfies AgentStudioNodeData, }; }); - const capabilityNodes = [...knowledgeNodes, ...toolNodes]; + const skillNodes: AgentStudioNodeView[] = []; + if (state.skillBindings.length > 0) { + const nodeId = 'skills'; + const previewItems = state.skillBindings + .slice(0, 3) + .map((binding) => + firstText(binding.resourceSummary?.displayName, '技能'), + ); + const position = resolveCapabilityNodePosition({ + canvasSize: size, + fallbackIndex: + state.knowledgeBindings.length + state.toolBindings.length, + layout, + nodeId, + occupiedPositions, + }); + occupiedPositions.push(position); + skillNodes.push({ + id: nodeId, + type: 'agentStudioCapability', + position, + width: 240, + height: 154, + data: { + detail: `${state.skillBindings.length} / 20`, + iconKey: 'skill', + id: nodeId, + kind: 'skill', + previewItems, + remainingCount: Math.max(0, state.skillBindings.length - 3), + selected: selectedNodeId() === nodeId, + title: '技能', + } satisfies AgentStudioNodeData, + }); + } + + const capabilityNodes = [...knowledgeNodes, ...toolNodes, ...skillNodes]; const edges: AgentStudioEdgeView[] = capabilityNodes.map((node) => ({ id: `edge:${node.id}`, diff --git a/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentDesignerState.test.ts b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentDesignerState.test.ts index d654e13d..6007b4ba 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentDesignerState.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentDesignerState.test.ts @@ -121,3 +121,114 @@ describe('useAgentDesignerState memory compression', () => { ).toBe(128_000); }); }); + +describe('useAgentDesignerState skill bindings', () => { + it('adds unique skills, keeps summaries safe and caps the list at twenty', () => { + const designer = useAgentDesignerState(); + const options = Array.from({ length: 22 }, (_, index) => ({ + label: `技能 ${index + 1}`, + value: String(index + 1), + raw: { + binaryResourceCount: 1, + description: `描述 ${index + 1}`, + internalSnapshot: { secret: true }, + textResourceCount: 2, + toolCount: index, + visibilityScope: 'DEPARTMENT', + }, + })); + + designer.appendSkillOptions([...options, ...options.slice(0, 1)]); + + expect(designer.state.skillBindings).toHaveLength(20); + expect(designer.state.selectedNodeId).toBe('skills'); + expect(designer.state.skillBindings[0]?.resourceSummary).toEqual({ + binaryExcludedCount: 1, + description: '描述 1', + displayName: '技能 1', + hasUpdate: false, + snapshotHash: '', + textResourceCount: 2, + toolCount: 0, + visibilityScope: 'DEPARTMENT', + }); + expect(designer.state.skillBindings[0]?.resourceSummary).not.toHaveProperty( + 'internalSnapshot', + ); + }); + + it('reorders and removes skills while generating a minimal save payload', () => { + const designer = useAgentDesignerState(); + designer.reset({ + id: 'agent-1', + name: '技能智能体', + skillBindings: [ + { skillId: 'skill-1', sortNo: 1 }, + { skillId: 'skill-2', sortNo: 2 }, + { skillId: 'skill-3', sortNo: 3 }, + ], + }); + + designer.moveSkill('skill-3', -1); + designer.removeSkill('skill-1'); + + expect(designer.buildSkillPayload()).toEqual([ + { skillId: 'skill-3', sortNo: 1 }, + { skillId: 'skill-2', sortNo: 2 }, + ]); + expect(designer.buildPayloadAgent()).not.toHaveProperty('skillBindings'); + }); +}); + +describe('useAgentDesignerState binding change tracking', () => { + it('sends only persisted fields and marks only changed binding groups', () => { + const designer = useAgentDesignerState(); + designer.reset({ + id: 'agent-1', + name: '保存性能测试', + knowledgeBindings: [ + { + id: 'knowledge-binding-1', + knowledgeId: 'knowledge-1', + localId: 'local-knowledge', + resourceSummary: { title: '知识库' }, + }, + ], + toolBindings: [ + { + id: 'tool-binding-1', + localId: 'local-tool', + resourceSummary: { title: '工具' }, + targetId: 'tool-1', + toolName: 'lookup', + toolType: 'PLUGIN', + }, + ], + }); + + expect(designer.getBindingChanges()).toEqual({ + knowledge: false, + skill: false, + tool: false, + }); + expect(designer.buildToolPayload('agent-1')[0]).not.toHaveProperty( + 'localId', + ); + expect(designer.buildToolPayload('agent-1')[0]).not.toHaveProperty( + 'resourceSummary', + ); + + const toolBinding = designer.state.toolBindings[0]; + expect(toolBinding).toBeDefined(); + if (!toolBinding) return; + toolBinding.hitlEnabled = true; + expect(designer.getBindingChanges()).toEqual({ + knowledge: false, + skill: false, + tool: true, + }); + + designer.commitBindingBaseline(); + expect(designer.getBindingChanges().tool).toBe(false); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentDesignerState.ts b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentDesignerState.ts index 4450fbf2..ffa90faa 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentDesignerState.ts +++ b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentDesignerState.ts @@ -3,12 +3,20 @@ import type { AgentDraftState, AgentInfo, AgentKnowledgeBinding, + AgentOption, + AgentSkillBinding, + AgentSkillSummary, AgentToolBinding, AgentValidationIssue, } from '../types'; import { computed, reactive } from 'vue'; +import { + buildAgentExecutionConfig, + createDefaultBuiltinTools, + normalizeBuiltinTools, +} from '../builtin-tools'; import { DEFAULT_AGENT_COMPRESSION_TOKEN_THRESHOLD } from '../compression-threshold'; import { buildInteractionConfigPayload, @@ -20,6 +28,12 @@ import { const BASE_NODE_ID = 'agent-base'; const SAFE_TOOL_NAME_PATTERN = /^[\w-]+$/; +export interface AgentBindingChangeSet { + knowledge: boolean; + skill: boolean; + tool: boolean; +} + function createLocalId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; } @@ -35,7 +49,7 @@ function buildFallbackToolName(prefix: string, resource?: Record) { function toolKindFromType( toolType?: string, -): Exclude { +): Exclude { const normalized = String(toolType || '').toUpperCase(); if (normalized === 'WORKFLOW') return 'workflow'; if (normalized === 'MCP') return 'mcp'; @@ -43,7 +57,7 @@ function toolKindFromType( } function resolveToolName( - kind: Exclude, + kind: Exclude, resource?: Record, ) { if (isSafeToolName(resource?.englishName)) { @@ -96,6 +110,7 @@ export function createEmptyAgent(): AgentInfo { categoryId: '', visibilityScope: 'PRIVATE', executionConfigJson: { + builtinTools: createDefaultBuiltinTools(), documentContextBudgetTokens: 20_000, }, modelId: '', @@ -134,6 +149,9 @@ function normalizeAgent(agent?: AgentInfo): AgentInfo { }, executionConfigJson: { ...source.executionConfigJson, + builtinTools: normalizeBuiltinTools( + source.executionConfigJson?.builtinTools, + ), documentContextBudgetTokens: Number(source.executionConfigJson?.documentContextBudgetTokens) > 0 ? Number(source.executionConfigJson?.documentContextBudgetTokens) @@ -202,15 +220,50 @@ function normalizeToolBinding( }; } +function toSkillSummary(source?: Record): AgentSkillSummary { + const raw = source || {}; + return { + binaryExcludedCount: Math.max( + 0, + Number(raw.binaryExcludedCount ?? raw.binaryResourceCount) || 0, + ), + description: String(raw.description || ''), + displayName: String(raw.displayName || raw.name || '技能'), + hasUpdate: Boolean(raw.hasUpdate), + snapshotHash: String(raw.snapshotHash || ''), + textResourceCount: Math.max(0, Number(raw.textResourceCount) || 0), + toolCount: Math.max(0, Number(raw.toolCount) || 0), + visibilityScope: String(raw.visibilityScope || 'PRIVATE'), + }; +} + +function normalizeSkillBinding( + binding: AgentSkillBinding, + index: number, +): AgentSkillBinding { + return { + id: binding.id, + skillId: binding.skillId, + resourceSummary: toSkillSummary(binding.resourceSummary), + sortNo: binding.sortNo ?? index + 1, + }; +} + export function useAgentDesignerState() { const state = reactive({ agent: createEmptyAgent(), knowledgeBindings: [], + skillBindings: [], toolBindings: [], selectedNodeId: BASE_NODE_ID, panelMode: 'base', dirty: false, }); + let savedBindingSignatures = { + knowledge: '', + skill: '', + tool: '', + }; const selectedCapability = computed(() => { if (state.selectedNodeId.startsWith('knowledge:')) { @@ -232,6 +285,12 @@ export function useAgentDesignerState() { binding, }; } + if (state.selectedNodeId === 'skills') { + return { + kind: 'skill' as AgentCapabilityKind, + binding: undefined, + }; + } return undefined; }); @@ -247,9 +306,13 @@ export function useAgentDesignerState() { state.toolBindings = (agent?.toolBindings || []).map((binding, index) => normalizeToolBinding(binding, index), ); + state.skillBindings = (agent?.skillBindings || []).map((binding, index) => + normalizeSkillBinding(binding, index), + ); state.selectedNodeId = BASE_NODE_ID; state.panelMode = 'base'; state.dirty = false; + commitBindingBaseline(); } function selectBase() { @@ -281,7 +344,7 @@ export function useAgentDesignerState() { } function addToolNode( - kind: Exclude, + kind: Exclude, resource?: Record, ) { let toolType = 'PLUGIN'; @@ -306,9 +369,84 @@ export function useAgentDesignerState() { markDirty(); } + function appendSkillOptions(options: AgentOption[]) { + let addedCount = 0; + const existingIds = new Set( + state.skillBindings.map((binding) => String(binding.skillId || '')), + ); + for (const option of options) { + if (state.skillBindings.length >= 20) break; + const skillId = String(option.value || '').trim(); + if (!skillId || existingIds.has(skillId)) continue; + state.skillBindings.push( + normalizeSkillBinding( + { + skillId, + resourceSummary: toSkillSummary({ + ...option.raw, + displayName: option.label, + }), + }, + state.skillBindings.length, + ), + ); + existingIds.add(skillId); + addedCount += 1; + } + if (addedCount > 0) { + state.selectedNodeId = 'skills'; + state.panelMode = 'capability'; + markDirty(); + } + } + + function replaceSkillBindings(bindings: AgentSkillBinding[]) { + state.skillBindings = bindings.map((binding, index) => + normalizeSkillBinding(binding, index), + ); + } + + function removeSkill(skillId?: number | string) { + const normalizedId = String(skillId || ''); + const next = state.skillBindings.filter( + (binding) => String(binding.skillId || '') !== normalizedId, + ); + if (next.length === state.skillBindings.length) return; + state.skillBindings = next.map((binding, index) => ({ + ...binding, + sortNo: index + 1, + })); + if (state.skillBindings.length === 0) selectBase(); + markDirty(); + } + + function moveSkill(skillId: number | string | undefined, offset: -1 | 1) { + const currentIndex = state.skillBindings.findIndex( + (binding) => String(binding.skillId || '') === String(skillId || ''), + ); + const targetIndex = currentIndex + offset; + if ( + currentIndex < 0 || + targetIndex < 0 || + targetIndex >= state.skillBindings.length + ) { + return; + } + const next = [...state.skillBindings]; + const [current] = next.splice(currentIndex, 1); + if (!current) return; + next.splice(targetIndex, 0, current); + state.skillBindings = next.map((binding, index) => ({ + ...binding, + sortNo: index + 1, + })); + markDirty(); + } + function removeSelectedCapability() { const selected = selectedCapability.value; - if (!selected?.binding?.localId) return; + if (!selected || selected.kind === 'skill') return; + if (!selected.binding?.localId) return; if (selected.kind === 'knowledge') { state.knowledgeBindings = state.knowledgeBindings.filter( (item) => item.localId !== selected.binding?.localId, @@ -371,7 +509,9 @@ export function useAgentDesignerState() { return issues; } - function buildPayloadAgent(): AgentInfo { + function buildPayloadAgent(options?: { + canDisableShellApproval?: boolean; + }): AgentInfo { const memoryConfigJson = state.agent.memoryConfigJson || {}; const compressionParameter = tokenOnlyCompressionParameter( memoryConfigJson.compressionParameter, @@ -380,8 +520,14 @@ export function useAgentDesignerState() { maxAttachedMessageCount: _maxAttachedMessageCount, ...restMemoryConfigJson } = memoryConfigJson; + const { + knowledgeBindings: _knowledgeBindings, + skillBindings: _skillBindings, + toolBindings: _toolBindings, + ...agent + } = state.agent; return { - ...state.agent, + ...agent, interactionConfigJson: buildInteractionConfigPayload( state.agent.interactionConfigJson, ), @@ -389,17 +535,10 @@ export function useAgentDesignerState() { ...state.agent.generationConfigJson, stream: state.agent.generationConfigJson?.stream !== false, }, - executionConfigJson: { - ...state.agent.executionConfigJson, - documentContextBudgetTokens: Math.max( - 1, - Math.trunc( - Number( - state.agent.executionConfigJson?.documentContextBudgetTokens, - ) || 20_000, - ), - ), - }, + executionConfigJson: buildAgentExecutionConfig( + state.agent.executionConfigJson, + Boolean(options?.canDisableShellApproval), + ), memoryConfigJson: { ...restMemoryConfigJson, compressionParameter: { @@ -413,9 +552,11 @@ export function useAgentDesignerState() { function buildKnowledgePayload(agentId?: number | string) { return state.knowledgeBindings.map((binding, index) => ({ - ...binding, agentId, + knowledgeId: binding.knowledgeId, enabled: binding.enabled !== false, + optionsJson: binding.optionsJson || {}, + retrievalMode: binding.retrievalMode || 'HYBRID', sortNo: index + 1, })); } @@ -424,16 +565,66 @@ export function useAgentDesignerState() { return state.toolBindings.map((binding, index) => { const isMcp = String(binding.toolType || '').toUpperCase() === 'MCP'; return { - ...binding, agentId, enabled: binding.enabled !== false, + hitlConfigJson: binding.hitlConfigJson || {}, hitlEnabled: Boolean(binding.hitlEnabled), + optionsJson: binding.optionsJson || {}, + targetId: binding.targetId, + toolType: binding.toolType, toolName: isMcp ? '' : binding.toolName, sortNo: index + 1, }; }); } + function buildSkillPayload() { + return state.skillBindings.map((binding, index) => ({ + skillId: binding.skillId, + sortNo: index + 1, + })); + } + + function bindingSignature(value: unknown) { + return JSON.stringify(canonicalValue(value)); + } + + function canonicalValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => canonicalValue(item)); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, item]) => item !== undefined) + .sort(([first], [second]) => first.localeCompare(second)) + .map(([key, item]) => [key, canonicalValue(item)]), + ); + } + return value; + } + + function currentBindingSignatures() { + return { + knowledge: bindingSignature(buildKnowledgePayload()), + skill: bindingSignature(buildSkillPayload()), + tool: bindingSignature(buildToolPayload()), + }; + } + + function getBindingChanges(): AgentBindingChangeSet { + const current = currentBindingSignatures(); + return { + knowledge: current.knowledge !== savedBindingSignatures.knowledge, + skill: current.skill !== savedBindingSignatures.skill, + tool: current.tool !== savedBindingSignatures.tool, + }; + } + + function commitBindingBaseline() { + savedBindingSignatures = currentBindingSignatures(); + } + reset(); return { @@ -441,13 +632,20 @@ export function useAgentDesignerState() { state, selectedCapability, addKnowledgeNode, + appendSkillOptions, addToolNode, buildKnowledgePayload, buildPayloadAgent, + buildSkillPayload, buildToolPayload, + commitBindingBaseline, + getBindingChanges, markDirty, + moveSkill, openTryout, + removeSkill, removeSelectedCapability, + replaceSkillBindings, reset, selectBase, selectNode, diff --git a/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutRawRounds.test.ts b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutRawRounds.test.ts index ebd9203e..72870d70 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutRawRounds.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutRawRounds.test.ts @@ -1,398 +1,266 @@ -import {beforeEach, describe, expect, it} from 'vitest'; +import type { AguiEvent } from '../../shared/agent-agui/client'; -import {useAgentTryoutRawRounds} from './useAgentTryoutRawRounds'; +import { EventType } from '@ag-ui/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { easyFlowAguiCustomEvent } from '../../shared/agent-agui/custom-events'; +import { useAgentTryoutRawRounds } from './useAgentTryoutRawRounds'; + +function event(value: AguiEvent) { + return value; +} describe('useAgentTryoutRawRounds', () => { beforeEach(() => { sessionStorage.clear(); }); - it('按原始事件顺序生成 timeline', () => { - const store = useAgentTryoutRawRounds({ - mode: 'draft', - sessionId: 'session-raw-1', - }); - const roundId = store.createRound('上一轮问题'); - - store.recordEvent(roundId, { - domain: 'LLM', - payload: { reasoning: '先思考' }, - type: 'THINKING', - }); - store.recordEvent(roundId, { - domain: 'LLM', - payload: { delta: '上一轮回答' }, - type: 'MESSAGE', - }); - store.completeRound(roundId); - - expect(store.buildTimelineItems().map((item) => item.type)).toEqual([ - 'message', - 'message', - ]); + afterEach(() => { + vi.useRealTimers(); }); - it('业务引用只用于展示', () => { + it('按原生 AG-UI 事件重建思考和正文', () => { const store = useAgentTryoutRawRounds({ mode: 'draft', - sessionId: 'session-raw-2', - }); - const roundId = store.createRound('查知识库'); - - store.recordEvent(roundId, { - domain: 'BUSINESS', - payload: { - items: [ - { - chunkContent: '知识库原文', - chunkId: 'chunk-1', - documentId: 'doc-1', - knowledgeId: 'kb-1', - }, - ], - }, - type: 'CITATIONS', - }); - store.recordEvent(roundId, { - domain: 'LLM', - payload: { delta: '引用后的回答' }, - type: 'MESSAGE', - }); - store.completeRound(roundId); - - expect(store.buildTimelineItems().map((item) => item.type)).toEqual([ - 'message', - 'knowledge', - 'message', - ]); - }); - - it('AgentScope fragment 工具事件不进入页面时间线', () => { - const store = useAgentTryoutRawRounds({ - mode: 'draft', - sessionId: 'session-raw-fragment', - }); - const roundId = store.createRound('调用内部片段'); - - store.recordEvent(roundId, { - domain: 'TOOL', - payload: { - input: { text: 'fragment' }, - toolCallId: 'fragment-1', - toolName: '__fragment__', - }, - type: 'TOOL_CALL', - }); - store.recordEvent(roundId, { - domain: 'TOOL', - payload: { - output: 'internal', - toolCallId: 'fragment-1', - toolName: '__fragment__', - }, - type: 'TOOL_RESULT', - }); - - expect(store.buildTimelineItems().map((item) => item.type)).toEqual([ - 'message', - ]); - }); - - it('AgentScope context_reload 工具事件不进入页面时间线', () => { - const store = useAgentTryoutRawRounds({ - mode: 'draft', - sessionId: 'session-raw-context-reload', - }); - const roundId = store.createRound('展开第一层'); - - store.recordEvent(roundId, { - domain: 'TOOL', - payload: { - input: { working_context_offload_uuid: 'context-id' }, - toolCallId: 'context-reload-1', - toolName: 'context_reload', - }, - type: 'TOOL_CALL', - }); - store.recordEvent(roundId, { - domain: 'TOOL', - payload: { - output: 'context', - toolCallId: 'context-reload-1', - toolName: 'context_reload', - }, - type: 'TOOL_RESULT', - }); - - expect(store.buildTimelineItems().map((item) => item.type)).toEqual([ - 'message', - ]); - }); - - it('刷新后能从 raw rounds 恢复 timeline', () => { - const first = useAgentTryoutRawRounds({ - mode: 'draft', - sessionId: 'session-raw-5', - }); - const roundId = first.createRound('问题'); - first.recordEvent(roundId, { - domain: 'LLM', - payload: { delta: '回答' }, - type: 'MESSAGE', - }); - first.completeRound(roundId); - - const restored = useAgentTryoutRawRounds({ - mode: 'draft', - sessionId: 'session-raw-5', - }); - - expect(restored.buildTimelineItems().map((item) => item.type)).toEqual([ - 'message', - 'message', - ]); - }); - - it('错误轮次不会被 completeRound 覆盖为成功状态', () => { - const store = useAgentTryoutRawRounds({ - mode: 'draft', - sessionId: 'session-raw-error', - }); - const roundId = store.createRound('会失败的问题'); - store.recordEvent(roundId, { - domain: 'LLM', - payload: { delta: '半截回答' }, - type: 'MESSAGE', - }); - store.recordEvent(roundId, { - domain: 'SYSTEM', - payload: { message: '调用失败' }, - type: 'ERROR', - }); - - store.completeRound(roundId); - - const assistant = store - .buildTimelineItems() - .find((item) => item.type === 'message' && item.role === 'assistant'); - expect(assistant).toMatchObject({ status: 'error' }); - }); - - it('流式重建 timeline 时保持稳定 item id', () => { - const store = useAgentTryoutRawRounds({ - mode: 'draft', - sessionId: 'session-raw-stable-id', + sessionId: 'standard-events', }); const roundId = store.createRound('问题'); - store.recordEvent(roundId, { - domain: 'LLM', - payload: { delta: '你' }, - type: 'MESSAGE', - }); - const firstIds = store.buildTimelineItems().map((item) => item.id); - - store.recordEvent(roundId, { - domain: 'LLM', - payload: { delta: '好' }, - type: 'MESSAGE', - }); - const secondIds = store.buildTimelineItems().map((item) => item.id); - - expect(secondIds).toEqual(firstIds); - }); - - it('审批状态作为展示事件缓存并可刷新恢复', () => { - const first = useAgentTryoutRawRounds({ - mode: 'draft', - sessionId: 'session-raw-approval', - }); - const roundId = first.createRound('审批工具'); - first.recordEvent(roundId, { - domain: 'TOOL', - payload: { - requestId: 'req-1', - resumeToken: 'resume-1', - toolCallId: 'call-approval', - toolName: 'dangerous_tool', - }, - type: 'FORM_REQUEST', - }); - first.recordEvent(roundId, { - domain: 'TOOL', - payload: { - requestId: 'req-1', - resumeToken: 'resume-1', - toolCallId: 'call-approval', - }, - type: 'FORM_APPROVING', - }); - first.flush(); - - const restored = useAgentTryoutRawRounds({ - mode: 'draft', - sessionId: 'session-raw-approval', - }); - const tool = restored - .buildTimelineItems() - .find((item) => item.type === 'tool'); - - expect(tool).toMatchObject({ - status: 'approving', - toolCallId: 'call-approval', - }); - }); - - it('异步工作流轮询事件始终归并到首张审批卡', () => { - const store = useAgentTryoutRawRounds({ - mode: 'draft', - sessionId: 'session-raw-async-tool', - }); - const roundId = store.createRound('生成文档'); - store.recordEvent(roundId, { - domain: 'TOOL', - payload: { - input: { user_input: '写一篇小作文' }, - requestId: 'req-async', - resumeToken: 'resume-async', - toolCallId: 'submit-call-1', - toolName: '文档生成', - }, - type: 'FORM_REQUEST', - }); - store.recordEvent(roundId, { - domain: 'TOOL', - payload: { - asyncTool: true, - phase: 'submit', - sourceToolCallId: 'submit-call-1', - status: 'RUNNING', - taskId: 'task-1', - toolCallId: 'task-1', - toolName: '文档生成', - }, - type: 'TOOL_RESULT', - }); - for (const sourceToolCallId of ['observe-call-1', 'observe-call-2']) { - store.recordEvent(roundId, { - domain: 'TOOL', - payload: { - asyncTool: true, - input: { taskId: 'task-1' }, - phase: 'observe', - sourceToolCallId, - status: 'RUNNING', - taskId: 'task-1', - toolCallId: 'task-1', - toolName: '文档生成', - }, - type: 'TOOL_CALL', - }); - } - store.recordEvent(roundId, { - domain: 'TOOL', - payload: { - asyncTool: true, - phase: 'result', - sourceToolCallId: 'result-call-1', - status: 'SUCCEEDED', - taskId: 'task-1', - toolCallId: 'task-1', - toolName: '文档生成', - }, - type: 'TOOL_RESULT', - }); - - const tools = store - .buildTimelineItems() - .filter((item) => item.type === 'tool'); - - expect(tools).toHaveLength(1); - expect(tools[0]).toMatchObject({ - mode: 'approval', - status: 'success', - taskId: 'task-1', - toolCallId: 'task-1', - toolName: '文档生成', - }); - }); - - it('连续文本增量压缩后刷新内容保持一致', () => { - const sessionId = 'stream-compaction'; - const store = useAgentTryoutRawRounds({ - mode: 'draft', - sessionId, - }); - const roundId = store.createRound('你好'); - const liveItems = store.buildTimelineItems(); - const assistantText = ( - items: ReturnType, - ) => - items - .flatMap((item) => - item.type === 'message' && item.role === 'assistant' - ? item.parts - .filter((part) => part.type === 'text') - .map((part) => part.content) - : [], - ) - .join(''); - - for (const delta of ['你', '好', ',', '世界']) { - const event = store.recordEvent(roundId, { - domain: 'LLM', - payload: { delta }, - type: 'MESSAGE', - }); - if (!event) { - throw new Error('流式事件记录失败'); - } - store.projectEvent(liveItems, roundId, event); - } - - expect(store.currentVariant(roundId)?.runtimeEvents).toHaveLength(1); - expect(store.currentVariant(roundId)?.runtimeEvents[0]?.payload.delta).toBe( - '你好,世界', + store.recordEvent( + roundId, + event({ + delta: '先思考', + messageId: 'reasoning-1', + type: EventType.REASONING_MESSAGE_CONTENT, + }), + ); + store.recordEvent( + roundId, + event({ + delta: '回答', + messageId: 'assistant-1', + type: EventType.TEXT_MESSAGE_CONTENT, + }), + ); + store.recordEvent( + roundId, + event({ + runId: 'run-1', + threadId: 'standard-events', + type: EventType.RUN_FINISHED, + }), ); - expect(assistantText(liveItems)).toBe('你好,世界'); - store.flush(); - const restored = useAgentTryoutRawRounds({ - mode: 'draft', - sessionId, - }); - expect(assistantText(restored.buildTimelineItems())).toBe('你好,世界'); + const messages = store + .buildTimelineItems() + .filter((item) => item.type === 'message'); + expect(messages).toHaveLength(2); + expect(JSON.stringify(messages)).toContain('先思考'); + expect(JSON.stringify(messages)).toContain('回答'); }); - it('存在待持久化增量时结束事件仍立即落盘', () => { - const sessionId = 'terminal-persist'; + it('使用原始轮次时间重放已完成处理时长', () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); const store = useAgentTryoutRawRounds({ mode: 'draft', - sessionId, + sessionId: 'stable-duration', }); - const roundId = store.createRound('结束测试'); + const roundId = store.createRound('问题'); + store.recordEvent( + roundId, + event({ + runId: roundId, + threadId: 'stable-duration', + type: EventType.RUN_STARTED, + }), + ); + vi.setSystemTime(19_000); + store.recordEvent( + roundId, + event({ + delta: '回答', + messageId: 'assistant-1', + type: EventType.TEXT_MESSAGE_CONTENT, + }), + ); + store.recordEvent( + roundId, + event({ + runId: roundId, + threadId: 'stable-duration', + type: EventType.RUN_FINISHED, + }), + ); - store.recordEvent(roundId, { - domain: 'LLM', - payload: { reasoning: '思考中' }, - type: 'THINKING', - }); - store.recordEvent(roundId, { - domain: 'SYSTEM', - payload: {}, - type: 'DONE', - }); + const turnItems = store + .buildTimelineItems() + .filter( + (item) => + item.roundId === roundId && + !(item.type === 'message' && item.role === 'user'), + ); + expect(turnItems.length).toBeGreaterThan(0); + expect(turnItems.every((item) => item.turnStartedAt === 1000)).toBe(true); + expect(turnItems.every((item) => item.turnFinishedAt === 19_000)).toBe( + true, + ); + }); - const restored = useAgentTryoutRawRounds({ - mode: 'draft', - sessionId, - }); - expect(restored.currentVariant(roundId)?.status).toBe('completed'); + it('持久化并恢复 EasyFlow 自定义引用事件', () => { + const sessionId = 'custom-citations'; + const store = useAgentTryoutRawRounds({ mode: 'draft', sessionId }); + const roundId = store.createRound('查知识库'); + store.recordEvent( + roundId, + event({ + name: easyFlowAguiCustomEvent.knowledgeCitations, + type: EventType.CUSTOM, + value: { + items: [{ chunkContent: '知识库原文', id: 'chunk-1' }], + }, + }), + ); + store.flush(); + + const restored = useAgentTryoutRawRounds({ mode: 'draft', sessionId }); expect( restored - .currentVariant(roundId) - ?.runtimeEvents.some( - (event) => event.domain === 'SYSTEM' && event.type === 'DONE', + .buildTimelineItems() + .some( + (item) => + item.type === 'message' && Boolean(item.knowledgeItems?.length), ), ).toBe(true); }); + + it('按工具调用 ID 合并标准工具参数与结果', () => { + const store = useAgentTryoutRawRounds({ + mode: 'draft', + sessionId: 'tool-events', + }); + const roundId = store.createRound('调用工具'); + for (const item of [ + event({ + toolCallId: 'call-1', + toolCallName: 'calculator', + type: EventType.TOOL_CALL_START, + }), + event({ + delta: '{"value":1}', + toolCallId: 'call-1', + type: EventType.TOOL_CALL_ARGS, + }), + event({ + content: '2', + messageId: 'tool-result-1', + role: 'tool', + toolCallId: 'call-1', + type: EventType.TOOL_CALL_RESULT, + }), + ]) { + store.recordEvent(roundId, item); + } + + expect( + store.buildTimelineItems().find((item) => item.type === 'tool'), + ).toMatchObject({ + input: { value: 1 }, + output: '2', + status: 'success', + toolCallId: 'call-1', + }); + }); + + it('审批缓存仅保存不透明 approvalId', () => { + const store = useAgentTryoutRawRounds({ + mode: 'draft', + sessionId: 'approval-events', + }); + const roundId = store.createRound('审批工具'); + store.recordEvent( + roundId, + event({ + name: easyFlowAguiCustomEvent.toolApprovalRequired, + type: EventType.CUSTOM, + value: { + approvalId: 'approval-public', + toolCallId: 'call-approval', + toolName: 'dangerous_tool', + }, + }), + ); + store.flush(); + + const serialized = JSON.stringify(store.currentVariant(roundId)); + expect(serialized).toContain('approval-public'); + expect(serialized).not.toContain('resumeToken'); + const tool = store + .buildTimelineItems() + .find((item) => item.type === 'tool'); + expect(tool?.type === 'tool' && tool.approval?.approvalId).toBe( + 'approval-public', + ); + }); + + it('压缩连续标准文本增量且刷新内容一致', () => { + const sessionId = 'stream-compaction'; + const store = useAgentTryoutRawRounds({ mode: 'draft', sessionId }); + const roundId = store.createRound('你好'); + for (const delta of ['你', '好', ',', '世界']) { + store.recordEvent( + roundId, + event({ + delta, + messageId: 'assistant-1', + type: EventType.TEXT_MESSAGE_CONTENT, + }), + ); + } + store.flush(); + + expect(store.currentVariant(roundId)?.runtimeEvents).toHaveLength(1); + const restored = useAgentTryoutRawRounds({ mode: 'draft', sessionId }); + expect(JSON.stringify(restored.buildTimelineItems())).toContain( + '你好,世界', + ); + }); + + it('记录 SDK 或 Vue 代理事件时转换为可持久化 JSON 数据', () => { + const store = useAgentTryoutRawRounds({ + mode: 'draft', + sessionId: 'proxied-event', + }); + const roundId = store.createRound('代理事件'); + const proxiedEvent = new Proxy( + event({ + delta: '代理响应', + messageId: 'assistant-proxy', + type: EventType.TEXT_MESSAGE_CONTENT, + }), + {}, + ); + + expect(() => store.recordEvent(roundId, proxiedEvent)).not.toThrow(); + store.flush(); + expect(JSON.stringify(store.buildTimelineItems())).toContain('代理响应'); + }); + + it('rUN_ERROR 终态立即持久化为失败轮次', () => { + const sessionId = 'terminal-error'; + const store = useAgentTryoutRawRounds({ mode: 'draft', sessionId }); + const roundId = store.createRound('失败测试'); + store.recordEvent( + roundId, + event({ + message: '调用失败', + runId: 'run-1', + threadId: sessionId, + type: EventType.RUN_ERROR, + }), + ); + + const restored = useAgentTryoutRawRounds({ mode: 'draft', sessionId }); + expect(restored.currentVariant(roundId)?.status).toBe('error'); + expect(JSON.stringify(restored.buildTimelineItems())).toContain('调用失败'); + }); }); diff --git a/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutRawRounds.ts b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutRawRounds.ts index a1d2c997..b79e45f8 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutRawRounds.ts +++ b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutRawRounds.ts @@ -2,20 +2,21 @@ import type { ChatDocumentAttachment, ChatImageAttachment, ChatTimelineItem, - ChatTimelineKnowledgeHit, ChatTimelineMessageItem, - ChatTimelineToolStatus, } from '@easyflow/common-ui'; +import type { AguiEvent } from '../../shared/agent-agui/client'; + import { ChatTimelineBuilder } from '@easyflow/common-ui'; -interface AgentTryoutRuntimeEvent { - createdAt: number; - domain: string; - payload: Record; - type: string; -} +import { EventSchemas, EventType } from '@ag-ui/client'; +import { + applyAguiEventToTimeline, + createAguiTimelineProjectionState, +} from '../../shared/agent-agui/projection'; + +type AgentTryoutRuntimeEvent = AguiEvent; type AgentTryoutRoundStatus = 'completed' | 'error' | 'running'; interface AgentTryoutRawVariant { @@ -44,7 +45,7 @@ interface AgentTryoutRawSessionRecord { version: number; } -const STORAGE_VERSION = 2; +const STORAGE_VERSION = 3; const MAX_ROUNDS = 50; const MAX_VARIANTS = 10; const STORAGE_PREFIX = 'easyflow:agent-tryout-raw-rounds'; @@ -55,41 +56,11 @@ function createRoundId() { return `round-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; } -function asText(value: unknown) { - return value === null || value === undefined ? '' : String(value); -} - -function asRecord(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function asBoolean(value: unknown) { - if (typeof value === 'boolean') { - return value; - } - if (typeof value === 'string') { - return value.toLowerCase() === 'true'; - } - return Boolean(value); -} - -function normalizeToolName(value: unknown) { - return asText(value).trim().toLowerCase(); -} - -function isHiddenToolName(value: unknown) { - const normalizedName = normalizeToolName(value); - return ( - normalizedName === 'retrieve_knowledge' || - normalizedName === 'context_reload' || - normalizedName === '__fragment__' - ); -} - function clone(value: T): T { - return structuredClone(value); + // 草稿缓存最终写入 sessionStorage,仅接受 JSON 数据;JSON 往返同时解除 Vue/SDK Proxy, + // 避免 structuredClone 对代理对象抛出 DataCloneError。 + // eslint-disable-next-line unicorn/prefer-structured-clone -- structuredClone 无法复制 Proxy + return JSON.parse(JSON.stringify(value)) as T; } function storageKey(mode: string, sessionId: string) { @@ -115,32 +86,18 @@ function createVariant(variantIndex: number): AgentTryoutRawVariant { }; } -function normalizeRuntimeEvent( - value: any, -): AgentTryoutRuntimeEvent | undefined { - if (!value || typeof value !== 'object') { - return undefined; - } - const domain = asText(value.domain).toUpperCase(); - const type = asText(value.type).toUpperCase(); - if (!domain || !type) { - return undefined; - } - return { - createdAt: Number(value.createdAt || Date.now()), - domain, - payload: asRecord(value.payload), - type, - }; +function normalizeRuntimeEvent(value: unknown) { + const parsed = EventSchemas.safeParse(value); + return parsed.success ? parsed.data : undefined; } -function normalizeVariant(value: any, index: number) { +function normalizeVariant(value: any, index: number): AgentTryoutRawVariant { if (!value || typeof value !== 'object') { return createVariant(index); } const runtimeEvents = Array.isArray(value.runtimeEvents) ? value.runtimeEvents - .map((item: any) => normalizeRuntimeEvent(item)) + .map((item: unknown) => normalizeRuntimeEvent(item)) .filter( ( item: AgentTryoutRuntimeEvent | undefined, @@ -163,8 +120,8 @@ function normalizeRound(value: any): AgentTryoutRawRound | undefined { if (!value || typeof value !== 'object') { return undefined; } - const prompt = asText(value.prompt); - const roundId = asText(value.roundId); + const prompt = String(value.prompt || ''); + const roundId = String(value.roundId || ''); const documents = Array.isArray(value.documents) ? value.documents.slice(0, 3) : []; @@ -175,7 +132,9 @@ function normalizeRound(value: any): AgentTryoutRawRound | undefined { const variants = Array.isArray(value.variants) ? value.variants .slice(-MAX_VARIANTS) - .map((item: any, index: number) => normalizeVariant(item, index + 1)) + .map((item: unknown, index: number) => + normalizeVariant(item, index + 1), + ) : []; if (variants.length === 0) { variants.push(createVariant(1)); @@ -207,14 +166,10 @@ function restoreSession(mode: string, sessionId: string) { return memoryRecords.map((item) => clone(item)); } const storage = safeSessionStorage(); - if (!storage) { - return []; - } + if (!storage) return []; try { const raw = storage.getItem(key); - if (!raw) { - return []; - } + if (!raw) return []; const parsed = JSON.parse(raw) as AgentTryoutRawSessionRecord; if (parsed.sessionId !== sessionId || parsed.version !== STORAGE_VERSION) { return []; @@ -224,10 +179,7 @@ function restoreSession(mode: string, sessionId: string) { .map((item) => normalizeRound(item)) .filter((item): item is AgentTryoutRawRound => item !== undefined) : []; - memorySessions.set( - key, - rounds.map((item) => clone(item)), - ); + memorySessions.set(key, clone(rounds)); return rounds; } catch { return []; @@ -240,37 +192,29 @@ function persistSession( rounds: AgentTryoutRawRound[], ) { const key = storageKey(mode, sessionId); - const snapshot: AgentTryoutRawSessionRecord = { - rounds: rounds.slice(-MAX_ROUNDS).map((item) => clone(item)), - sessionId, - version: STORAGE_VERSION, - }; - memorySessions.set( - key, - snapshot.rounds.map((item) => clone(item)), - ); - const storage = safeSessionStorage(); - if (!storage) { - return; - } try { - storage.setItem(key, JSON.stringify(snapshot)); + // 同一份 JSON 同时用于解除 Proxy、内存快照和 sessionStorage, + // 避免对完整草稿会话重复做全量序列化。 + const serialized = JSON.stringify({ + rounds: rounds.slice(-MAX_ROUNDS), + sessionId, + version: STORAGE_VERSION, + } satisfies AgentTryoutRawSessionRecord); + const snapshot = JSON.parse(serialized) as AgentTryoutRawSessionRecord; + memorySessions.set(key, snapshot.rounds); + safeSessionStorage()?.setItem(key, serialized); } catch { - // 试运行缓存失败不影响当前聊天主流程。 + // 本地试用缓存失败不影响当前聊天。 } } function removeStoredSession(mode: string, sessionId: string) { const key = storageKey(mode, sessionId); memorySessions.delete(key); - const storage = safeSessionStorage(); - if (!storage) { - return; - } try { - storage.removeItem(key); + safeSessionStorage()?.removeItem(key); } catch { - // 清理缓存失败不影响界面重置。 + // 清理本地缓存失败不影响界面重置。 } } @@ -282,50 +226,36 @@ function selectedVariant(round: AgentTryoutRawRound) { ); } -function streamingPayloadKey(event: AgentTryoutRuntimeEvent) { - if (event.domain !== 'LLM') { - return undefined; - } - let candidates: string[] = []; - if (event.type === 'MESSAGE') { - candidates = ['delta']; - } else if (event.type === 'THINKING') { - candidates = ['reasoning', 'delta', 'text']; - } - return candidates.find((key) => typeof event.payload[key] === 'string'); -} - function appendRuntimeEvent( variant: AgentTryoutRawVariant, event: AgentTryoutRuntimeEvent, ) { const previous = variant.runtimeEvents[variant.runtimeEvents.length - 1]; - const currentKey = streamingPayloadKey(event); - const previousKey = previous ? streamingPayloadKey(previous) : undefined; if ( - previous && - previous.domain === event.domain && - previous.type === event.type && - currentKey && - currentKey === previousKey + previous?.type === EventType.TEXT_MESSAGE_CONTENT && + event.type === EventType.TEXT_MESSAGE_CONTENT && + previous.messageId === event.messageId ) { - previous.payload = { - ...previous.payload, - ...event.payload, - [currentKey]: - asText(previous.payload[currentKey]) + - asText(event.payload[currentKey]), - }; + previous.delta += event.delta; return; } - variant.runtimeEvents.push(event); -} - -function visibleText(item: ChatTimelineMessageItem) { - return item.parts - .filter((part) => part.type === 'text') - .map((part) => part.content) - .join(''); + if ( + previous?.type === EventType.REASONING_MESSAGE_CONTENT && + event.type === EventType.REASONING_MESSAGE_CONTENT && + previous.messageId === event.messageId + ) { + previous.delta += event.delta; + return; + } + if ( + previous?.type === EventType.TOOL_CALL_ARGS && + event.type === EventType.TOOL_CALL_ARGS && + previous.toolCallId === event.toolCallId + ) { + previous.delta += event.delta; + return; + } + variant.runtimeEvents.push(clone(event)); } function isUserMessage( @@ -340,335 +270,37 @@ function isAssistantMessage( return item.type === 'message' && item.role === 'assistant'; } -function findRoundResponseRange(items: ChatTimelineItem[], roundId: string) { - const userIndex = items.findIndex( - (item) => isUserMessage(item) && item.roundId === roundId, - ); - if (userIndex === -1) { - return undefined; - } - const nextUserIndex = items.findIndex( - (item, index) => index > userIndex && isUserMessage(item), - ); - return { - end: nextUserIndex === -1 ? items.length : nextUserIndex, - start: userIndex + 1, - }; -} - -function assistantSegmentIndex(items: ChatTimelineItem[], roundId: string) { - return items.filter( - (item) => isAssistantMessage(item) && item.roundId === roundId, - ).length; -} - -function nextAssistantId( - items: ChatTimelineItem[], - roundId: string, - variantIndex: number, -) { - const last = items[items.length - 1]; - if ( - last && - isAssistantMessage(last) && - last.roundId === roundId && - last.status !== 'done' - ) { - return last.id; - } - return `assistant-${roundId}-${variantIndex}-${assistantSegmentIndex(items, roundId) + 1}`; -} - -function normalizeAssistantPartIds( - items: ChatTimelineItem[], - roundId: string, - variantIndex: number, -) { - const segment = assistantSegmentIndex(items, roundId); - const latest = [...items] - .reverse() - .find( - (item): item is ChatTimelineMessageItem => - isAssistantMessage(item) && item.roundId === roundId, - ); - if (!latest) { - return; - } - latest.id = `assistant-${roundId}-${variantIndex}-${segment}`; - latest.parts.forEach((part, index) => { - part.id = `${part.type}-${roundId}-${variantIndex}-${segment}-${index + 1}`; - }); -} - -function normalizeLatestItemId( - items: ChatTimelineItem[], - prefix: string, - roundId: string, - variantIndex: number, -) { - const last = items[items.length - 1]; - if (!last) { - return; - } - last.id = `${prefix}-${roundId}-${variantIndex}`; +function visibleText(item: ChatTimelineMessageItem) { + return item.parts + .filter((part) => part.type === 'text') + .map((part) => part.content) + .join(''); } function markRoundCompleted( items: ChatTimelineItem[], - roundId: string, + round: AgentTryoutRawRound, variant: AgentTryoutRawVariant, - variantCount: number, - selectedVariantIndex: number, ) { - const range = findRoundResponseRange(items, roundId); - const source = range ? items.slice(range.start, range.end) : items; - const latest = [...source] - .reverse() - .find((item): item is ChatTimelineMessageItem => isAssistantMessage(item)); - if (!latest) { - return; - } + const userIndex = items.findIndex( + (item) => isUserMessage(item) && item.roundId === round.roundId, + ); + const nextUserIndex = items.findIndex( + (item, index) => index > userIndex && isUserMessage(item), + ); + const source = items.slice( + userIndex + 1, + nextUserIndex === -1 ? items.length : nextUserIndex, + ); + const latest = [...source].reverse().find((item) => isAssistantMessage(item)); + if (!latest) return; latest.roundCompleted = true; latest.status = latest.status === 'error' ? 'error' : 'done'; latest.regenerable = true; - latest.switchable = variantCount > 1; - latest.variantCount = variantCount; + latest.switchable = round.variants.length > 1; + latest.variantCount = round.variants.length; latest.variantIndex = variant.variantIndex; - latest.selectedVariantIndex = selectedVariantIndex; -} - -function normalizeKnowledgeItems(payload: Record) { - const source = - payload.items || - payload.hits || - payload.documents || - payload.knowledgeResults || - []; - if (!Array.isArray(source)) { - return []; - } - const topLevelKnowledgeType = asText(payload.knowledgeType); - const topLevelFaqCollection = - payload.faqCollection === undefined - ? topLevelKnowledgeType.toUpperCase() === 'FAQ' - : asBoolean(payload.faqCollection); - return source.map((item: any) => { - const metadata = asRecord(item.metadata); - const sourceFileName = asText( - item.sourceFileName ?? metadata.sourceFileName, - ); - const documentName = asText( - item.documentName ?? item.documentTitle ?? item.title, - ); - const chunkId = asText(item.chunkId ?? metadata.chunkId ?? item.id); - const documentId = asText( - item.documentId ?? metadata.documentId ?? item.id, - ); - return { - ...item, - id: asText(item.id || chunkId || documentId), - knowledgeId: asText(item.knowledgeId ?? payload.knowledgeId), - knowledgeName: asText(item.knowledgeName ?? payload.knowledgeName), - knowledgeType: asText(item.knowledgeType ?? payload.knowledgeType), - faqCollection: - item.faqCollection === undefined - ? topLevelFaqCollection - : asBoolean(item.faqCollection), - documentId, - documentName, - chunkId, - score: item.score ?? item.similarity, - source: item.source, - sourceFileName, - sourceUri: asText(item.sourceUri ?? metadata.sourceUri), - metadata, - chunkContent: asText( - item.chunkContent ?? item.content ?? item.text ?? item.summary, - ), - content: asText(item.content ?? item.text ?? item.summary), - title: documentName || sourceFileName || item.source, - } satisfies ChatTimelineKnowledgeHit; - }); -} - -function statusKeyForProjection( - payload: Record, - roundId: string, - variantIndex: number, - fallback = 'status', -) { - const statusKey = asText(payload.statusKey) || fallback; - return `${statusKey}:${roundId}:${variantIndex}`; -} - -function projectEventToTimeline( - items: ChatTimelineItem[], - event: AgentTryoutRuntimeEvent, - roundId: string, - variantIndex: number, -) { - const { domain, payload, type } = event; - if (domain === 'LLM' && type === 'MESSAGE') { - ChatTimelineBuilder.appendMessageDelta(items, payload.delta, { - id: nextAssistantId(items, roundId, variantIndex), - roundId, - }); - normalizeAssistantPartIds(items, roundId, variantIndex); - return; - } - if (domain === 'LLM' && type === 'THINKING') { - const text = asText(payload.reasoning ?? payload.delta ?? payload.text); - ChatTimelineBuilder.appendThinkingDelta(items, text, { - id: nextAssistantId(items, roundId, variantIndex), - roundId, - }); - normalizeAssistantPartIds(items, roundId, variantIndex); - return; - } - if (domain === 'TOOL' && type === 'FORM_REQUEST') { - ChatTimelineBuilder.appendToolApproval(items, { - expiresAt: asText(payload.expiresAt), - input: payload.input, - metadata: payload.metadata, - requestId: asText(payload.requestId), - resumeToken: asText(payload.resumeToken), - toolCallId: asText( - payload.toolCallId ?? payload.tool_call_id ?? payload.id, - ), - toolDisplayName: asText(payload.toolDisplayName), - toolName: asText(payload.toolName), - toolType: asText(payload.toolType), - }); - if (items[items.length - 1]?.type === 'tool') { - normalizeLatestItemId(items, 'tool-approval', roundId, variantIndex); - } - return; - } - if (domain === 'TOOL' && type === 'FORM_APPROVING') { - ChatTimelineBuilder.markToolApproving(items, { - requestId: asText(payload.requestId), - resumeToken: asText(payload.resumeToken), - toolCallId: asText( - payload.toolCallId ?? payload.tool_call_id ?? payload.id, - ), - }); - return; - } - if (domain === 'TOOL' && type === 'FORM_REJECTED') { - ChatTimelineBuilder.markToolRejected(items, { - reason: asText(payload.reason), - requestId: asText(payload.requestId), - resumeToken: asText(payload.resumeToken), - toolCallId: asText( - payload.toolCallId ?? payload.tool_call_id ?? payload.id, - ), - }); - return; - } - if (domain === 'TOOL' && (type === 'TOOL_CALL' || type === 'TOOL_RESULT')) { - const rawToolName = asText(payload.toolName ?? payload.name); - const normalizedToolName = normalizeToolName(rawToolName); - if (!normalizedToolName && type === 'TOOL_CALL') { - return; - } - const displayToolName = asText( - payload.toolDisplayName ?? rawToolName ?? '工具', - ); - const asyncTool = payload.asyncTool === true; - const taskInput = asRecord(payload.input ?? payload.toolInput); - let status: ChatTimelineToolStatus = 'running'; - if (asyncTool) { - status = asyncToolTimelineStatus(payload); - } else if (type === 'TOOL_RESULT') { - status = 'success'; - } - let toolName = displayToolName; - if (!asyncTool && isHiddenToolName(rawToolName)) { - toolName = rawToolName; - } - ChatTimelineBuilder.upsertToolCall(items, { - input: payload.input ?? payload.toolInput, - output: asyncTool - ? (payload.summary ?? - payload.label ?? - payload.output ?? - payload.result ?? - payload.text) - : (payload.output ?? payload.result ?? payload.text), - status, - statusKey: statusKeyForProjection( - payload, - roundId, - variantIndex, - 'knowledge-retrieval', - ), - sourceToolCallId: asyncTool - ? asText(payload.sourceToolCallId ?? payload.source_tool_call_id) - : undefined, - taskId: asyncTool - ? asText(payload.taskId ?? taskInput.taskId ?? taskInput.task_id) - : undefined, - toolCallId: asText( - payload.toolCallId ?? - payload.taskId ?? - payload.tool_call_id ?? - payload.id, - ), - toolName, - }); - return; - } - if (domain === 'BUSINESS' && type === 'CITATIONS') { - const itemsToAppend = normalizeKnowledgeItems(payload); - if (itemsToAppend.length > 0) { - ChatTimelineBuilder.appendKnowledge(items, itemsToAppend); - if (items[items.length - 1]?.type === 'knowledge') { - normalizeLatestItemId(items, 'knowledge', roundId, variantIndex); - } - } - return; - } - if (domain === 'BUSINESS' && type === 'STATUS') { - if (asText(payload.statusKey) === 'memory-compression') { - ChatTimelineBuilder.upsertMemoryCompressionStatus(items, { - compressed: - typeof payload.compressed === 'boolean' - ? payload.compressed - : undefined, - label: asText(payload.label), - phase: asText(payload.phase), - status: asText(payload.status), - statusKey: statusKeyForProjection(payload, roundId, variantIndex), - }); - return; - } - if (asText(payload.statusKey) === 'knowledge-retrieval') { - ChatTimelineBuilder.upsertKnowledgeRetrievalStatus( - items, - asText(payload.status) === 'running' ? 'running' : 'done', - statusKeyForProjection(payload, roundId, variantIndex), - ); - } - return; - } - if (type === 'ERROR' || domain === 'ERROR') { - ChatTimelineBuilder.appendError( - items, - payload.message ?? payload.error ?? '试运行失败', - ); - normalizeLatestItemId(items, 'error', roundId, variantIndex); - } -} - -function asyncToolTimelineStatus( - payload: Record, -): ChatTimelineToolStatus { - const status = asText(payload.status).toUpperCase(); - if (status === 'SUCCEEDED') return 'success'; - if (status === 'FAILED' || status === 'TIMEOUT' || status === 'CANCELLED') { - return 'error'; - } - return 'running'; + latest.selectedVariantIndex = round.selectedVariantIndex; } function sortedRounds(rounds: Map) { @@ -684,6 +316,10 @@ export function useAgentTryoutRawRounds(options: { sessionId: string; }) { const rounds = new Map(); + const liveProjectionStates = new Map< + string, + ReturnType + >(); let persistTimer: ReturnType | undefined; for (const round of restoreSession(options.mode, options.sessionId)) { @@ -705,20 +341,15 @@ export function useAgentTryoutRawRounds(options: { } function schedulePersist() { - if (persistTimer) { - return; - } - persistTimer = setTimeout(() => { - persistNow(); - }, PERSIST_DEBOUNCE_MS); + if (persistTimer) return; + persistTimer = setTimeout(persistNow, PERSIST_DEBOUNCE_MS); } function clear() { rounds.clear(); - if (persistTimer) { - clearTimeout(persistTimer); - persistTimer = undefined; - } + liveProjectionStates.clear(); + if (persistTimer) clearTimeout(persistTimer); + persistTimer = undefined; removeStoredSession(options.mode, options.sessionId); } @@ -746,10 +377,7 @@ export function useAgentTryoutRawRounds(options: { function regenerateRound(roundId: string) { const round = rounds.get(roundId); - if (!round) { - return undefined; - } - const nextVariantIndex = Math.min(round.variants.length + 1, MAX_VARIANTS); + if (!round) return undefined; round.variants.push(createVariant(round.variants.length + 1)); if (round.variants.length > MAX_VARIANTS) { round.variants.splice(0, round.variants.length - MAX_VARIANTS); @@ -757,7 +385,7 @@ export function useAgentTryoutRawRounds(options: { variant.variantIndex = index + 1; }); } - round.selectedVariantIndex = nextVariantIndex; + round.selectedVariantIndex = round.variants.length; round.status = 'running'; round.updatedAt = Date.now(); persistNow(); @@ -773,68 +401,60 @@ export function useAgentTryoutRawRounds(options: { return round ? selectedVariant(round) : undefined; } - function recordEvent( - roundId: string, - event: { - domain: string; - payload?: Record; - type: string; - }, - ) { + function recordEvent(roundId: string, event: AgentTryoutRuntimeEvent) { const round = rounds.get(roundId); const variant = round && selectedVariant(round); - if (!round || !variant) { - return; - } - const runtimeEvent: AgentTryoutRuntimeEvent = { - createdAt: Date.now(), - domain: event.domain.toUpperCase(), - payload: event.payload || {}, - type: event.type.toUpperCase(), - }; - appendRuntimeEvent(variant, runtimeEvent); - if (runtimeEvent.domain === 'SYSTEM' && runtimeEvent.type === 'DONE') { + if (!round || !variant) return undefined; + appendRuntimeEvent(variant, event); + if (event.type === EventType.RUN_FINISHED) { variant.status = 'completed'; round.status = 'completed'; - } - if (runtimeEvent.type === 'ERROR' || runtimeEvent.domain === 'ERROR') { + } else if (event.type === EventType.RUN_ERROR) { variant.status = 'error'; round.status = 'error'; } variant.updatedAt = Date.now(); round.updatedAt = variant.updatedAt; if ( - (runtimeEvent.domain === 'SYSTEM' && runtimeEvent.type === 'DONE') || - runtimeEvent.type === 'ERROR' || - runtimeEvent.domain === 'ERROR' + event.type === EventType.RUN_FINISHED || + event.type === EventType.RUN_ERROR ) { persistNow(); - return runtimeEvent; + } else { + schedulePersist(); } - schedulePersist(); - return runtimeEvent; + return event; } function projectEvent( items: ChatTimelineItem[], roundId: string, event: AgentTryoutRuntimeEvent, + onInputAccepted?: ( + payload: Record, + ) => Promise | void, ) { const round = rounds.get(roundId); const variant = round && selectedVariant(round); - if (!round || !variant) { - return; + if (!round || !variant) return; + const key = `${roundId}:${variant.variantIndex}`; + let state = liveProjectionStates.get(key); + if (!state) { + state = createAguiTimelineProjectionState(variant.createdAt); + liveProjectionStates.set(key, state); } - projectEventToTimeline(items, event, round.roundId, variant.variantIndex); + applyAguiEventToTimeline( + items, + event, + { onInputAccepted, roundId, startedAt: variant.createdAt }, + state, + ); } function completeRound(roundId: string) { const round = rounds.get(roundId); const variant = round && selectedVariant(round); - if (!round || !variant) { - return; - } - if (variant.status === 'error' || round.status === 'error') { + if (!round || !variant || variant.status === 'error') { persistNow(); return; } @@ -845,6 +465,20 @@ export function useAgentTryoutRawRounds(options: { persistNow(); } + function failRound(roundId: string) { + const round = rounds.get(roundId); + const variant = round && selectedVariant(round); + if (!round || !variant) { + persistNow(); + return; + } + variant.status = 'error'; + round.status = 'error'; + variant.updatedAt = Date.now(); + round.updatedAt = variant.updatedAt; + persistNow(); + } + function buildTimelineItems() { const items: ChatTimelineItem[] = []; for (const round of sortedRounds(rounds)) { @@ -855,26 +489,34 @@ export function useAgentTryoutRawRounds(options: { roundId: round.roundId, }); const variant = selectedVariant(round); - if (!variant) { - continue; - } + if (!variant) continue; + const state = createAguiTimelineProjectionState(variant.createdAt); + ChatTimelineBuilder.ensureAssistantTurn(items, { + id: `turn-${round.roundId}`, + roundId: round.roundId, + turnStartedAt: variant.createdAt, + }); for (const event of variant.runtimeEvents) { - projectEventToTimeline( + applyAguiEventToTimeline( items, event, - round.roundId, - variant.variantIndex, + { + finishedAt: variant.updatedAt, + roundId: round.roundId, + startedAt: variant.createdAt, + }, + state, ); } - if (variant.status === 'completed' || variant.status === 'error') { - ChatTimelineBuilder.finalize(items); - markRoundCompleted( - items, - round.roundId, - variant, - round.variants.length, - round.selectedVariantIndex, - ); + if (variant.status !== 'running') { + ChatTimelineBuilder.finalize(items, { + roundCompleted: variant.status === 'completed', + roundId: round.roundId, + turnFinishedAt: variant.updatedAt, + turnStartedAt: variant.createdAt, + turnSucceeded: variant.status === 'completed', + }); + markRoundCompleted(items, round, variant); } } return items; @@ -882,16 +524,12 @@ export function useAgentTryoutRawRounds(options: { function selectVariant(roundId: string, direction: 'next' | 'previous') { const round = rounds.get(roundId); - if (!round) { - return; - } + if (!round) return; const next = direction === 'previous' ? round.selectedVariantIndex - 1 : round.selectedVariantIndex + 1; - if (next < 1 || next > round.variants.length) { - return; - } + if (next < 1 || next > round.variants.length) return; round.selectedVariantIndex = next; round.updatedAt = Date.now(); persistNow(); @@ -906,18 +544,15 @@ export function useAgentTryoutRawRounds(options: { return direction === 'previous' ? current > 1 : current < total; } - function copyText(item: ChatTimelineMessageItem) { - return visibleText(item); - } - return { buildTimelineItems, canSwitch, clear, completeRound, - copyText, + copyText: visibleText, createRound, currentVariant, + failRound, getPrompt, projectEvent, recordEvent, diff --git a/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutStream.test.ts b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutStream.test.ts new file mode 100644 index 00000000..a22410dd --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutStream.test.ts @@ -0,0 +1,182 @@ +// @vitest-environment happy-dom + +import type { EasyFlowAguiRunOptions } from '../../shared/agent-agui/client'; +import type { AgentInfo } from '../types'; + +import { EventType } from '@ag-ui/client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAgentTryoutStream } from './useAgentTryoutStream'; + +const aguiMocks = vi.hoisted(() => ({ + abort: vi.fn(), + runs: [] as EasyFlowAguiRunOptions[], +})); + +vi.mock('../../shared/agent-agui/client', () => ({ + EasyFlowAguiClient: class { + abort = aguiMocks.abort; + + run(options: EasyFlowAguiRunOptions) { + aguiMocks.runs.push(options); + return new Promise(() => {}); + } + }, +})); + +vi.mock('../api', () => ({ + clearAgentDraftSession: vi.fn(async () => ({ errorCode: 0 })), +})); + +describe('useAgentTryoutStream', () => { + beforeEach(() => { + sessionStorage.clear(); + aguiMocks.runs.length = 0; + vi.clearAllMocks(); + }); + + it('ignores late events from a stopped draft run after resending', async () => { + const stream = useAgentTryoutStream(); + const payload = { + agent: { id: 'agent-1', name: 'Agent' } as AgentInfo, + knowledgeBindings: [], + prompt: '旧问题', + sessionId: 'draft-race', + skillBindings: [], + toolBindings: [], + }; + + void stream.sendDraft(payload); + await Promise.resolve(); + stream.stop(); + void stream.sendDraft({ ...payload, prompt: '新问题' }); + await Promise.resolve(); + + aguiMocks.runs[0]?.onEvent({ + delta: '旧流迟到正文', + messageId: 'old-assistant', + type: EventType.TEXT_MESSAGE_CONTENT, + }); + aguiMocks.runs[1]?.onEvent({ + delta: '新流正文', + messageId: 'new-assistant', + type: EventType.TEXT_MESSAGE_CONTENT, + }); + + const timeline = JSON.stringify(stream.timelineItems.value); + expect(timeline).toContain('新流正文'); + expect(timeline).not.toContain('旧流迟到正文'); + }); + + it('sends only the draft runtime snapshot through forwardedProps', async () => { + const stream = useAgentTryoutStream(); + void stream.sendDraft({ + agent: { + created: '2026-08-14 10:00:00', + displayPublishStatus: 'PUBLISHED', + id: 'agent-1', + modelId: 'model-1', + name: 'Agent', + publishedSnapshotJson: { internal: true }, + }, + knowledgeBindings: [ + { + agentId: 'agent-1', + enabled: true, + id: 'knowledge-binding-1', + knowledgeId: 'knowledge-1', + localId: 'local-knowledge', + optionsJson: { topK: 5 }, + resourceSnapshot: { internal: true }, + resourceSummary: { displayName: '知识库' }, + retrievalMode: 'HYBRID', + sortNo: 2, + }, + ], + prompt: '问题', + sessionId: 'draft-snapshot', + skillBindings: [ + { + agentId: 'agent-1', + id: 'skill-binding-1', + resourceSummary: { + displayName: '合同审查', + toolCount: 2, + }, + skillId: 'skill-1', + sortNo: 3, + }, + ], + toolBindings: [ + { + agentId: 'agent-1', + enabled: true, + hitlConfigJson: { message: '请确认' }, + hitlEnabled: true, + id: 'tool-binding-1', + localId: 'local-tool', + optionsJson: { timeout: 30 }, + resourceSnapshot: { internal: true }, + resourceSummary: { displayName: '工具' }, + sortNo: 1, + targetId: 'tool-1', + toolName: 'search', + toolType: 'PLUGIN', + }, + ], + }); + await Promise.resolve(); + + const forwardedProps = aguiMocks.runs[0]?.forwardedProps as { + easyflow: { + draft: { + agent: Record; + knowledgeBindings: Record[]; + skillBindings: Record[]; + toolBindings: Record[]; + }; + }; + }; + expect(forwardedProps.easyflow.draft.agent).toEqual( + expect.objectContaining({ + id: 'agent-1', + modelId: 'model-1', + name: 'Agent', + }), + ); + expect(forwardedProps.easyflow.draft.agent).not.toHaveProperty('created'); + expect(forwardedProps.easyflow.draft.agent).not.toHaveProperty( + 'publishedSnapshotJson', + ); + expect(forwardedProps.easyflow.draft.toolBindings).toEqual([ + { + enabled: true, + hitlConfigJson: { message: '请确认' }, + hitlEnabled: true, + id: 'tool-binding-1', + optionsJson: { timeout: 30 }, + sortNo: 1, + targetId: 'tool-1', + toolName: 'search', + toolType: 'PLUGIN', + }, + ]); + expect(forwardedProps.easyflow.draft.knowledgeBindings).toEqual([ + { + enabled: true, + id: 'knowledge-binding-1', + knowledgeId: 'knowledge-1', + optionsJson: { topK: 5 }, + retrievalMode: 'HYBRID', + sortNo: 2, + }, + ]); + expect(forwardedProps.easyflow.draft.skillBindings).toEqual([ + { + skillId: 'skill-1', + sortNo: 3, + }, + ]); + stream.stop(); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutStream.ts b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutStream.ts index cb5eeb09..ff7a0bf7 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutStream.ts +++ b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutStream.ts @@ -1,5 +1,3 @@ -import type { ServerSentEventMessage } from 'fetch-event-stream'; - import type { ChatDocumentAttachment, ChatImageAttachment, @@ -7,9 +5,11 @@ import type { ChatTimelineMessageItem, } from '@easyflow/common-ui'; +import type { AguiEvent } from '../../shared/agent-agui/client'; import type { AgentInfo, AgentKnowledgeBinding, + AgentSkillBinding, AgentToolBinding, } from '../types'; @@ -17,8 +17,9 @@ import { ref } from 'vue'; import { ChatTimelineBuilder } from '@easyflow/common-ui'; -import { sseClient } from '#/api/request'; +import { EventType } from '@ag-ui/client'; +import { EasyFlowAguiClient } from '../../shared/agent-agui/client'; import { clearAgentDraftSession } from '../api'; import { useAgentTryoutRawRounds } from './useAgentTryoutRawRounds'; @@ -26,24 +27,6 @@ function resolveDraftSessionId(agent: AgentInfo) { return `agent-draft-${agent.id || agent.localId || 'unsaved'}`; } -function parseEventData(message: ServerSentEventMessage) { - const raw = message.data || ''; - if (!raw) return {}; - try { - return JSON.parse(raw); - } catch { - return { payload: { delta: raw } }; - } -} - -function resolveEnvelope(data: any) { - return { - domain: data.domain || data.eventDomain || data.typeDomain, - type: data.type || data.eventType || data.chatType || data.event, - payload: data.payload ?? data.data ?? data, - }; -} - function asText(value: unknown) { return value === null || value === undefined ? '' : String(value); } @@ -54,23 +37,78 @@ function asRecord(value: unknown): Record { : {}; } -function isEndOfRoundEvent(domain: string, type: string) { - return domain === 'SYSTEM' && type === 'DONE'; +function draftAgentTransport(agent: AgentInfo) { + return { + avatar: agent.avatar, + categoryId: agent.categoryId, + description: agent.description, + executionConfigJson: agent.executionConfigJson, + generationConfigJson: agent.generationConfigJson, + id: agent.id, + memoryConfigJson: agent.memoryConfigJson, + modelConfigJson: agent.modelConfigJson, + modelId: agent.modelId, + name: agent.name, + promptConfigJson: agent.promptConfigJson, + publishStatus: agent.publishStatus, + status: agent.status, + visibilityScope: agent.visibilityScope, + }; +} + +function draftToolBindingTransport(binding: AgentToolBinding) { + return { + enabled: binding.enabled, + hitlConfigJson: binding.hitlConfigJson, + hitlEnabled: binding.hitlEnabled, + id: binding.id, + optionsJson: binding.optionsJson, + sortNo: binding.sortNo, + targetId: binding.targetId, + toolName: binding.toolName, + toolType: binding.toolType, + }; +} + +function draftKnowledgeBindingTransport(binding: AgentKnowledgeBinding) { + return { + enabled: binding.enabled, + id: binding.id, + knowledgeId: binding.knowledgeId, + optionsJson: binding.optionsJson, + retrievalMode: binding.retrievalMode, + sortNo: binding.sortNo, + }; +} + +function draftSkillBindingTransport(binding: AgentSkillBinding) { + return { + skillId: binding.skillId, + sortNo: binding.sortNo, + }; } interface DraftRuntimeContext { agent: AgentInfo; knowledgeBindings: AgentKnowledgeBinding[]; + skillBindings: AgentSkillBinding[]; toolBindings: AgentToolBinding[]; } +interface ActiveDraftRun { + roundId: string; + sessionId: string; + stopped: boolean; +} + export function useAgentTryoutStream() { const timelineItems = ref([]); const loading = ref(false); let rawRounds: ReturnType | undefined; let activeRoundId = ''; let activeSessionId = ''; - let userStopped = false; + let activeRun: ActiveDraftRun | undefined; + const aguiClient = new EasyFlowAguiClient(); function errorMessageOf(error: unknown) { if (error instanceof Error) { @@ -87,18 +125,11 @@ export function useAgentTryoutStream() { .join(' '); } - function isAbortError(error: unknown) { - const message = errorMessageOf(error).toLowerCase(); - return message.includes('abort'); - } - - function shouldIgnoreStoppedError(error: unknown) { - return userStopped && isAbortError(error); - } - - function finishStoppedRun() { - finishAssistant(); - rawRounds?.flush(); + function finishStoppedRun(roundId: string) { + if (roundId) { + rawRounds?.failRound(roundId); + rebuildTimeline(); + } loading.value = false; } @@ -141,79 +172,18 @@ export function useAgentTryoutStream() { } function markToolApproving(payload: { - requestId?: string; - resumeToken?: string; + approvalId?: string; toolCallId?: string; }) { - rawRounds?.recordEvent(activeRoundId, { - domain: 'TOOL', - payload, - type: 'FORM_APPROVING', - }); - rawRounds?.flush(); - rebuildTimeline(); + ChatTimelineBuilder.markToolApproving(timelineItems.value, payload); } function markToolRejected(payload: { + approvalId?: string; reason?: string; - requestId?: string; - resumeToken?: string; toolCallId?: string; }) { - rawRounds?.recordEvent(activeRoundId, { - domain: 'TOOL', - payload, - type: 'FORM_REJECTED', - }); - rawRounds?.flush(); - rebuildTimeline(); - } - - function handleMessage(message: ServerSentEventMessage) { - const data = parseEventData(message); - const envelope = resolveEnvelope(data); - const domain = String(envelope.domain || '').toUpperCase(); - const type = String(envelope.type || '').toUpperCase(); - const payload = envelope.payload || {}; - - if (activeRoundId) { - const runtimeEvent = rawRounds?.recordEvent(activeRoundId, { - domain, - payload, - type, - }); - if (runtimeEvent) { - rawRounds?.projectEvent( - timelineItems.value, - activeRoundId, - runtimeEvent, - ); - } - } - - if (domain === 'LLM' && type === 'MESSAGE') { - return; - } - if (domain === 'LLM' && type === 'THINKING') { - const text = asText(payload.reasoning ?? payload.delta ?? payload.text); - if (!text) return; - return; - } - if (domain === 'TOOL' && type === 'FORM_REQUEST') { - return; - } - if (domain === 'TOOL' && (type === 'TOOL_CALL' || type === 'TOOL_RESULT')) { - return; - } - if (domain === 'BUSINESS' && type === 'CITATIONS') { - return; - } - if (domain === 'BUSINESS' && type === 'STATUS') { - return; - } - if (isEndOfRoundEvent(domain, type)) { - markRoundCompleted(activeRoundId); - } + ChatTimelineBuilder.markToolRejected(timelineItems.value, payload); } async function runDraft(payload: { @@ -226,6 +196,7 @@ export function useAgentTryoutStream() { onAccepted?: () => Promise | void; prompt: string; sessionId?: string; + skillBindings: AgentSkillBinding[]; toolBindings: AgentToolBinding[]; }) { syncDraftContext(payload, false, payload.sessionId); @@ -237,59 +208,83 @@ export function useAgentTryoutStream() { payload.images, payload.documents, ); + const run: ActiveDraftRun = { + roundId: activeRoundId, + sessionId: activeSessionId, + stopped: false, + }; + activeRun = run; rebuildTimeline(); loading.value = true; - userStopped = false; let accepted = false; - await sseClient.post( - '/api/v1/agent/chat/draft', - { - agent: payload.agent, - documentUploadIds: payload.documentUploadIds, - imageUploadIds: payload.imageUploadIds, - knowledgeBindings: payload.knowledgeBindings, - prompt: payload.prompt, - sessionId: activeSessionId, - toolBindings: payload.toolBindings, - }, - { - onMessage: (message) => { - const envelope = resolveEnvelope(parseEventData(message)); - const domain = String(envelope.domain || '').toUpperCase(); - const type = String(envelope.type || '').toUpperCase(); - if (!accepted && domain === 'SYSTEM' && type === 'INPUT_ACCEPTED') { - accepted = true; - void payload.onAccepted?.(); - } - handleMessage(message); - }, - onError: (error) => { - if (shouldIgnoreStoppedError(error)) { - return; - } - rawRounds?.recordEvent(activeRoundId, { - domain: 'SYSTEM', - payload: { - message: error?.message ?? '试运行失败,请稍后再试', + try { + await aguiClient.run({ + forwardedProps: { + easyflow: { + draft: { + agent: draftAgentTransport(payload.agent), + knowledgeBindings: payload.knowledgeBindings.map((binding) => + draftKnowledgeBindingTransport(binding), + ), + skillBindings: payload.skillBindings.map((binding) => + draftSkillBindingTransport(binding), + ), + toolBindings: payload.toolBindings.map((binding) => + draftToolBindingTransport(binding), + ), }, - type: 'ERROR', - }); - rebuildTimeline(); - finishAssistant(); - rawRounds?.flush(); - loading.value = false; + input: { + documentUploadIds: payload.documentUploadIds, + imageUploadIds: payload.imageUploadIds, + }, + }, }, - onFinished: () => { - if (userStopped) { - return; - } - finishAssistant(); - markRoundCompleted(activeRoundId); - rawRounds?.flush(); - loading.value = false; + onEvent(event) { + if (activeRun !== run) return; + const runtimeEvent = rawRounds?.recordEvent(run.roundId, event); + if (!runtimeEvent) return; + rawRounds?.projectEvent( + timelineItems.value, + run.roundId, + runtimeEvent, + () => { + if (accepted) return; + accepted = true; + void payload.onAccepted?.(); + }, + ); }, - }, - ); + threadId: run.sessionId, + url: '/api/v1/agent/agui/run/draft', + userMessage: { + content: payload.prompt, + id: `user-${run.roundId}`, + role: 'user', + }, + }); + if (activeRun === run && !run.stopped) { + finishAssistant(); + markRoundCompleted(run.roundId); + } + } catch (error) { + if (activeRun === run && !run.stopped) { + const runError = { + message: errorMessageOf(error) || '试运行失败,请稍后再试', + runId: run.roundId, + threadId: run.sessionId, + type: EventType.RUN_ERROR, + } as AguiEvent; + rawRounds?.recordEvent(run.roundId, runError); + rawRounds?.projectEvent(timelineItems.value, run.roundId, runError); + finishAssistant(); + rawRounds?.flush(); + } + } finally { + if (activeRun === run) { + activeRun = undefined; + loading.value = false; + } + } } async function sendDraft(payload: { @@ -302,6 +297,7 @@ export function useAgentTryoutStream() { onAccepted?: () => Promise | void; prompt: string; sessionId?: string; + skillBindings: AgentSkillBinding[]; toolBindings: AgentToolBinding[]; }) { await runDraft(payload); @@ -329,8 +325,9 @@ export function useAgentTryoutStream() { async function clearDraftSession() { if (loading.value) { - userStopped = true; - sseClient.abort(); + if (activeRun) activeRun.stopped = true; + activeRun = undefined; + aguiClient.abort(); loading.value = false; } const sessionId = activeSessionId; @@ -346,16 +343,20 @@ export function useAgentTryoutStream() { if (!loading.value) { return; } - userStopped = true; - sseClient.abort(); - finishStoppedRun(); + const stoppedRoundId = activeRun?.roundId || activeRoundId; + if (activeRun) activeRun.stopped = true; + activeRun = undefined; + aguiClient.abort(); + finishStoppedRun(stoppedRoundId); } function dispose() { if (loading.value) { - userStopped = true; - sseClient.abort(); - finishStoppedRun(); + const stoppedRoundId = activeRun?.roundId || activeRoundId; + if (activeRun) activeRun.stopped = true; + activeRun = undefined; + aguiClient.abort(); + finishStoppedRun(stoppedRoundId); return; } rawRounds?.flush(); diff --git a/easyflow-ui-admin/app/src/views/ai/agents/types.ts b/easyflow-ui-admin/app/src/views/ai/agents/types.ts index baeedc30..61bd7bfa 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/types.ts +++ b/easyflow-ui-admin/app/src/views/ai/agents/types.ts @@ -1,7 +1,12 @@ /* cspell:ignore hitl */ export type AgentPanelMode = 'base' | 'capability' | 'tryout'; -export type AgentCapabilityKind = 'knowledge' | 'plugin' | 'workflow' | 'mcp'; +export type AgentCapabilityKind = + | 'knowledge' + | 'mcp' + | 'plugin' + | 'skill' + | 'workflow'; export interface AgentInteractionConfig { inputPlaceholder: string; @@ -9,6 +14,30 @@ export interface AgentInteractionConfig { welcomeMessage: string; } +export interface AgentBuiltinToolConfig { + approvalRequired: boolean; + enabled: boolean; +} + +export interface AgentBuiltinToolsConfig { + artifactPublish: AgentBuiltinToolConfig; + patch: AgentBuiltinToolConfig; + read: AgentBuiltinToolConfig; + schemaVersion: 1; + shell: AgentBuiltinToolConfig; + shellApprovalRiskConfirmed?: boolean; + write: AgentBuiltinToolConfig; +} + +export interface AgentExecutionConfig extends Record { + builtinTools?: AgentBuiltinToolsConfig; + documentContextBudgetTokens?: number; +} + +export interface AgentBuiltinToolCapabilities { + canDisableShellApproval?: boolean; +} + export interface AgentInfo { id?: number | string; name?: string; @@ -20,7 +49,7 @@ export interface AgentInfo { generationConfigJson?: Record; promptConfigJson?: Record; memoryConfigJson?: Record; - executionConfigJson?: Record; + executionConfigJson?: AgentExecutionConfig; interactionConfigJson?: AgentInteractionConfig; supportImage?: boolean; status?: number; @@ -28,11 +57,13 @@ export interface AgentInfo { publishStatus?: string; displayPublishStatus?: string; approvalPending?: boolean; + builtinToolCapabilities?: AgentBuiltinToolCapabilities; currentApprovalActionType?: string; currentApprovalInstanceId?: number | string; publishedSnapshotJson?: Record; toolBindings?: AgentToolBinding[]; knowledgeBindings?: AgentKnowledgeBinding[]; + skillBindings?: AgentSkillBinding[]; created?: string; createdByName?: string; [key: string]: any; @@ -69,10 +100,30 @@ export interface AgentKnowledgeBinding { [key: string]: any; } +export interface AgentSkillBinding { + id?: number | string; + agentId?: number | string; + skillId?: number | string; + resourceSummary?: AgentSkillSummary; + sortNo?: number; +} + +export interface AgentSkillSummary { + binaryExcludedCount?: number; + description?: string; + displayName?: string; + hasUpdate?: boolean; + snapshotHash?: string; + textResourceCount?: number; + toolCount?: number; + visibilityScope?: string; +} + export interface AgentDraftState { agent: AgentInfo; toolBindings: AgentToolBinding[]; knowledgeBindings: AgentKnowledgeBinding[]; + skillBindings: AgentSkillBinding[]; selectedNodeId: string; panelMode: AgentPanelMode; dirty: boolean; diff --git a/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/artifact-projection.ts b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/artifact-projection.ts new file mode 100644 index 00000000..60bf0cba --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/artifact-projection.ts @@ -0,0 +1,84 @@ +import type { + ChatArtifactAttachment, + ChatTimelineItem, + ChatTimelineMessageItem, +} from '@easyflow/common-ui'; + +import { ChatTimelineBuilder } from '@easyflow/common-ui'; + +const ARTIFACT_ID_PATTERN = /^[\w-]{1,64}$/; +const SHA256_PATTERN = /^[a-f\d]{64}$/i; + +function asText(value: unknown) { + return value === null || value === undefined ? '' : String(value); +} + +function artifactStatus(value: unknown): ChatArtifactAttachment['status'] { + const status = asText(value).trim().toUpperCase(); + if (!status || status === 'AVAILABLE') return 'available'; + if (status === 'EXPIRED' || status === 'DELETED') return 'expired'; + if (status === 'DELETE_FAILED') return 'delete_failed'; + return 'unavailable'; +} + +function safeFileName(value: unknown) { + const withoutControlCharacters = [...asText(value)] + .filter((character) => { + const code = character.codePointAt(0) ?? 0; + return code > 31 && code !== 127; + }) + .join(''); + return ( + withoutControlCharacters.split(/[\\/]/).pop()?.trim().slice(0, 255) || '' + ); +} + +/** + * 将 Artifact 事件裁剪为前端允许展示的稳定字段。 + * + * @param payload AG-UI 或历史记录中的 Artifact 载荷。 + * @returns 安全 Artifact;标识或文件名非法时不投影。 + */ +export function normalizeArtifactPayload( + payload: Record, +): ChatArtifactAttachment | undefined { + const artifactId = asText(payload.artifactId).trim(); + const fileName = safeFileName(payload.fileName); + if (!ARTIFACT_ID_PATTERN.test(artifactId) || !fileName) { + return undefined; + } + const status = artifactStatus(payload.status); + const rawSize = Number(payload.size); + const mimeType = asText(payload.mimeType).trim().slice(0, 128); + const sha256 = asText(payload.sha256).trim(); + return { + artifactId, + downloadUrl: + status === 'available' + ? `/api/v1/agent/artifacts/${encodeURIComponent(artifactId)}/content` + : undefined, + fileName, + mimeType: mimeType || undefined, + sha256: SHA256_PATTERN.test(sha256) ? sha256.toLowerCase() : undefined, + size: Number.isSafeInteger(rawSize) && rawSize >= 0 ? rawSize : undefined, + status, + }; +} + +/** + * 使用共享 Builder 将安全 Artifact 投影到聊天时间线。 + * + * @param items 当前时间线。 + * @param payload Artifact 公开载荷。 + * @param metadata 当前轮次元数据。 + */ +export function projectArtifactPayload( + items: ChatTimelineItem[], + payload: Record, + metadata?: Partial, +) { + const artifact = normalizeArtifactPayload(payload); + if (artifact) { + ChatTimelineBuilder.upsertArtifact(items, artifact, metadata); + } +} 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 new file mode 100644 index 00000000..b8b16b86 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.test.ts @@ -0,0 +1,135 @@ +import { EventType } from '@ag-ui/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { EasyFlowAguiClient } from './client'; + +vi.mock('#/api/request', () => ({ + createEventStreamHeaders: () => ({ 'easyflow-token': 'test-token' }), + resolveApiUrl: (url: string) => `http://localhost${url}`, +})); + +function sse(events: unknown[]) { + return new Response( + events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(''), + { headers: { 'Content-Type': 'text/event-stream' }, status: 200 }, + ); +} + +describe('easyFlowAguiClient', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('trims outbound history, tools, context and state at the transport boundary', async () => { + let requestBody: Record | undefined; + const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { + requestBody = JSON.parse(String(init?.body)); + return sse([ + { runId: 'run-1', threadId: '101', type: EventType.RUN_STARTED }, + { + runId: 'run-1', + threadId: '101', + type: EventType.RUN_FINISHED, + }, + ]); + }); + vi.stubGlobal('fetch', fetchMock); + + const received: string[] = []; + await new EasyFlowAguiClient().run({ + forwardedProps: { easyflow: { input: { imageUploadIds: ['image-1'] } } }, + onEvent: (event) => received.push(event.type), + threadId: '101', + url: '/api/v1/agent/1/agui/run', + userMessage: { content: '你好', id: 'user-1', role: 'user' }, + }); + + expect(received).toEqual([EventType.RUN_STARTED, EventType.RUN_FINISHED]); + expect(requestBody).toEqual( + expect.objectContaining({ + context: [], + messages: [{ content: '你好', id: 'user-1', role: 'user' }], + state: {}, + tools: [], + }), + ); + }); + + it('converts proxied forwardedProps to transport JSON before the SDK clones input', async () => { + let requestBody: Record | undefined; + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init?: RequestInit) => { + requestBody = JSON.parse(String(init?.body)); + return sse([ + { runId: 'run-1', threadId: 'draft-1', type: EventType.RUN_STARTED }, + { + runId: 'run-1', + threadId: 'draft-1', + type: EventType.RUN_FINISHED, + }, + ]); + }), + ); + const forwardedProps = new Proxy( + { easyflow: { draft: { agent: { id: 'agent-1' } } } }, + {}, + ); + + await expect( + new EasyFlowAguiClient().run({ + forwardedProps, + onEvent: () => undefined, + threadId: 'draft-1', + url: '/api/v1/agent/agui/run/draft', + userMessage: { content: '你好', id: 'user-1', role: 'user' }, + }), + ).resolves.toBeUndefined(); + expect(requestBody?.forwardedProps).toEqual(forwardedProps); + }); + + it('rejects a clean EOF without RUN_FINISHED or RUN_ERROR', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + sse([{ runId: 'run-1', threadId: '101', type: EventType.RUN_STARTED }]), + ), + ); + + await expect( + new EasyFlowAguiClient().run({ + onEvent: () => undefined, + threadId: '101', + url: '/api/v1/agent/1/agui/run', + userMessage: { content: '你好', id: 'user-1', role: 'user' }, + }), + ).rejects.toThrow('缺少终态'); + }); + + it('treats a standard cancelled terminal as an accepted stop', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + sse([ + { runId: 'run-1', threadId: '101', type: EventType.RUN_STARTED }, + { + code: 'RUN_CANCELLED', + message: '用户拒绝执行', + runId: 'run-1', + threadId: '101', + type: EventType.RUN_ERROR, + }, + ]), + ), + ); + + await expect( + new EasyFlowAguiClient().run({ + onEvent: () => undefined, + threadId: '101', + url: '/api/v1/agent/1/agui/run', + userMessage: { content: '你好', id: 'user-1', role: 'user' }, + }), + ).resolves.toBeUndefined(); + }); +}); 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 new file mode 100644 index 00000000..f265c01a --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/client.ts @@ -0,0 +1,127 @@ +import type { + AgentSubscriber, + Message, + RunAgentInput, + State, +} from '@ag-ui/client'; + +import { EventSchemas, EventType, HttpAgent, randomUUID } from '@ag-ui/client'; + +import { createEventStreamHeaders, resolveApiUrl } from '#/api/request'; + +export interface EasyFlowAguiRunOptions { + forwardedProps?: Record; + onEvent: (event: AguiEvent) => void; + onMessagesChanged?: (messages: ReadonlyArray>) => void; + onStateChanged?: (state: Readonly) => void; + threadId: string; + url: string; + userMessage: Message; +} + +export type AguiEvent = ReturnType<(typeof EventSchemas)['parse']>; + +interface ActiveAguiRun { + aborted: boolean; + agent: HttpAgent; +} + +function toTransportJson(value: T): T { + // AG-UI SDK 会在请求前 structuredClone;先以真实传输格式解除 Vue Proxy,避免草稿对象克隆失败。 + // eslint-disable-next-line unicorn/prefer-structured-clone -- structuredClone 无法复制 Proxy + return JSON.parse(JSON.stringify(value)) as T; +} + +/** + * EasyFlow 的无头 AG-UI 运行客户端。 + * + *

SDK 持有标准 messages/state;页面只消费投影回调。每次运行都会在出站边界再次裁剪 + * tools、context、state 和历史消息,服务端仍会独立执行同样的安全校验。

+ */ +export class EasyFlowAguiClient { + private activeRun?: ActiveAguiRun; + + abort() { + if (!this.activeRun) return; + this.activeRun.aborted = true; + this.activeRun.agent.abortRun(); + this.activeRun = undefined; + } + + async run(options: EasyFlowAguiRunOptions) { + this.abort(); + const requestUrl = options.url; + const agent = new EasyFlowHttpAgent({ + headers: createEventStreamHeaders(requestUrl), + initialMessages: [options.userMessage], + threadId: options.threadId, + url: resolveApiUrl(requestUrl), + }); + const activeRun: ActiveAguiRun = { aborted: false, agent }; + this.activeRun = activeRun; + let terminalReceived = false; + let cancelledReceived = false; + const subscriber: AgentSubscriber = { + onEvent: ({ event }) => { + if ( + event.type === EventType.RUN_FINISHED || + event.type === EventType.RUN_ERROR + ) { + terminalReceived = true; + } + if ( + event.type === EventType.RUN_ERROR && + event.code === 'RUN_CANCELLED' + ) { + cancelledReceived = true; + } + options.onEvent(event as AguiEvent); + }, + onMessagesChanged: ({ messages }) => { + options.onMessagesChanged?.(messages); + }, + onStateChanged: ({ state }) => { + options.onStateChanged?.(state); + }, + }; + try { + await agent.runAgent( + { + context: [], + forwardedProps: options.forwardedProps + ? toTransportJson(options.forwardedProps) + : undefined, + runId: `run_${randomUUID()}`, + tools: [], + }, + subscriber, + ); + if (!terminalReceived) { + if (activeRun.aborted) return; + throw new Error('Agent 事件流缺少终态,请重试'); + } + } catch (error) { + if (activeRun.aborted || cancelledReceived) return; + throw error; + } finally { + if (this.activeRun === activeRun) { + this.activeRun = undefined; + } + } + } +} + +class EasyFlowHttpAgent extends HttpAgent { + protected override requestInit(input: RunAgentInput): RequestInit { + const latestUserMessage = [...input.messages] + .reverse() + .find((message) => message.role === 'user'); + return super.requestInit({ + ...input, + context: [], + messages: latestUserMessage ? [latestUserMessage] : [], + state: {}, + tools: [], + }); + } +} diff --git a/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/custom-events.ts b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/custom-events.ts new file mode 100644 index 00000000..621a5e17 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/custom-events.ts @@ -0,0 +1,12 @@ +export const easyFlowAguiCustomEvent = { + artifactPublished: 'easyflow.artifact.published', + asyncToolStatus: 'easyflow.async_tool.status', + inputAccepted: 'easyflow.input.accepted', + knowledgeCitations: 'easyflow.knowledge.citations', + knowledgeRetrievalStatus: 'easyflow.knowledge.retrieval_status', + runtimeContextStatus: 'easyflow.runtime.context_status', + skillInvocationStatus: 'easyflow.skill.invocation_status', + toolMetadata: 'easyflow.tool.metadata', + toolApprovalRequired: 'easyflow.hitl.tool_approval_required', + toolApprovalResolved: 'easyflow.hitl.tool_approval_resolved', +} as const; 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 new file mode 100644 index 00000000..97857135 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/projection.test.ts @@ -0,0 +1,485 @@ +import type { ChatTimelineItem } from '@easyflow/common-ui'; + +import { EventSchemas, EventType } from '@ag-ui/client'; +import { describe, expect, it } from 'vitest'; + +import { easyFlowAguiCustomEvent } from './custom-events'; +import { + applyAguiEventToTimeline, + createAguiTimelineProjectionState, +} from './projection'; + +describe('aG-UI wire contract and timeline projection', () => { + it('projects a published Artifact through safe public fields only', () => { + const items: ChatTimelineItem[] = []; + applyAguiEventToTimeline( + items, + EventSchemas.parse({ + name: easyFlowAguiCustomEvent.artifactPublished, + type: EventType.CUSTOM, + value: { + artifactId: '01JARTIFACT', + bucket: 'private-bucket', + downloadUrl: 'https://evil.example/file', + fileName: '../项目报告.pdf', + mimeType: 'application/pdf', + objectKey: 'formal/tenant/secret', + schemaVersion: 1, + sha256: 'A'.repeat(64), + size: 2048, + status: 'AVAILABLE', + workspacePath: '/app/data/agent-workspaces/private/report.pdf', + }, + }), + { roundId: 'round-artifact' }, + ); + + expect(items).toEqual([ + expect.objectContaining({ + artifactId: '01JARTIFACT', + downloadUrl: '/api/v1/agent/artifacts/01JARTIFACT/content', + fileName: '项目报告.pdf', + roundId: 'round-artifact', + sha256: 'a'.repeat(64), + status: 'available', + type: 'artifact', + }), + ]); + expect(JSON.stringify(items)).not.toMatch( + /private-bucket|evil\.example|objectKey|workspacePath|agent-workspaces/, + ); + }); + + it('accepts representative Java wire events with the official schemas', () => { + const events = [ + { + runId: 'run-1', + threadId: '101', + type: EventType.RUN_STARTED, + }, + { + messageId: 'reasoning-1', + role: 'reasoning', + runId: 'run-1', + threadId: '101', + type: EventType.REASONING_MESSAGE_START, + }, + { + messageId: 'assistant-1', + role: 'assistant', + runId: 'run-1', + threadId: '101', + type: EventType.TEXT_MESSAGE_START, + }, + { + delta: '你好', + messageId: 'assistant-1', + runId: 'run-1', + threadId: '101', + type: EventType.TEXT_MESSAGE_CONTENT, + }, + { + name: easyFlowAguiCustomEvent.inputAccepted, + runId: 'run-1', + threadId: '101', + type: EventType.CUSTOM, + value: { schemaVersion: 1 }, + }, + { + runId: 'run-1', + threadId: '101', + type: EventType.RUN_FINISHED, + }, + ]; + + expect(events.map((event) => EventSchemas.parse(event))).toHaveLength(6); + }); + + it('creates a running turn as soon as RUN_STARTED arrives', () => { + const items: ChatTimelineItem[] = []; + applyAguiEventToTimeline( + items, + EventSchemas.parse({ + runId: 'run-started', + threadId: 'thread-started', + type: EventType.RUN_STARTED, + }), + { roundId: 'round-started', startedAt: 1000 }, + ); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + parts: [], + role: 'assistant', + roundId: 'round-started', + status: 'streaming', + turnStartedAt: 1000, + type: 'message', + }); + }); + + it('projects standard and custom events without a local standard enum', () => { + const items: ChatTimelineItem[] = []; + const state = createAguiTimelineProjectionState(); + const events = [ + { + delta: '分析中', + messageId: 'reasoning-1', + type: EventType.REASONING_MESSAGE_CONTENT, + }, + { + delta: '执行结果', + messageId: 'assistant-1', + type: EventType.TEXT_MESSAGE_CONTENT, + }, + { + toolCallId: 'tool-1', + toolCallName: 'workflow', + type: EventType.TOOL_CALL_START, + }, + { + name: easyFlowAguiCustomEvent.toolMetadata, + type: EventType.CUSTOM, + value: { + toolCallId: 'tool-1', + toolDisplayName: '数据处理工作流', + toolName: 'workflow', + }, + }, + { + delta: '{"topic":"AG-UI"}', + toolCallId: 'tool-1', + type: EventType.TOOL_CALL_ARGS, + }, + { + content: '完成', + messageId: 'tool-result-1', + role: 'tool', + toolCallId: 'tool-1', + type: EventType.TOOL_CALL_RESULT, + }, + { + name: easyFlowAguiCustomEvent.toolApprovalRequired, + type: EventType.CUSTOM, + value: { + approvalId: 'approval-public', + toolCallId: 'tool-2', + toolName: 'dangerous_tool', + }, + }, + { + name: easyFlowAguiCustomEvent.toolApprovalResolved, + type: EventType.CUSTOM, + value: { + approvalId: 'approval-public', + reason: '用户拒绝执行', + status: 'REJECTED', + }, + }, + { + runId: 'run-1', + threadId: '101', + type: EventType.RUN_FINISHED, + }, + ].map((event) => EventSchemas.parse(event)); + + for (const event of events) { + applyAguiEventToTimeline(items, event, { roundId: 'round-1' }, state); + } + + expect(JSON.stringify(items)).toContain('分析中'); + expect(JSON.stringify(items)).toContain('执行结果'); + expect(items.find((item) => item.id === 'tool-1')).toMatchObject({ + input: { topic: 'AG-UI' }, + output: '完成', + status: 'success', + toolName: '数据处理工作流', + }); + const approval = items.find((item) => item.id === 'tool-2'); + expect(approval?.type === 'tool' && approval.approval).toEqual( + expect.objectContaining({ approvalId: 'approval-public' }), + ); + expect(approval).toMatchObject({ status: 'rejected' }); + expect(JSON.stringify(approval)).not.toContain('resumeToken'); + }); + + it('uses standard message snapshots for authoritative text and keeps cancellation non-error', () => { + const items: ChatTimelineItem[] = []; + applyAguiEventToTimeline( + items, + EventSchemas.parse({ + delta: 'draft', + messageId: 'assistant-1', + type: EventType.TEXT_MESSAGE_CONTENT, + }), + ); + applyAguiEventToTimeline( + items, + EventSchemas.parse({ + messages: [ + { content: 'question', id: 'user-1', role: 'user' }, + { content: 'final', id: 'assistant-1', role: 'assistant' }, + ], + runId: 'run-1', + threadId: '101', + type: EventType.MESSAGES_SNAPSHOT, + }), + ); + applyAguiEventToTimeline( + items, + EventSchemas.parse({ + code: 'RUN_CANCELLED', + message: '用户拒绝执行', + runId: 'run-1', + threadId: '101', + type: EventType.RUN_ERROR, + }), + ); + + expect(JSON.stringify(items)).toContain('final'); + expect(JSON.stringify(items)).not.toContain('draft'); + expect(items.some((item) => item.type === 'error')).toBe(false); + }); + + it('aligns message snapshots by message id and supports an empty authoritative body', () => { + const items: ChatTimelineItem[] = []; + const state = createAguiTimelineProjectionState(); + for (const event of [ + { + delta: '第一段', + messageId: 'assistant-1', + type: EventType.TEXT_MESSAGE_CONTENT, + }, + { + toolCallId: 'tool-1', + toolCallName: 'workflow', + type: EventType.TOOL_CALL_START, + }, + { + delta: '过期正文', + messageId: 'assistant-2', + type: EventType.TEXT_MESSAGE_CONTENT, + }, + ].map((event) => EventSchemas.parse(event))) { + applyAguiEventToTimeline(items, event, { roundId: 'round-1' }, state); + } + + applyAguiEventToTimeline( + items, + EventSchemas.parse({ + messages: [{ content: '', id: 'assistant-2', role: 'assistant' }], + runId: 'run-1', + threadId: '101', + type: EventType.MESSAGES_SNAPSHOT, + }), + { roundId: 'round-1' }, + state, + ); + + const assistants = items.filter( + (item) => item.type === 'message' && item.role === 'assistant', + ); + expect(assistants).toHaveLength(1); + expect(assistants[0]).toMatchObject({ id: 'assistant-2', status: 'done' }); + expect(JSON.stringify(assistants[0])).not.toContain('过期正文'); + }); + + it('projects one AG-UI run as a completed timeline turn', () => { + const items: ChatTimelineItem[] = []; + const state = createAguiTimelineProjectionState(); + const events = [ + { + runId: 'run-1', + threadId: 'thread-1', + type: EventType.RUN_STARTED, + }, + { + toolCallId: 'tool-1', + toolCallName: 'context7', + type: EventType.TOOL_CALL_START, + }, + { + content: 'ok', + messageId: 'tool-result-1', + role: 'tool', + toolCallId: 'tool-1', + type: EventType.TOOL_CALL_RESULT, + }, + { + delta: '最终回答', + messageId: 'assistant-1', + type: EventType.TEXT_MESSAGE_CONTENT, + }, + { + runId: 'run-1', + threadId: 'thread-1', + type: EventType.RUN_FINISHED, + }, + ].map((event) => EventSchemas.parse(event)); + + for (const event of events) { + applyAguiEventToTimeline(items, event, { roundId: 'round-1' }, state); + } + + expect(items).toHaveLength(2); + expect(items.every((item) => item.roundId === 'round-1')).toBe(true); + expect(items.every((item) => item.turnSucceeded === true)).toBe(true); + expect(items.every((item) => item.turnStartedAt !== undefined)).toBe(true); + expect(items.every((item) => item.turnFinishedAt !== undefined)).toBe(true); + expect( + items.some( + (item) => + item.type === 'message' && + item.role === 'assistant' && + item.roundCompleted, + ), + ).toBe(true); + }); + + it('projects Skill invocation status in place through the strict public fields', () => { + const items: ChatTimelineItem[] = []; + const state = createAguiTimelineProjectionState(); + for (const event of [ + { + name: easyFlowAguiCustomEvent.skillInvocationStatus, + type: EventType.CUSTOM, + value: { + configJson: { token: 'secret' }, + input: { contract: 'private' }, + path: 'references/private.md', + skillContent: 'private Skill body', + skillDisplayName: '合同审查助手', + skillId: '101', + skillName: 'contract-review', + status: 'RUNNING', + statusKey: 'skill-invocation:round-skill:101', + toolCallId: 'tool-skill-1', + }, + }, + { + name: easyFlowAguiCustomEvent.skillInvocationStatus, + type: EventType.CUSTOM, + value: { + message: '完成', + skillDisplayName: '合同审查助手', + skillId: '101', + skillName: 'contract-review', + status: 'SUCCESS', + statusKey: 'skill-invocation:round-skill:101', + toolCallId: 'tool-skill-1', + }, + }, + ].map((event) => EventSchemas.parse(event))) { + applyAguiEventToTimeline(items, event, { roundId: 'round-skill' }, state); + } + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + icon: 'skill', + label: '已调用 合同审查助手', + roundId: 'round-skill', + status: 'done', + statusKey: 'skill-invocation:round-skill:101', + type: 'status', + }); + expect(JSON.stringify(items[0])).not.toMatch( + /skillContent|private Skill body|private\.md|contract|token|toolCallId/, + ); + }); + + it.each([ + ['FAILED', 'error', '调用 合同审查助手 失败'], + ['CANCELLED', 'cancelled', '已停止调用 合同审查助手'], + ['INCOMPLETE', 'incomplete', '调用 合同审查助手 未完成'], + ] as const)( + 'keeps the %s Skill terminal semantics', + (status, expected, label) => { + const items: ChatTimelineItem[] = []; + applyAguiEventToTimeline( + items, + EventSchemas.parse({ + name: easyFlowAguiCustomEvent.skillInvocationStatus, + type: EventType.CUSTOM, + value: { + skillDisplayName: '合同审查助手', + status, + statusKey: 'skill-invocation:r1:101', + }, + }), + { roundId: 'r1' }, + ); + + expect(items[0]).toMatchObject({ label, status: expected }); + }, + ); + + it('closes a running Skill as cancelled when the run is cancelled', () => { + const items: ChatTimelineItem[] = []; + const state = createAguiTimelineProjectionState(); + applyAguiEventToTimeline( + items, + EventSchemas.parse({ + name: easyFlowAguiCustomEvent.skillInvocationStatus, + type: EventType.CUSTOM, + value: { + skillDisplayName: '合同审查助手', + status: 'RUNNING', + statusKey: 'skill-invocation:r1:101', + }, + }), + { roundId: 'r1' }, + state, + ); + applyAguiEventToTimeline( + items, + EventSchemas.parse({ + code: 'RUN_CANCELLED', + message: 'cancelled', + runId: 'run-1', + threadId: 'thread-1', + type: EventType.RUN_ERROR, + }), + { roundId: 'r1' }, + state, + ); + + expect(items[0]).toMatchObject({ + label: '已停止调用 合同审查助手', + status: 'cancelled', + }); + }); + + it('closes a running Skill as incomplete when the run finishes without its terminal event', () => { + const items: ChatTimelineItem[] = []; + const state = createAguiTimelineProjectionState(); + applyAguiEventToTimeline( + items, + EventSchemas.parse({ + name: easyFlowAguiCustomEvent.skillInvocationStatus, + type: EventType.CUSTOM, + value: { + skillDisplayName: '合同审查助手', + status: 'RUNNING', + statusKey: 'skill-invocation:r1:101', + }, + }), + { roundId: 'r1' }, + state, + ); + applyAguiEventToTimeline( + items, + EventSchemas.parse({ + runId: 'run-1', + threadId: 'thread-1', + type: EventType.RUN_FINISHED, + }), + { roundId: 'r1' }, + state, + ); + + expect(items[0]).toMatchObject({ + label: '调用 合同审查助手 未完成', + status: 'incomplete', + }); + }); +}); 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 new file mode 100644 index 00000000..77d84ae3 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/projection.ts @@ -0,0 +1,438 @@ +import type { + ChatTimelineItem, + ChatTimelineKnowledgeHit, + ChatTimelineMessageItem, + ChatTimelineSkillInvocationStatus, + ChatTimelineToolStatus, +} from '@easyflow/common-ui'; + +import type { AguiEvent } from './client'; + +import { ChatTimelineBuilder } from '@easyflow/common-ui'; + +import { EventType } from '@ag-ui/client'; + +import { projectArtifactPayload } from './artifact-projection'; +import { easyFlowAguiCustomEvent } from './custom-events'; + +export interface AguiTimelineProjectionOptions { + finishedAt?: number; + onInputAccepted?: (payload: Record) => Promise | void; + roundId?: string; + startedAt?: number; +} + +export interface AguiTimelineProjectionState { + startedAt?: number; + toolArgs: Map; + toolNames: Map; +} + +export function createAguiTimelineProjectionState( + startedAt?: number, +): AguiTimelineProjectionState { + return { + startedAt, + toolArgs: new Map(), + toolNames: new Map(), + }; +} + +function asText(value: unknown) { + return value === null || value === undefined ? '' : String(value); +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function asArray(value: unknown) { + return Array.isArray(value) ? value : []; +} + +function metadata( + options: AguiTimelineProjectionOptions, + state: AguiTimelineProjectionState, + messageId?: string, +): Partial { + return { + ...(messageId ? { id: messageId } : {}), + ...(options.roundId ? { roundId: options.roundId } : {}), + ...(state.startedAt ? { turnStartedAt: state.startedAt } : {}), + }; +} + +function parseToolArgs(value: string) { + if (!value) return undefined; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function normalizeKnowledgeItems( + payload: Record, +): ChatTimelineKnowledgeHit[] { + const source = + payload.items ?? + payload.hits ?? + payload.documents ?? + payload.knowledgeReferences ?? + payload.knowledgeCitations ?? + []; + return asArray(source) + .map((value, index) => { + const item = asRecord(value); + const itemMetadata = asRecord(item.metadata); + const documentName = asText( + item.documentName ?? item.documentTitle ?? item.title, + ); + const sourceFileName = asText( + item.sourceFileName ?? itemMetadata.sourceFileName, + ); + const chunkContent = asText( + item.chunkContent ?? item.content ?? item.text ?? item.summary, + ); + const rawScore = item.score ?? item.similarity; + const score = + typeof rawScore === 'number' || typeof rawScore === 'string' + ? rawScore + : undefined; + return { + ...item, + chunkContent, + chunkId: asText(item.chunkId ?? itemMetadata.chunkId), + content: asText(item.content ?? item.text ?? item.summary), + documentId: asText(item.documentId ?? itemMetadata.documentId), + documentName, + id: asText(item.id ?? item.chunkId ?? index), + knowledgeId: asText(item.knowledgeId ?? payload.knowledgeId), + knowledgeName: asText(item.knowledgeName ?? payload.knowledgeName), + metadata: itemMetadata, + score, + sourceFileName, + sourceUri: asText(item.sourceUri ?? itemMetadata.sourceUri), + title: documentName || sourceFileName || asText(item.source), + } satisfies ChatTimelineKnowledgeHit; + }) + .filter((item) => item.chunkContent || item.title || item.documentName); +} + +function asyncToolStatus( + payload: Record, +): ChatTimelineToolStatus { + const status = asText(payload.status).toUpperCase(); + if (status === 'SUCCEEDED') return 'success'; + if (['CANCELLED', 'FAILED', 'TIMEOUT'].includes(status)) return 'error'; + return 'running'; +} + +function statusKey( + payload: Record, + options: AguiTimelineProjectionOptions, + fallback: string, +) { + const value = asText(payload.statusKey) || fallback; + return options.roundId ? `${value}:${options.roundId}` : value; +} + +const skillInvocationStatuses = new Set([ + 'CANCELLED', + 'FAILED', + 'INCOMPLETE', + 'RUNNING', + 'SUCCESS', +]); + +function normalizeSkillInvocationStatus( + value: unknown, +): ChatTimelineSkillInvocationStatus | undefined { + const status = asText(value).trim().toUpperCase(); + return skillInvocationStatuses.has( + status as ChatTimelineSkillInvocationStatus, + ) + ? (status as ChatTimelineSkillInvocationStatus) + : undefined; +} + +function skillInvocationStatusKey( + payload: Record, + options: AguiTimelineProjectionOptions, +) { + const supplied = asText(payload.statusKey).trim(); + if (supplied) { + return supplied; + } + const identity = + asText(payload.skillId).trim() || + asText(payload.skillName).trim() || + 'unknown'; + return `skill-invocation:${options.roundId || 'draft'}:${identity}`; +} + +function applyCustomEvent( + items: ChatTimelineItem[], + event: Extract, + options: AguiTimelineProjectionOptions, + state: AguiTimelineProjectionState, +) { + const payload = asRecord(event.value); + const turnMetadata = metadata(options, state); + if (event.name === easyFlowAguiCustomEvent.inputAccepted) { + void options.onInputAccepted?.(payload); + return; + } + if (event.name === easyFlowAguiCustomEvent.artifactPublished) { + projectArtifactPayload(items, payload, turnMetadata); + return; + } + if (event.name === easyFlowAguiCustomEvent.toolApprovalRequired) { + ChatTimelineBuilder.appendToolApproval( + items, + { + approvalId: asText(payload.approvalId), + expiresAt: asText(payload.expiresAt), + input: payload.input, + metadata: payload.metadata, + toolCallId: asText(payload.toolCallId), + toolDisplayName: asText(payload.toolDisplayName), + toolName: asText(payload.toolName) || '工具调用', + toolType: asText(payload.toolType), + }, + turnMetadata, + ); + return; + } + if (event.name === easyFlowAguiCustomEvent.toolApprovalResolved) { + const toolCallId = asText(payload.toolCallId); + if (asText(payload.status).toUpperCase() === 'APPROVED') { + ChatTimelineBuilder.upsertToolCall(items, { + ...turnMetadata, + approvalId: asText(payload.approvalId), + status: 'running', + toolCallId, + }); + } else { + ChatTimelineBuilder.markToolRejected(items, { + ...turnMetadata, + approvalId: asText(payload.approvalId), + reason: asText(payload.reason), + toolCallId, + }); + } + return; + } + if (event.name === easyFlowAguiCustomEvent.toolMetadata) { + const toolCallId = asText(payload.toolCallId); + const toolDisplayName = asText(payload.toolDisplayName); + if (toolCallId && toolDisplayName) { + state.toolNames.set(toolCallId, toolDisplayName); + ChatTimelineBuilder.upsertToolCall(items, { + ...turnMetadata, + status: 'running', + toolCallId, + toolName: toolDisplayName, + }); + } + return; + } + if (event.name === easyFlowAguiCustomEvent.knowledgeCitations) { + ChatTimelineBuilder.appendKnowledge( + items, + normalizeKnowledgeItems(payload), + turnMetadata, + ); + return; + } + if (event.name === easyFlowAguiCustomEvent.asyncToolStatus) { + const input = asRecord(payload.input ?? payload.toolInput); + ChatTimelineBuilder.upsertToolCall(items, { + ...turnMetadata, + input: payload.input ?? payload.toolInput, + output: + payload.summary ?? + payload.label ?? + payload.output ?? + payload.result ?? + payload.text, + sourceToolCallId: asText( + payload.sourceToolCallId ?? payload.source_tool_call_id, + ), + status: asyncToolStatus(payload), + statusKey: statusKey(payload, options, 'knowledge-retrieval'), + taskId: asText(payload.taskId ?? input.taskId ?? input.task_id), + toolCallId: asText(payload.toolCallId ?? payload.taskId ?? payload.id), + toolName: asText( + payload.toolDisplayName ?? payload.toolName ?? payload.name, + ), + }); + return; + } + if (event.name === easyFlowAguiCustomEvent.knowledgeRetrievalStatus) { + ChatTimelineBuilder.upsertKnowledgeRetrievalStatus( + items, + asText(payload.status).toLowerCase() === 'running' ? 'running' : 'done', + statusKey(payload, options, 'knowledge-retrieval'), + turnMetadata, + ); + return; + } + if (event.name === easyFlowAguiCustomEvent.skillInvocationStatus) { + const status = normalizeSkillInvocationStatus(payload.status); + if (!status) { + return; + } + ChatTimelineBuilder.upsertSkillInvocationStatus(items, { + ...turnMetadata, + displayName: + asText(payload.skillDisplayName).trim() || + asText(payload.skillName).trim() || + '技能', + status, + statusKey: skillInvocationStatusKey(payload, options), + }); + return; + } + if (event.name === easyFlowAguiCustomEvent.runtimeContextStatus) { + ChatTimelineBuilder.upsertMemoryCompressionStatus(items, { + ...turnMetadata, + compressed: + typeof payload.compressed === 'boolean' + ? payload.compressed + : undefined, + label: asText(payload.label), + phase: asText(payload.phase), + status: asText(payload.status), + statusKey: statusKey(payload, options, 'memory-compression'), + }); + } +} + +export function applyAguiEventToTimeline( + items: ChatTimelineItem[], + event: AguiEvent, + options: AguiTimelineProjectionOptions = {}, + state = createAguiTimelineProjectionState(), +) { + state.startedAt ??= options.startedAt ?? Date.now(); + switch (event.type) { + case EventType.CUSTOM: { + applyCustomEvent(items, event, options, state); + return; + } + case EventType.MESSAGES_SNAPSHOT: { + const assistantMessage = [...event.messages] + .reverse() + .find((message) => message.role === 'assistant'); + if (assistantMessage?.content !== undefined) { + ChatTimelineBuilder.replaceMessageContent( + items, + assistantMessage.content, + metadata(options, state, assistantMessage.id), + ); + } + return; + } + case EventType.REASONING_MESSAGE_CONTENT: { + ChatTimelineBuilder.appendThinkingDelta( + items, + event.delta, + metadata(options, state, event.messageId), + ); + return; + } + case EventType.RUN_ERROR: { + if (event.code === 'RUN_CANCELLED') { + ChatTimelineBuilder.finalize( + items, + { + ...metadata(options, state), + turnFinishedAt: options.finishedAt ?? Date.now(), + turnSucceeded: false, + }, + { + runningSkillStatus: 'cancelled', + }, + ); + return; + } + ChatTimelineBuilder.appendError( + items, + event.message || '请求失败', + metadata(options, state), + ); + ChatTimelineBuilder.finalize(items, { + ...metadata(options, state), + turnFinishedAt: options.finishedAt ?? Date.now(), + turnSucceeded: false, + }); + return; + } + case EventType.RUN_FINISHED: { + ChatTimelineBuilder.finalize(items, { + ...metadata(options, state), + roundCompleted: true, + turnFinishedAt: options.finishedAt ?? Date.now(), + turnSucceeded: true, + }); + return; + } + case EventType.RUN_STARTED: { + ChatTimelineBuilder.ensureAssistantTurn( + items, + metadata( + options, + state, + options.roundId ? `turn-${options.roundId}` : undefined, + ), + ); + return; + } + case EventType.TEXT_MESSAGE_CONTENT: { + ChatTimelineBuilder.appendMessageDelta( + items, + event.delta, + metadata(options, state, event.messageId), + ); + return; + } + case EventType.TOOL_CALL_ARGS: { + const args = `${state.toolArgs.get(event.toolCallId) || ''}${event.delta}`; + state.toolArgs.set(event.toolCallId, args); + ChatTimelineBuilder.upsertToolCall(items, { + ...metadata(options, state), + input: parseToolArgs(args), + status: 'running', + toolCallId: event.toolCallId, + toolName: state.toolNames.get(event.toolCallId), + }); + return; + } + case EventType.TOOL_CALL_RESULT: { + ChatTimelineBuilder.upsertToolCall(items, { + ...metadata(options, state), + output: event.content, + status: 'success', + toolCallId: event.toolCallId, + toolName: state.toolNames.get(event.toolCallId), + }); + return; + } + case EventType.TOOL_CALL_START: { + state.toolNames.set(event.toolCallId, event.toolCallName); + ChatTimelineBuilder.upsertToolCall(items, { + ...metadata(options, state), + status: 'running', + toolCallId: event.toolCallId, + toolName: event.toolCallName, + }); + break; + } + default: { + break; + } + } +} diff --git a/easyflow-ui-admin/app/src/views/ai/shared/offline-impact.ts b/easyflow-ui-admin/app/src/views/ai/shared/offline-impact.ts index 659153a8..fc278de8 100644 --- a/easyflow-ui-admin/app/src/views/ai/shared/offline-impact.ts +++ b/easyflow-ui-admin/app/src/views/ai/shared/offline-impact.ts @@ -9,9 +9,11 @@ export interface OfflineImpactCheck { canProceed: boolean; hasAgentBindings: boolean; hasPluginBindings: boolean; + hasSkillBindings: boolean; hasWorkflowUsages: boolean; agentBindings: OfflineImpactBinding[]; pluginBindings: OfflineImpactBinding[]; + skillBindings: OfflineImpactBinding[]; workflowUsages: OfflineImpactBinding[]; message?: string; } @@ -21,7 +23,10 @@ function resolveTitle(item: OfflineImpactBinding) { } export function joinOfflineImpactTitles(items: OfflineImpactBinding[] = []) { - return items.map(resolveTitle).filter(Boolean).join('、'); + return items + .map((item) => resolveTitle(item)) + .filter(Boolean) + .join('、'); } export function buildOfflineImpactMessage( diff --git a/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.test.ts b/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.test.ts index ba3b90df..e5054aaf 100644 --- a/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.test.ts @@ -16,6 +16,9 @@ describe('skill studio contract', () => { ); expect(detailSource).not.toContain('能力绑定'); expect(detailSource).not.toContain('SkillCapabilityPanel'); + expect(detailSource).toContain('command="tools"'); + expect(detailSource).toContain(' { diff --git a/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.vue b/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.vue index 3d8d027a..e24af984 100644 --- a/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.vue +++ b/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.vue @@ -18,6 +18,7 @@ import { import { ArrowLeft, + Connection, Delete, Edit, MoreFilled, @@ -61,6 +62,7 @@ import { isSkillAccessDeniedError } from './skill-api-error'; import { readFrontmatterScalar, splitSkillMarkdown } from './skill-markdown'; import SkillResourceWorkbench from './SkillResourceWorkbench.vue'; import SkillSettingsDialog from './SkillSettingsDialog.vue'; +import SkillToolBindingDialog from './SkillToolBindingDialog.vue'; const route = useRoute(); const router = useRouter(); @@ -74,6 +76,7 @@ const loadError = ref(''); const loadAccessDenied = ref(false); const resourceDirty = ref(false); const settingsDialogOpen = ref(false); +const toolBindingDialogOpen = ref(false); const publishDialogOpen = ref(false); const publishReason = ref(''); const categories = ref([]); @@ -118,6 +121,9 @@ const canManage = computed( const canEditFiles = computed( () => canManage.value && hasPermission(['/api/v1/skill/file']), ); +const canManageTools = computed( + () => canManage.value && hasPermission(['/api/v1/skill/save']), +); const canSubmitPublish = computed( () => canManage.value && @@ -305,8 +311,11 @@ async function confirmPublish() { } } -function handleMoreCommand(command: 'delete' | 'offline' | 'settings') { +function handleMoreCommand( + command: 'delete' | 'offline' | 'settings' | 'tools', +) { if (command === 'settings') settingsDialogOpen.value = true; + if (command === 'tools') toolBindingDialogOpen.value = true; if (command === 'offline') void offline(); if (command === 'delete') void remove(); } @@ -360,6 +369,18 @@ function handleSettingsSaved(next: SkillInfo) { Object.assign(skill, next); } +function handleToolBindingsSaved( + bindings: NonNullable, +) { + skill.toolBindings = bindings; + skill.toolCount = bindings.reduce( + (total, binding) => + total + (binding.toolType === 'MCP' ? binding.mcpToolCount || 0 : 1), + 0, + ); + skill.hasToolUpdate = true; +} + function handleBeforeUnload(event: BeforeUnloadEvent) { if (!operationLocked.value && !resourceDirty.value) return; event.preventDefault(); @@ -452,6 +473,13 @@ function handleSaveShortcut(event: KeyboardEvent) { 基本信息 + + 工具绑定 + + + ({ + getSkillMcpTools: vi.fn(), + getSkillToolOptions: vi.fn(), + updateSkillToolBindings: vi.fn(), +})); + +vi.mock('./api', () => apiMocks); +vi.mock('@easyflow/common-ui', () => ({ + EasyFlowPanelModal: defineComponent({ + name: 'EasyFlowPanelModal', + props: { + open: Boolean, + title: { default: '', type: String }, + }, + emits: ['update:open'], + setup(props, { slots }) { + return () => + props.open + ? h('section', { 'data-testid': 'modal' }, [ + h('h2', props.title), + slots.default?.(), + ]) + : null; + }, + }), +})); + +function optionResponse(toolType = 'WORKFLOW') { + return { + data: { + pageNum: 1, + pageSize: 100, + records: [ + { + available: true, + description: '用于执行验收流程', + knownToolCount: 1, + targetId: toolType === 'MCP' ? 20 : 10, + title: toolType === 'MCP' ? '企业 MCP' : '合同审批流', + toolType, + }, + ], + total: 1, + }, + errorCode: 0, + }; +} + +function mountDialog(props: Record) { + return mount(SkillToolBindingDialog, { + attachTo: document.body, + global: { directives: { loading: {} } }, + props: props as never, + }); +} + +describe('skill tool binding dialog', () => { + beforeEach(() => { + vi.clearAllMocks(); + apiMocks.getSkillToolOptions.mockResolvedValue(optionResponse()); + apiMocks.getSkillMcpTools.mockResolvedValue({ + data: { + manifestHash: 'manifest-v2', + toolCount: 2, + tools: [ + { description: '查询订单', name: 'query_order' }, + { description: '创建订单', name: 'create_order' }, + ], + }, + errorCode: 0, + }); + apiMocks.updateSkillToolBindings.mockResolvedValue({ + data: [], + errorCode: 0, + }); + }); + + it('selects a workflow and sends only binding fields', async () => { + const wrapper = mountDialog({ modelValue: true, skillId: 101 }); + await flushPromises(); + + await wrapper.get('.skill-tools__candidate-content').trigger('click'); + await wrapper + .findAll('button') + .find((button) => button.text().trim() === '保存绑定') + ?.trigger('click'); + await flushPromises(); + + expect(apiMocks.updateSkillToolBindings).toHaveBeenCalledWith(101, [ + expect.objectContaining({ + hitlEnabled: false, + targetId: 10, + toolType: 'WORKFLOW', + }), + ]); + const submitted = apiMocks.updateSkillToolBindings.mock.calls[0]?.[1]?.[0]; + expect(submitted).not.toHaveProperty('resourceSnapshot'); + expect(wrapper.emitted('saved')).toHaveLength(1); + wrapper.unmount(); + }); + + it('binds one MCP as a whole service and exposes its child tools on demand', async () => { + apiMocks.getSkillToolOptions.mockImplementation( + ({ toolType }: { toolType: string }) => + Promise.resolve(optionResponse(toolType)), + ); + const wrapper = mountDialog({ modelValue: true, skillId: 101 }); + await flushPromises(); + + const mcpTab = wrapper + .findAll('[role="tab"]') + .find((tab) => tab.text().trim() === 'MCP'); + await mcpTab?.trigger('click'); + await flushPromises(); + await wrapper.get('.skill-tools__candidate-content').trigger('click'); + await flushPromises(); + + expect(apiMocks.getSkillMcpTools).toHaveBeenCalledWith(20); + expect(wrapper.text()).toContain('2 个工具'); + const expand = wrapper + .findAll('button') + .find((button) => button.text().includes('查看工具')); + await expand?.trigger('click'); + await flushPromises(); + expect(wrapper.text()).toContain('query_order'); + expect(wrapper.text()).toContain('create_order'); + wrapper.unmount(); + }); + + it('requires explicit confirmation when a bound MCP manifest changes', async () => { + const wrapper = mountDialog({ + bindings: [ + { + hitlEnabled: true, + mcpToolCount: 1, + mcpToolManifestHash: 'manifest-v1', + resourceSummary: { + approvalRequired: true, + available: true, + title: '企业 MCP', + toolCount: 1, + }, + targetId: 20, + toolType: 'MCP', + }, + ], + modelValue: true, + skillId: 101, + }); + await flushPromises(); + + expect(wrapper.text()).toContain('清单变化'); + const saveButton = wrapper + .findAll('button') + .find((button) => button.text().trim() === '保存绑定'); + expect((saveButton?.element as HTMLButtonElement).disabled).toBe(true); + const accept = wrapper + .findAll('button') + .find((button) => button.text().trim() === '确认最新清单'); + await accept?.trigger('click'); + await flushPromises(); + expect(wrapper.text()).not.toContain('清单变化'); + expect(wrapper.text()).toContain('保存后随下一次发布生效'); + expect((saveButton?.element as HTMLButtonElement).disabled).toBe(false); + wrapper.unmount(); + }); + + it('keeps invalid bindings visible and removable', async () => { + const wrapper = mountDialog({ + bindings: [ + { + resourceSummary: { + available: false, + title: '已删除工作流', + }, + targetId: 99, + toolType: 'WORKFLOW', + }, + ], + modelValue: true, + skillId: 101, + }); + await flushPromises(); + + expect(wrapper.text()).toContain('已删除工作流'); + expect(wrapper.text()).toContain('请移除已失效的绑定'); + const remove = wrapper + .findAll('button') + .find((button) => button.text().trim() === '移除'); + await remove?.trigger('click'); + expect(wrapper.text()).toContain('从左侧选择工具'); + wrapper.unmount(); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/ai/skill/SkillToolBindingDialog.vue b/easyflow-ui-admin/app/src/views/ai/skill/SkillToolBindingDialog.vue new file mode 100644 index 00000000..35d25bbb --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/skill/SkillToolBindingDialog.vue @@ -0,0 +1,1066 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/ai/skill/api.ts b/easyflow-ui-admin/app/src/views/ai/skill/api.ts index 3a769c3c..a9c7e11e 100644 --- a/easyflow-ui-admin/app/src/views/ai/skill/api.ts +++ b/easyflow-ui-admin/app/src/views/ai/skill/api.ts @@ -8,6 +8,10 @@ import type { SkillImportConfirmPayload, SkillImportPreview, SkillInfo, + SkillMcpToolManifest, + SkillToolBinding, + SkillToolOptionPage, + SkillToolType, SkillValidationResult, } from './types'; @@ -22,6 +26,45 @@ export function getSkillDetail(id: number | string) { }); } +export function getSkillToolOptions(params: { + keyword?: string; + pageNum?: number; + pageSize?: number; + toolType?: SkillToolType; +}) { + return api.get>( + '/api/v1/skill/toolOptions', + { params }, + ); +} + +export function getSkillMcpTools(mcpId: number | string) { + return api.get>( + '/api/v1/skill/mcpTools', + { params: { mcpId } }, + ); +} + +export function updateSkillToolBindings( + skillId: number | string, + bindings: SkillToolBinding[], +) { + return api.post>( + '/api/v1/skill/toolBinding/update', + { + bindings: bindings.map((binding, sortNo) => ({ + hitlEnabled: Boolean(binding.hitlEnabled), + mcpToolManifestHash: + binding.toolType === 'MCP' ? binding.mcpToolManifestHash : undefined, + sortNo, + targetId: binding.targetId, + toolType: binding.toolType, + })), + skillId, + }, + ); +} + export function saveSkill(skill: SkillInfo) { return api.post>( '/api/v1/skill/save', diff --git a/easyflow-ui-admin/app/src/views/ai/skill/skill-tool-api.test.ts b/easyflow-ui-admin/app/src/views/ai/skill/skill-tool-api.test.ts new file mode 100644 index 00000000..cedc66ae --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/skill/skill-tool-api.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + getSkillMcpTools, + getSkillToolOptions, + updateSkillToolBindings, +} from './api'; + +const requestMocks = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), +})); + +vi.mock('#/api/request', () => ({ api: requestMocks })); + +describe('skill tool binding api', () => { + beforeEach(() => vi.clearAllMocks()); + + it('uses the safe candidate and MCP manifest endpoints', () => { + getSkillToolOptions({ + keyword: '合同', + pageNum: 1, + pageSize: 100, + toolType: 'WORKFLOW', + }); + getSkillMcpTools(20); + + expect(requestMocks.get).toHaveBeenNthCalledWith( + 1, + '/api/v1/skill/toolOptions', + { + params: { + keyword: '合同', + pageNum: 1, + pageSize: 100, + toolType: 'WORKFLOW', + }, + }, + ); + expect(requestMocks.get).toHaveBeenNthCalledWith( + 2, + '/api/v1/skill/mcpTools', + { params: { mcpId: 20 } }, + ); + }); + + it('submits only the binding whitelist', () => { + updateSkillToolBindings(101, [ + { + hitlEnabled: true, + mcpToolCount: 2, + mcpToolManifestHash: 'manifest-v2', + resourceSummary: { + available: true, + description: '不应回传', + title: '企业 MCP', + toolCount: 2, + }, + sortNo: 9, + targetId: 20, + toolType: 'MCP', + }, + ]); + + expect(requestMocks.post).toHaveBeenCalledWith( + '/api/v1/skill/toolBinding/update', + { + bindings: [ + { + hitlEnabled: true, + mcpToolManifestHash: 'manifest-v2', + sortNo: 0, + targetId: 20, + toolType: 'MCP', + }, + ], + skillId: 101, + }, + ); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/ai/skill/types.ts b/easyflow-ui-admin/app/src/views/ai/skill/types.ts index eb76c1de..7a9867ab 100644 --- a/easyflow-ui-admin/app/src/views/ai/skill/types.ts +++ b/easyflow-ui-admin/app/src/views/ai/skill/types.ts @@ -6,6 +6,7 @@ export interface RequestResult { export type SkillIssueSeverity = 'ERROR' | 'INFO' | 'WARNING'; export type SkillVisibilityScope = 'DEPT' | 'PRIVATE' | 'PUBLIC'; +export type SkillToolType = 'MCP' | 'PLUGIN' | 'WORKFLOW'; export interface SkillInfo { approvalPending?: boolean; @@ -28,9 +29,59 @@ export interface SkillInfo { resources?: SkillResource[]; skillContent?: string; snapshotHash?: string; + hasToolUpdate?: boolean; + toolBindings?: SkillToolBinding[]; + toolCount?: number; visibilityScope?: SkillVisibilityScope; } +export interface SkillToolBinding { + hitlEnabled?: boolean; + id?: number | string; + mcpToolCount?: number; + mcpToolManifestHash?: string; + resourceSummary?: { + approvalRequired?: boolean; + available?: boolean; + description?: string; + title?: string; + toolCount?: number; + }; + sortNo?: number; + targetId: number | string; + toolType: SkillToolType; +} + +export interface SkillToolOption { + approvalRequired?: boolean; + available: boolean; + description?: string; + knownToolCount?: number; + targetId: number | string; + title: string; + toolType: SkillToolType; +} + +export interface SkillToolOptionPage { + pageNum: number; + pageSize: number; + records: SkillToolOption[]; + total: number; +} + +export interface SkillMcpTool { + description?: string; + inputSchema?: unknown; + name: string; + outputSchema?: unknown; +} + +export interface SkillMcpToolManifest { + manifestHash: string; + toolCount: number; + tools: SkillMcpTool[]; +} + export interface SkillResource { content?: string; contentHash?: string; diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue index 595d26b2..b308be65 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue +++ b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue @@ -1037,15 +1037,19 @@ async function submitOfflineAction(row: any) { } try { const sections = []; - let offlineImpactFooter = $t('aiWorkflow.offlineImpactBoundAgentsFooter'); if (impactRes.data?.hasAgentBindings) { sections.push( buildOfflineImpactMessage( $t('aiWorkflow.offlineImpactBoundAgentsIntro'), impactRes.data.agentBindings, - impactRes.data?.hasPluginBindings - ? undefined - : $t('aiWorkflow.offlineImpactBoundAgentsFooter'), + ), + ); + } + if (impactRes.data?.hasSkillBindings) { + sections.push( + buildOfflineImpactMessage( + $t('aiWorkflow.offlineImpactBoundSkillsIntro'), + impactRes.data.skillBindings, ), ); } @@ -1054,29 +1058,37 @@ async function submitOfflineAction(row: any) { buildOfflineImpactMessage( $t('aiWorkflow.offlineImpactBoundPluginsIntro'), impactRes.data.pluginBindings, - impactRes.data?.hasAgentBindings - ? undefined - : $t('aiWorkflow.offlineImpactBoundPluginsFooter'), ), ); } - if (impactRes.data?.hasAgentBindings && impactRes.data?.hasPluginBindings) { - offlineImpactFooter = $t('aiWorkflow.offlineImpactBoundMixedFooter'); - } else if (impactRes.data?.hasPluginBindings) { - offlineImpactFooter = $t('aiWorkflow.offlineImpactBoundPluginsFooter'); + if (!impactRes.data?.canProceed) { + const blockedMessage = + sections.length > 0 + ? h('div', [ + ...sections, + h( + 'p', + { style: 'margin-top: 12px;' }, + $t('aiWorkflow.offlineImpactBlockedFooter'), + ), + ]) + : impactRes.data?.message || + $t('aiWorkflow.offlineImpactBlockedFooter'); + await ElMessageBox.alert(blockedMessage, $t('message.noticeTitle'), { + confirmButtonText: $t('button.confirm'), + type: 'warning', + }); + return; } - const impactMessage = - sections.length > 0 - ? h('div', [ - ...sections, - h('p', { style: 'margin-top: 12px;' }, offlineImpactFooter), - ]) - : $t('aiWorkflow.submitOfflineApprovalConfirm'); - await ElMessageBox.confirm(impactMessage, $t('message.noticeTitle'), { - confirmButtonText: $t('button.confirm'), - cancelButtonText: $t('button.cancel'), - type: 'warning', - }); + await ElMessageBox.confirm( + $t('aiWorkflow.submitOfflineApprovalConfirm'), + $t('message.noticeTitle'), + { + confirmButtonText: $t('button.confirm'), + cancelButtonText: $t('button.cancel'), + type: 'warning', + }, + ); } catch { return; } diff --git a/easyflow-ui-admin/packages/@core/base/icons/src/local-icons.ts b/easyflow-ui-admin/packages/@core/base/icons/src/local-icons.ts index d5a0b5cd..36e079fc 100644 --- a/easyflow-ui-admin/packages/@core/base/icons/src/local-icons.ts +++ b/easyflow-ui-admin/packages/@core/base/icons/src/local-icons.ts @@ -50,6 +50,11 @@ export const LOCAL_ICON_DATA: Record = { height: 24, body: '', }, + 'lucide:notebook-tabs': { + width: 24, + height: 24, + body: '', + }, 'lucide:copyright': { width: 24, height: 24, diff --git a/easyflow-ui-admin/packages/@core/base/icons/src/lucide.ts b/easyflow-ui-admin/packages/@core/base/icons/src/lucide.ts index c0ea9cd5..118abdb8 100644 --- a/easyflow-ui-admin/packages/@core/base/icons/src/lucide.ts +++ b/easyflow-ui-admin/packages/@core/base/icons/src/lucide.ts @@ -57,6 +57,7 @@ export { Minimize, Minimize2, MoonStar, + NotebookTabs, Palette, PanelLeft, PanelRight, @@ -78,3 +79,8 @@ export { UserRoundPen, X, } from 'lucide-vue-next'; + +export { + BookOpenText as KnowledgeIcon, + NotebookTabs as SkillIcon, +} from 'lucide-vue-next'; diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatArtifactAttachment.vue b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatArtifactAttachment.vue new file mode 100644 index 00000000..6a6e3916 --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatArtifactAttachment.vue @@ -0,0 +1,265 @@ + + + + + diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimeline.vue b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimeline.vue index 30fc60c2..3d299ef9 100644 --- a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimeline.vue +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimeline.vue @@ -1,5 +1,6 @@ + + + + diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatArtifactAttachment.test.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatArtifactAttachment.test.ts new file mode 100644 index 00000000..ccf9b750 --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatArtifactAttachment.test.ts @@ -0,0 +1,65 @@ +import type { ChatTimelineArtifactItem } from '../types'; + +import { flushPromises, mount } from '@vue/test-utils'; + +import { describe, expect, it, vi } from 'vitest'; + +import ChatArtifactAttachment from '../ChatArtifactAttachment.vue'; + +function artifact( + status: ChatTimelineArtifactItem['status'] = 'available', +): ChatTimelineArtifactItem { + return { + artifactId: '01JTESTARTIFACT', + downloadUrl: + status === 'available' + ? '/api/v1/agent/artifacts/01JTESTARTIFACT/content' + : undefined, + fileName: '项目报告.pdf', + id: 'artifact:01JTESTARTIFACT', + mimeType: 'application/pdf', + sha256: 'a'.repeat(64), + size: 2048, + status, + type: 'artifact', + }; +} + +describe('chat Artifact attachment', () => { + it('支持鉴权下载、失败提示和原位重试', async () => { + const loader = vi + .fn() + .mockRejectedValueOnce(new Error('网络中断')) + .mockResolvedValueOnce(undefined); + const wrapper = mount(ChatArtifactAttachment, { + props: { artifactLoader: loader, item: artifact() }, + }); + + await wrapper.get('button').trigger('click'); + await flushPromises(); + expect(wrapper.text()).toContain('网络中断,点击重试'); + expect(wrapper.get('button').attributes('aria-label')).toContain( + '重试下载', + ); + + await wrapper.get('button').trigger('click'); + await flushPromises(); + expect(loader).toHaveBeenCalledTimes(2); + expect(wrapper.text()).toContain('PDF · 2 KB'); + }); + + it.each([ + ['expired', '已过期'], + ['delete_failed', '删除失败'], + ['unavailable', '不可用'], + ] as const)('展示 %s 状态并禁用下载', (status, label) => { + const loader = vi.fn(); + const wrapper = mount(ChatArtifactAttachment, { + props: { artifactLoader: loader, item: artifact(status) }, + }); + + expect(wrapper.text()).toContain(label); + expect(wrapper.get('button').attributes('disabled')).toBeDefined(); + expect(wrapper.html()).not.toMatch(/MinIO|bucket|objectKey|workspace/i); + }); +}); diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineStatusRow.test.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineStatusRow.test.ts index f86c92c6..fc476e5c 100644 --- a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineStatusRow.test.ts +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineStatusRow.test.ts @@ -1,12 +1,14 @@ -import type {ChatTimelineStatusItem} from '../types'; +import type { ChatTimelineStatusItem } from '../types'; -import {mount} from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; -import {describe, expect, it} from 'vitest'; +import { SkillIcon } from '@easyflow/icons'; + +import { describe, expect, it } from 'vitest'; import ChatTimelineStatusRow from '../ChatTimelineStatusRow.vue'; -describe('ChatTimelineStatusRow', () => { +describe('chatTimelineStatusRow', () => { it('uses shimmer text while running and static text after done', async () => { const item: ChatTimelineStatusItem = { id: 'knowledge-retrieval', @@ -20,7 +22,9 @@ describe('ChatTimelineStatusRow', () => { }); expect(wrapper.text()).toContain('正在检索知识库'); - expect(wrapper.find('.chat-timeline-status-row__line').exists()).toBe(false); + expect(wrapper.find('.chat-timeline-status-row__line').exists()).toBe( + false, + ); expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true); expect(wrapper.find('.chat-shimmer-text').classes()).toContain('is-active'); @@ -33,7 +37,9 @@ describe('ChatTimelineStatusRow', () => { }); expect(wrapper.text()).toContain('已检索知识库'); - expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain('is-active'); + expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain( + 'is-active', + ); }); it('renders memory compression status as a separator row', () => { @@ -52,7 +58,9 @@ describe('ChatTimelineStatusRow', () => { expect(wrapper.classes()).toContain('is-separator'); expect(wrapper.text()).toContain('正在整理上下文'); expect(wrapper.findAll('.chat-timeline-status-row__line')).toHaveLength(2); - expect(wrapper.find('.chat-timeline-status-row__content').exists()).toBe(true); + expect(wrapper.find('.chat-timeline-status-row__content').exists()).toBe( + true, + ); expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true); expect(wrapper.find('.chat-shimmer-text').classes()).toContain('is-active'); }); @@ -71,7 +79,9 @@ describe('ChatTimelineStatusRow', () => { expect(wrapper.find('.chat-event-label').exists()).toBe(true); expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true); - expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain('is-active'); + expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain( + 'is-active', + ); }); it('can render a plain inline status without the context icon', () => { @@ -88,7 +98,53 @@ describe('ChatTimelineStatusRow', () => { }); expect(wrapper.text()).toContain('运行完成'); - expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(false); - expect(wrapper.find('.chat-timeline-status-row__line').exists()).toBe(false); + expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe( + false, + ); + expect(wrapper.find('.chat-timeline-status-row__line').exists()).toBe( + false, + ); + }); + + it('uses the shared Skill icon, full accessible label and failure tone', () => { + const label = `调用 ${'超长技能名称'.repeat(20)} 失败`; + const item: ChatTimelineStatusItem = { + icon: 'skill', + id: 'skill-invocation:r1:101', + label, + status: 'error', + statusKey: 'skill-invocation:r1:101', + tone: 'danger', + type: 'status', + }; + const wrapper = mount(ChatTimelineStatusRow, { + props: { item }, + }); + + expect(wrapper.attributes('aria-label')).toBe(label); + expect(wrapper.attributes('title')).toBe(label); + expect(wrapper.classes()).toContain('is-error'); + expect(wrapper.classes()).toContain('is-danger'); + expect(wrapper.find('.chat-timeline-status-row__icon').exists()).toBe(true); + expect(wrapper.find('.chat-shimmer-text').classes()).not.toContain( + 'is-active', + ); + }); + + it('uses the knowledge status shimmer for a running Skill', () => { + const item: ChatTimelineStatusItem = { + icon: 'skill', + id: 'skill-invocation:r1:101', + label: '正在调用 合同审查助手', + status: 'running', + statusKey: 'skill-invocation:r1:101', + type: 'status', + }; + const wrapper = mount(ChatTimelineStatusRow, { + props: { item }, + }); + + expect(wrapper.find('.chat-shimmer-text').classes()).toContain('is-active'); + expect(wrapper.findComponent(SkillIcon).exists()).toBe(true); }); }); diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineTurn.test.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineTurn.test.ts new file mode 100644 index 00000000..292b7cb7 --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineTurn.test.ts @@ -0,0 +1,464 @@ +import type { ChatTimelineItem } from '../types'; + +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import ChatTimeline from '../ChatTimeline.vue'; +import ChatTimelineTurn from '../ChatTimelineTurn.vue'; + +function completedTurnItems(): ChatTimelineItem[] { + return [ + { + id: 'reasoning-1', + parts: [ + { + content: '先检索资料', + id: 'thinking-1', + status: 'end', + type: 'thinking', + }, + ], + role: 'assistant', + roundCompleted: true, + roundId: 'round-1', + status: 'done', + turnFinishedAt: 19_000, + turnStartedAt: 1000, + turnSucceeded: true, + type: 'message', + }, + { + id: 'tool-1', + input: { query: 'AG-UI' }, + mode: 'auto', + roundCompleted: true, + roundId: 'round-1', + status: 'success', + toolCallId: 'tool-1', + toolName: 'Context7 查询', + turnFinishedAt: 19_000, + turnStartedAt: 1000, + turnSucceeded: true, + type: 'tool', + }, + { + id: 'assistant-final', + parts: [ + { content: 'AG-UI 是智能体交互协议。', id: 'text-1', type: 'text' }, + ], + role: 'assistant', + roundCompleted: true, + roundId: 'round-1', + status: 'done', + turnFinishedAt: 19_000, + turnStartedAt: 1000, + turnSucceeded: true, + type: 'message', + }, + ]; +} + +describe('chat timeline turn', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(19_000); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it('shows one running header for an empty RUN_STARTED placeholder', () => { + const wrapper = mount(ChatTimeline, { + props: { + assistantAvatar: '/assistant.svg', + items: [ + { + id: 'turn-round-started', + parts: [], + role: 'assistant', + roundId: 'round-started', + status: 'streaming', + turnStartedAt: 1000, + type: 'message', + }, + ], + }, + }); + + expect(wrapper.findAll('.chat-timeline-turn__avatar')).toHaveLength(1); + expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe( + '已处理 18 秒', + ); + expect( + wrapper.findAll('.chat-timeline-item__assistant-avatar'), + ).toHaveLength(0); + expect(wrapper.text()).toContain('正在继续处理'); + }); + + it('keeps continuous feedback around a fast automatic tool call', async () => { + const assistant: ChatTimelineItem = { + id: 'assistant-fast-tool', + parts: [ + { + content: '开始生成文件。', + id: 'text-fast-tool', + type: 'text', + }, + ], + role: 'assistant', + roundId: 'round-fast-tool', + status: 'streaming', + turnStartedAt: 1000, + type: 'message', + }; + const runningTool: ChatTimelineItem = { + id: 'tool-fast-write', + mode: 'auto', + roundId: 'round-fast-tool', + status: 'running', + toolCallId: 'tool-fast-write', + toolName: 'write_text_file', + turnStartedAt: 1000, + type: 'tool', + }; + const wrapper = mount(ChatTimelineTurn, { + props: { + items: [assistant], + roundId: 'round-fast-tool', + }, + }); + + expect(wrapper.text()).toContain('正在继续处理'); + + await wrapper.setProps({ items: [assistant, runningTool] }); + + expect(wrapper.text()).toContain('调用中'); + expect(wrapper.text()).not.toContain('正在继续处理'); + + await wrapper.setProps({ + items: [assistant, { ...runningTool, status: 'success' }], + }); + + expect(wrapper.text()).toContain('已完成'); + expect(wrapper.text()).toContain('正在继续处理'); + }); + + it('updates the running duration every second and freezes it on success', async () => { + vi.setSystemTime(1000); + const runningItem: ChatTimelineItem = { + id: 'turn-round-live', + parts: [], + role: 'assistant', + roundId: 'round-live', + status: 'streaming', + turnStartedAt: 1000, + type: 'message', + }; + const wrapper = mount(ChatTimelineTurn, { + props: { + items: [runningItem], + roundId: 'round-live', + }, + }); + + expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe( + '已处理 1 秒', + ); + expect(vi.getTimerCount()).toBe(1); + + vi.advanceTimersByTime(64_000); + await nextTick(); + + expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe( + '已处理 1 分 4 秒', + ); + + await wrapper.setProps({ + items: [ + { + ...runningItem, + roundCompleted: true, + status: 'done', + turnFinishedAt: 65_000, + turnSucceeded: true, + }, + ], + }); + + expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe( + '已处理 1 分 4 秒', + ); + expect(vi.getTimerCount()).toBe(0); + + vi.advanceTimersByTime(10_000); + await nextTick(); + + expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe( + '已处理 1 分 4 秒', + ); + }); + + it('switches the running timer to the incomplete terminal state', async () => { + vi.setSystemTime(5000); + const runningItem: ChatTimelineItem = { + id: 'turn-round-error', + parts: [], + role: 'assistant', + roundId: 'round-error', + status: 'streaming', + turnStartedAt: 1000, + type: 'message', + }; + const wrapper = mount(ChatTimelineTurn, { + props: { + items: [runningItem], + roundId: 'round-error', + }, + }); + + expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe( + '已处理 4 秒', + ); + + await wrapper.setProps({ + items: [ + { + ...runningItem, + status: 'error', + turnFinishedAt: 5000, + turnSucceeded: false, + }, + ], + }); + + expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe( + '处理未完成', + ); + expect(vi.getTimerCount()).toBe(0); + }); + + it('keeps partial assistant text in event order while the turn is active', () => { + const items: ChatTimelineItem[] = [ + { + id: 'assistant-partial', + parts: [ + { + content: '先检查依赖', + id: 'thinking-1', + status: 'end', + type: 'thinking', + }, + { content: '正文 A', id: 'text-1', type: 'text' }, + ], + role: 'assistant', + roundId: 'round-live-order', + status: 'streaming', + turnStartedAt: 1000, + type: 'message', + }, + { + id: 'tool-completed', + mode: 'auto', + roundId: 'round-live-order', + status: 'success', + toolCallId: 'tool-completed', + toolName: '已执行工具', + turnStartedAt: 1000, + type: 'tool', + }, + { + id: 'assistant-reasoning', + parts: [ + { + content: '继续思考', + id: 'thinking-2', + status: 'thinking', + type: 'thinking', + }, + ], + role: 'assistant', + roundId: 'round-live-order', + status: 'streaming', + turnStartedAt: 1000, + type: 'message', + }, + { + approval: { + approvalId: 'approval-1', + toolCallId: 'tool-pending', + toolName: '待审批工具', + }, + id: 'tool-pending', + mode: 'approval', + roundId: 'round-live-order', + status: 'pending_approval', + toolCallId: 'tool-pending', + toolName: '待审批工具', + turnStartedAt: 1000, + type: 'tool', + }, + ]; + const wrapper = mount(ChatTimelineTurn, { + props: { items, roundId: 'round-live-order' }, + }); + const rendered = wrapper.text(); + + expect(wrapper.find('[data-chat-turn-final]').exists()).toBe(false); + expect(rendered.indexOf('正文 A')).toBeLessThan( + rendered.indexOf('已执行工具'), + ); + expect(rendered.indexOf('已执行工具')).toBeLessThan( + rendered.indexOf('继续思考'), + ); + expect(rendered.indexOf('继续思考')).toBeLessThan( + rendered.indexOf('待审批工具'), + ); + }); + + it('renders one avatar and collapses process after a successful turn', async () => { + const wrapper = mount(ChatTimeline, { + props: { + assistantAvatar: '/assistant.svg', + items: completedTurnItems(), + }, + }); + + expect(wrapper.findAll('.chat-timeline-turn__avatar')).toHaveLength(1); + expect( + wrapper.findAll('.chat-timeline-item__assistant-avatar'), + ).toHaveLength(0); + expect(wrapper.find('.chat-timeline-turn__summary').text()).toContain( + '已处理 18 秒', + ); + expect(wrapper.text()).toContain('AG-UI 是智能体交互协议。'); + expect(wrapper.text()).not.toContain('Context7 查询'); + expect(wrapper.text()).not.toContain('先检索资料'); + + await wrapper.find('.chat-timeline-turn__summary').trigger('click'); + + expect(wrapper.text()).toContain('Context7 查询'); + expect(wrapper.text()).toContain('已思考'); + expect(wrapper.text()).toContain('AG-UI 是智能体交互协议。'); + }); + + it('preserves the final-message scroll anchor while auto-collapsing', async () => { + const runningItems = completedTurnItems().map((item) => { + const { + roundCompleted: _roundCompleted, + turnFinishedAt: _turnFinishedAt, + turnSucceeded: _turnSucceeded, + ...runningItem + } = item; + return runningItem; + }); + const wrapper = mount(ChatTimeline, { + attachTo: document.body, + global: { stubs: { Transition: false } }, + props: { + assistantAvatar: '/assistant.svg', + items: runningItems, + }, + }); + const container = wrapper.find('.chat-timeline').element as HTMLElement; + Object.defineProperties(container, { + clientHeight: { configurable: true, value: 500 }, + scrollHeight: { configurable: true, value: 1200 }, + scrollTop: { configurable: true, value: 400, writable: true }, + }); + vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({ + bottom: 500, + top: 0, + } as DOMRect); + const header = wrapper.find('.chat-timeline-turn__header') + .element as HTMLElement; + vi.spyOn(header, 'getBoundingClientRect').mockReturnValue({ + bottom: -72, + top: -100, + } as DOMRect); + const liveMessage = wrapper.find('[data-chat-turn-live-anchor]') + .element as HTMLElement; + vi.spyOn(liveMessage, 'getBoundingClientRect') + .mockReturnValueOnce({ bottom: 700, top: 600 } as DOMRect) + .mockReturnValue({ bottom: 400, top: 300 } as DOMRect); + await wrapper.find('.chat-timeline').trigger('scroll'); + wrapper.findComponent(ChatTimelineTurn).vm.$emit('layoutToggle', 'round-1'); + + await wrapper.setProps({ items: completedTurnItems() }); + await nextTick(); + wrapper.findComponent(ChatTimelineTurn).vm.$emit('layoutChanged'); + await nextTick(); + + expect(container.scrollTop).toBe(100); + wrapper.unmount(); + }); + + it('keeps running and approval content expanded in one turn', () => { + const items: ChatTimelineItem[] = [ + { + id: 'reasoning-1', + parts: [ + { + content: '准备调用工具', + id: 'thinking-1', + status: 'thinking', + type: 'thinking', + }, + ], + role: 'assistant', + roundId: 'round-1', + status: 'streaming', + turnStartedAt: 1000, + type: 'message', + }, + { + approval: { + approvalId: 'approval-1', + toolCallId: 'tool-1', + toolName: 'context7', + }, + id: 'tool-1', + mode: 'approval', + roundId: 'round-1', + status: 'pending_approval', + toolCallId: 'tool-1', + toolName: 'Context7 查询', + turnStartedAt: 1000, + type: 'tool', + }, + ]; + const wrapper = mount(ChatTimeline, { + props: { assistantAvatar: '/assistant.svg', items }, + }); + + expect(wrapper.findAll('.chat-timeline-turn__avatar')).toHaveLength(1); + expect(wrapper.find('.chat-timeline-turn__summary').text()).toBe( + '已处理 18 秒', + ); + expect(wrapper.text()).toContain('准备调用工具'); + expect(wrapper.text()).toContain('Context7 查询'); + expect( + wrapper.find('.chat-timeline-turn__summary').attributes('disabled'), + ).toBeDefined(); + }); + + it('renders one avatar for each of multiple turns', () => { + const secondTurn = completedTurnItems().map((item) => ({ + ...item, + id: `${item.id}-2`, + roundId: 'round-2', + })); + const wrapper = mount(ChatTimeline, { + props: { + assistantAvatar: '/assistant.svg', + items: [...completedTurnItems(), ...secondTurn], + }, + }); + + expect(wrapper.findAll('.chat-timeline-turn')).toHaveLength(2); + expect(wrapper.findAll('.chat-timeline-turn__avatar')).toHaveLength(2); + }); +}); diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/builder.test.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/builder.test.ts index a5e79db4..78aaa40d 100644 --- a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/builder.test.ts +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/builder.test.ts @@ -1,8 +1,8 @@ -import type {ChatTimelineItem} from '../types'; +import type { ChatTimelineItem } from '../types'; -import {describe, expect, it} from 'vitest'; +import { describe, expect, it } from 'vitest'; -import {ChatTimelineBuilder} from '../builder'; +import { ChatTimelineBuilder } from '../builder'; describe('chat timeline builder', () => { it('keeps streamed thinking, text, tool and following text in timeline order', () => { @@ -156,6 +156,72 @@ describe('chat timeline builder', () => { } }); + it('updates one Skill invocation row in place and preserves Skill order', () => { + const items: ChatTimelineItem[] = []; + + ChatTimelineBuilder.upsertSkillInvocationStatus(items, { + displayName: '合同审查助手', + status: 'RUNNING', + statusKey: 'skill-invocation:r1:101', + }); + ChatTimelineBuilder.upsertSkillInvocationStatus(items, { + displayName: '数据分析助手', + status: 'RUNNING', + statusKey: 'skill-invocation:r1:102', + }); + ChatTimelineBuilder.upsertSkillInvocationStatus(items, { + displayName: '合同审查助手', + status: 'SUCCESS', + statusKey: 'skill-invocation:r1:101', + }); + + expect(items).toHaveLength(2); + expect(items[0]).toMatchObject({ + icon: 'skill', + label: '已调用 合同审查助手', + status: 'done', + }); + expect(items[1]).toMatchObject({ + icon: 'skill', + label: '正在调用 数据分析助手', + status: 'running', + }); + }); + + it('never turns an unterminated Skill invocation into fake success', () => { + const incompleteItems: ChatTimelineItem[] = []; + ChatTimelineBuilder.upsertSkillInvocationStatus(incompleteItems, { + displayName: '合同审查助手', + roundId: 'r1', + status: 'RUNNING', + statusKey: 'skill-invocation:r1:101', + }); + ChatTimelineBuilder.finalize(incompleteItems, { roundId: 'r1' }); + + expect(incompleteItems[0]).toMatchObject({ + label: '调用 合同审查助手 未完成', + status: 'incomplete', + }); + + const cancelledItems: ChatTimelineItem[] = []; + ChatTimelineBuilder.upsertSkillInvocationStatus(cancelledItems, { + displayName: '合同审查助手', + roundId: 'r2', + status: 'RUNNING', + statusKey: 'skill-invocation:r2:101', + }); + ChatTimelineBuilder.finalize( + cancelledItems, + { roundId: 'r2' }, + { runningSkillStatus: 'cancelled' }, + ); + + expect(cancelledItems[0]).toMatchObject({ + label: '已停止调用 合同审查助手', + status: 'cancelled', + }); + }); + it('removes memory compression status when compression produced no compressed event', () => { const items: ChatTimelineItem[] = []; @@ -352,15 +418,13 @@ describe('chat timeline builder', () => { const items: ChatTimelineItem[] = []; ChatTimelineBuilder.appendToolApproval(items, { - requestId: 'request-1', - resumeToken: 'resume-1', + approvalId: 'approval-1', toolCallId: 'call-1', toolName: '审批工具', input: { keyword: 'EasyFlow' }, }); ChatTimelineBuilder.markToolApproving(items, { - requestId: 'request-1', - resumeToken: 'resume-1', + approvalId: 'approval-1', toolCallId: 'call-1', }); ChatTimelineBuilder.upsertToolCall(items, { @@ -379,7 +443,7 @@ describe('chat timeline builder', () => { if (items[0]?.type === 'tool') { expect(items[0].mode).toBe('approval'); expect(items[0].status).toBe('success'); - expect(items[0].approval?.requestId).toBe('request-1'); + expect(items[0].approval?.approvalId).toBe('approval-1'); expect(items[0].input).toEqual({ keyword: 'EasyFlow' }); expect(items[0].output).toEqual({ result: 'ok' }); } @@ -389,8 +453,7 @@ describe('chat timeline builder', () => { const items: ChatTimelineItem[] = []; ChatTimelineBuilder.appendToolApproval(items, { - requestId: 'request-1', - resumeToken: 'resume-1', + approvalId: 'approval-1', toolCallId: 'submit-call-1', toolName: '文档生成', input: { user_input: '写一篇小作文' }, @@ -452,14 +515,13 @@ describe('chat timeline builder', () => { const items: ChatTimelineItem[] = []; ChatTimelineBuilder.appendToolApproval(items, { - requestId: 'request-1', - resumeToken: 'resume-1', + approvalId: 'approval-1', toolCallId: 'call-1', toolName: '审批工具', input: { keyword: 'EasyFlow' }, }); ChatTimelineBuilder.markToolRejected(items, { - requestId: 'request-1', + approvalId: 'approval-1', toolCallId: 'call-1', reason: '用户拒绝执行', }); @@ -482,8 +544,7 @@ describe('chat timeline builder', () => { input: { keyword: 'before approval' }, }); ChatTimelineBuilder.appendToolApproval(items, { - requestId: 'request-1', - resumeToken: 'resume-1', + approvalId: 'approval-1', toolCallId: 'call-2', toolName: '查询工具', input: { keyword: 'approval' }, @@ -498,7 +559,7 @@ describe('chat timeline builder', () => { expect(items[1].toolCallId).toBe('call-2'); expect(items[1].mode).toBe('approval'); expect(items[1].status).toBe('pending_approval'); - expect(items[1].approval?.requestId).toBe('request-1'); + expect(items[1].approval?.approvalId).toBe('approval-1'); expect(items[1].input).toEqual({ keyword: 'approval' }); } }); 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 3b9891a4..a5e88ceb 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 @@ -1,8 +1,12 @@ import type { + ChatArtifactAttachment, + ChatTimelineArtifactItem, ChatTimelineItem, + ChatTimelineItemBase, ChatTimelineKnowledgeHit, ChatTimelineMessageItem, ChatTimelineMessagePart, + ChatTimelineSkillInvocationStatus, ChatTimelineStatusItem, ChatTimelineStatusStatus, ChatTimelineStatusTone, @@ -13,6 +17,17 @@ import type { ChatTimelineToolStatus, } from './types'; +type ChatTimelineTurnMetadata = Partial< + Pick< + ChatTimelineItemBase, + | 'roundCompleted' + | 'roundId' + | 'turnFinishedAt' + | 'turnStartedAt' + | 'turnSucceeded' + > +>; + function createId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; } @@ -73,6 +88,24 @@ function ensureMessageTail( Object.assign(last, metadata); return last; } + const placeholder = + role === 'assistant' && metadata?.roundId + ? [...items] + .reverse() + .find( + (item): item is ChatTimelineMessageItem => + item.type === 'message' && + item.role === 'assistant' && + item.roundId === metadata.roundId && + item.status !== 'done' && + item.parts.length === 0, + ) + : undefined; + if (placeholder) { + placeholder.status = status; + Object.assign(placeholder, metadata); + return placeholder; + } const item: ChatTimelineMessageItem = { id: createId(role), role, @@ -138,19 +171,18 @@ function updateThinkingStatus( ); } -function finishLastAssistantMessage(items: ChatTimelineItem[]) { - finishAssistantMessage(items, true); -} - function finishAssistantMessage( items: ChatTimelineItem[], roundCompleted: boolean, + roundId?: string, ) { const lastMessage = [...items] .reverse() .find( (item): item is ChatTimelineMessageItem => - item.type === 'message' && item.role === 'assistant', + item.type === 'message' && + item.role === 'assistant' && + (!roundId || item.roundId === roundId), ); if (!lastMessage) { return; @@ -167,6 +199,7 @@ function findToolItem( toolCallId?: string, taskId?: string, sourceToolCallId?: string, + approvalId?: string, ) { const identities = new Set( [toolCallId, sourceToolCallId] @@ -174,13 +207,16 @@ function findToolItem( .filter(Boolean), ); const normalizedTaskId = normalizeText(taskId).trim(); - if (identities.size === 0 && !normalizedTaskId) { + const normalizedApprovalId = normalizeText(approvalId).trim(); + if (identities.size === 0 && !normalizedTaskId && !normalizedApprovalId) { return undefined; } return items.find( (item): item is ChatTimelineToolItem => item.type === 'tool' && - ((Boolean(normalizedTaskId) && item.taskId === normalizedTaskId) || + ((Boolean(normalizedApprovalId) && + item.approval?.approvalId === normalizedApprovalId) || + (Boolean(normalizedTaskId) && item.taskId === normalizedTaskId) || (item.toolCallId ? identities.has(item.toolCallId) : false)), ); } @@ -211,9 +247,33 @@ function doneStatusLabel(item: ChatTimelineStatusItem) { return item.label.replace(/^正在/, '已'); } -function finishRunningStatusItems(items: ChatTimelineItem[]) { +function skillTerminalLabel( + label: string, + status: Extract, +) { + const name = label.replace(/^正在调用\s*/, '').trim() || '技能'; + return status === 'cancelled' ? `已停止调用 ${name}` : `调用 ${name} 未完成`; +} + +function finishRunningStatusItems( + items: ChatTimelineItem[], + roundId?: string, + skillTerminalStatus: Extract< + ChatTimelineStatusStatus, + 'cancelled' | 'incomplete' + > = 'incomplete', +) { items.forEach((item) => { - if (item.type !== 'status' || item.status !== 'running') { + if ( + item.type !== 'status' || + item.status !== 'running' || + (roundId && item.roundId !== roundId) + ) { + return; + } + if (item.icon === 'skill') { + item.status = skillTerminalStatus; + item.label = skillTerminalLabel(item.label, skillTerminalStatus); return; } item.status = 'done'; @@ -221,9 +281,36 @@ function finishRunningStatusItems(items: ChatTimelineItem[]) { }); } +function skillStatusPresentation(status: ChatTimelineSkillInvocationStatus) { + switch (status) { + case 'CANCELLED': { + return { + prefix: '已停止调用', + status: 'cancelled' as const, + }; + } + case 'FAILED': { + return { prefix: '调用', status: 'error' as const, suffix: '失败' }; + } + case 'INCOMPLETE': { + return { + prefix: '调用', + status: 'incomplete' as const, + suffix: '未完成', + }; + } + case 'RUNNING': { + return { prefix: '正在调用', status: 'running' as const }; + } + case 'SUCCESS': { + return { prefix: '已调用', status: 'done' as const }; + } + } +} + function upsertStatus( items: ChatTimelineItem[], - payload: { + payload: ChatTimelineTurnMetadata & { label: string; presentation?: ChatTimelineStatusItem['presentation']; status: ChatTimelineStatusStatus; @@ -237,6 +324,7 @@ function upsertStatus( found.presentation = payload.presentation ?? found.presentation; found.status = payload.status; found.tone = payload.tone ?? found.tone; + applyTurnMetadata(found, payload); return found; } const item: ChatTimelineStatusItem = { @@ -249,20 +337,20 @@ function upsertStatus( tone: payload.tone ?? 'muted', type: 'status', }; + applyTurnMetadata(item, payload); items.push(item); return item; } function upsertTool( items: ChatTimelineItem[], - payload: { + payload: ChatTimelineTurnMetadata & { approval?: ChatTimelineToolApprovalPayload; + approvalId?: string; input?: unknown; mode?: ChatTimelineToolMode; output?: unknown; rejectReason?: string; - requestId?: string; - resumeToken?: string; sourceToolCallId?: string; status?: ChatTimelineToolStatus; taskId?: string; @@ -279,6 +367,7 @@ function upsertTool( toolCallId, taskId, payload.sourceToolCallId, + payload.approvalId ?? payload.approval?.approvalId, ); const approval = payload.approval ?? found?.approval; const mode = @@ -310,6 +399,7 @@ function upsertTool( found.taskId = taskId || found.taskId; found.toolCallId = toolCallId || found.toolCallId; found.toolName = toolName || found.toolName; + applyTurnMetadata(found, payload); return found; } @@ -330,11 +420,49 @@ function upsertTool( toolName: toolName || '工具调用', type: 'tool', }; + applyTurnMetadata(toolItem, payload); items.push(toolItem); return toolItem; } +function applyTurnMetadata( + item: ChatTimelineItemBase, + metadata?: ChatTimelineTurnMetadata, +) { + if (!metadata) { + return; + } + if (metadata.roundId) { + item.roundId = metadata.roundId; + } + if (metadata.roundCompleted !== undefined) { + item.roundCompleted = metadata.roundCompleted; + } + if (metadata.turnSucceeded !== undefined) { + item.turnSucceeded = metadata.turnSucceeded; + } + if (metadata.turnStartedAt !== undefined) { + item.turnStartedAt = Math.min( + item.turnStartedAt ?? metadata.turnStartedAt, + metadata.turnStartedAt, + ); + } + if (metadata.turnFinishedAt !== undefined) { + item.turnFinishedAt = Math.max( + item.turnFinishedAt ?? metadata.turnFinishedAt, + metadata.turnFinishedAt, + ); + } +} + export const ChatTimelineBuilder = { + ensureAssistantTurn( + items: ChatTimelineItem[], + metadata?: Partial, + ) { + ensureMessageTail(items, 'assistant', 'streaming', metadata); + }, + appendUserMessage( items: ChatTimelineItem[], content?: unknown, @@ -410,12 +538,45 @@ export const ChatTimelineBuilder = { appendTextPart(message, text); }, - replaceMessageContent(items: ChatTimelineItem[], content?: unknown) { + replaceMessageContent( + items: ChatTimelineItem[], + content?: unknown, + metadata?: Partial, + ) { const text = normalizeText(content); - if (!text) { - return; + const message = + (metadata?.id + ? items.find( + (item): item is ChatTimelineMessageItem => + item.type === 'message' && item.id === metadata.id, + ) + : undefined) || + [...items] + .reverse() + .find( + (item): item is ChatTimelineMessageItem => + item.type === 'message' && + item.role === 'assistant' && + (!metadata?.roundId || item.roundId === metadata.roundId), + ) || + ensureMessageTail(items, 'assistant', 'done', metadata); + for (let index = items.length - 1; index >= 0; index--) { + const item = items[index]; + if ( + item === message || + item?.type !== 'message' || + item.role !== 'assistant' || + (metadata?.roundId && item.roundId !== metadata.roundId) + ) { + continue; + } + item.parts = item.parts.filter((part) => part.type !== 'text'); + if (item.parts.length === 0) { + items.splice(index, 1); + } } - const message = ensureMessageTail(items, 'assistant', 'done'); + Object.assign(message, metadata); + message.status = 'done'; updateThinkingStatus(message, 'end'); replaceTextPart(message, text); }, @@ -423,8 +584,10 @@ export const ChatTimelineBuilder = { appendToolApproval( items: ChatTimelineItem[], payload: ChatTimelineToolApprovalPayload, + metadata?: ChatTimelineTurnMetadata, ) { upsertTool(items, { + ...metadata, approval: payload, input: payload.input, mode: 'approval', @@ -436,7 +599,8 @@ export const ChatTimelineBuilder = { upsertToolCall( items: ChatTimelineItem[], - payload: { + payload: ChatTimelineTurnMetadata & { + approvalId?: string; input?: unknown; output?: unknown; sourceToolCallId?: string; @@ -452,6 +616,7 @@ export const ChatTimelineBuilder = { items, payload.status === 'success' ? 'done' : 'running', payload.statusKey, + payload, ); return; } @@ -466,9 +631,11 @@ export const ChatTimelineBuilder = { items: ChatTimelineItem[], status: ChatTimelineStatusStatus, statusKey?: string, + metadata?: ChatTimelineTurnMetadata, ) { - finishAssistantMessage(items, false); + finishAssistantMessage(items, false, metadata?.roundId); upsertStatus(items, { + ...metadata, label: status === 'running' ? '正在检索知识库' : '已检索知识库', status, statusKey: knowledgeRetrievalStatusKey(statusKey), @@ -476,9 +643,32 @@ export const ChatTimelineBuilder = { }); }, + upsertSkillInvocationStatus( + items: ChatTimelineItem[], + payload: ChatTimelineTurnMetadata & { + displayName?: string; + status: ChatTimelineSkillInvocationStatus; + statusKey: string; + }, + ) { + const displayName = normalizeText(payload.displayName).trim() || '技能'; + const presentation = skillStatusPresentation(payload.status); + const label = [presentation.prefix, displayName, presentation.suffix] + .filter(Boolean) + .join(' '); + finishAssistantMessage(items, false, payload.roundId); + upsertStatus(items, { + ...payload, + label, + status: presentation.status, + statusKey: payload.statusKey, + tone: payload.status === 'FAILED' ? 'danger' : 'muted', + }).icon = 'skill'; + }, + upsertMemoryCompressionStatus( items: ChatTimelineItem[], - payload?: { + payload?: ChatTimelineTurnMetadata & { compressed?: boolean; label?: string; phase?: string; @@ -491,7 +681,7 @@ export const ChatTimelineBuilder = { ? 'done' : 'running'; const statusKey = payload?.statusKey || 'memory-compression'; - finishAssistantMessage(items, false); + finishAssistantMessage(items, false, payload?.roundId); if (status === 'done' && payload?.compressed === false) { removeStatusItem(items, statusKey); return; @@ -501,6 +691,7 @@ export const ChatTimelineBuilder = { ? payload?.label || '正在整理上下文' : payload?.label || '已整理上下文'; upsertStatus(items, { + ...payload, label, status, statusKey, @@ -511,9 +702,8 @@ export const ChatTimelineBuilder = { markToolApproving( items: ChatTimelineItem[], - payload: { - requestId?: string; - resumeToken?: string; + payload: ChatTimelineTurnMetadata & { + approvalId?: string; toolCallId?: string; }, ) { @@ -526,10 +716,9 @@ export const ChatTimelineBuilder = { markToolRejected( items: ChatTimelineItem[], - payload: { + payload: ChatTimelineTurnMetadata & { + approvalId?: string; reason?: string; - requestId?: string; - resumeToken?: string; toolCallId?: string; }, ) { @@ -544,6 +733,7 @@ export const ChatTimelineBuilder = { appendKnowledge( items: ChatTimelineItem[], knowledgeItems: ChatTimelineKnowledgeHit[], + metadata?: ChatTimelineTurnMetadata, ) { if (knowledgeItems.length === 0) { return; @@ -552,9 +742,12 @@ export const ChatTimelineBuilder = { .reverse() .find( (item): item is ChatTimelineMessageItem => - item.type === 'message' && item.role === 'assistant', + item.type === 'message' && + item.role === 'assistant' && + (!metadata?.roundId || item.roundId === metadata.roundId), ); if (lastAssistantMessage) { + applyTurnMetadata(lastAssistantMessage, metadata); lastAssistantMessage.knowledgeItems = [ ...(lastAssistantMessage.knowledgeItems || []), ...knowledgeItems, @@ -562,36 +755,119 @@ export const ChatTimelineBuilder = { return; } const last = items[items.length - 1]; - if (last?.type === 'knowledge') { + if ( + last?.type === 'knowledge' && + (!metadata?.roundId || last.roundId === metadata.roundId) + ) { + applyTurnMetadata(last, metadata); last.items.push(...knowledgeItems); return; } - items.push({ + const item = { id: createId('knowledge'), createdAt: Date.now(), items: knowledgeItems, - type: 'knowledge', - }); + type: 'knowledge' as const, + }; + applyTurnMetadata(item, metadata); + items.push(item); }, - appendError(items: ChatTimelineItem[], message?: unknown) { + upsertArtifact( + items: ChatTimelineItem[], + artifact: ChatArtifactAttachment, + metadata?: ChatTimelineTurnMetadata, + ) { + const artifactId = normalizeText(artifact.artifactId).trim(); + const fileName = normalizeText(artifact.fileName).trim(); + if (!artifactId || !fileName) { + return; + } + const existing = items.find( + (item): item is ChatTimelineArtifactItem => + item.type === 'artifact' && item.artifactId === artifactId, + ); + if (existing) { + existing.downloadUrl = artifact.downloadUrl; + existing.fileName = fileName; + existing.mimeType = artifact.mimeType; + existing.sha256 = artifact.sha256; + existing.size = artifact.size; + existing.status = artifact.status; + applyTurnMetadata(existing, metadata); + return; + } + const item = { + artifactId, + createdAt: Date.now(), + downloadUrl: artifact.downloadUrl, + fileName, + id: `artifact:${artifactId}`, + mimeType: artifact.mimeType, + sha256: artifact.sha256, + size: artifact.size, + status: artifact.status, + type: 'artifact' as const, + }; + applyTurnMetadata(item, metadata); + items.push(item); + }, + + appendError( + items: ChatTimelineItem[], + message?: unknown, + metadata?: ChatTimelineTurnMetadata, + ) { const text = normalizeText(message) || '请求失败'; - const last = items[items.length - 1]; - if (last?.type === 'message' && last.role === 'assistant') { + const last = [...items] + .reverse() + .find( + (item): item is ChatTimelineMessageItem => + item.type === 'message' && + item.role === 'assistant' && + (!metadata?.roundId || item.roundId === metadata.roundId), + ); + if (last) { updateThinkingStatus(last, 'error'); last.status = 'error'; } - items.push({ + const item = { id: createId('error'), createdAt: Date.now(), message: text, - type: 'error', - }); + type: 'error' as const, + }; + applyTurnMetadata(item, metadata); + items.push(item); }, - finalize(items: ChatTimelineItem[]) { - finishRunningStatusItems(items); - finishLastAssistantMessage(items); + finalize( + items: ChatTimelineItem[], + metadata?: ChatTimelineTurnMetadata, + options?: { + runningSkillStatus?: Extract< + ChatTimelineStatusStatus, + 'cancelled' | 'incomplete' + >; + }, + ) { + finishRunningStatusItems( + items, + metadata?.roundId, + options?.runningSkillStatus, + ); + finishAssistantMessage( + items, + metadata?.turnSucceeded ?? true, + metadata?.roundId, + ); + if (metadata?.roundId) { + for (const item of items) { + if (item.roundId === metadata.roundId) { + applyTurnMetadata(item, metadata); + } + } + } }, replaceRoundAssistant( diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/index.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/index.ts index d3ea7cc9..835324a9 100644 --- a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/index.ts +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/index.ts @@ -1,5 +1,6 @@ export { defaultAssistantAvatar } from './assistantAvatar'; export { ChatTimelineBuilder } from './builder'; +export { default as ChatArtifactCard } from './ChatArtifactAttachment.vue'; export { default as ChatAssistantAvatar } from './ChatAssistantAvatar.vue'; export { default as ChatDocumentAttachments } from './ChatDocumentAttachments.vue'; export { default as ChatErrorNotice } from './ChatErrorNotice.vue'; @@ -14,10 +15,14 @@ export { default as ChatToolApprovalCard } from './ChatToolApprovalCard.vue'; export { default as ChatToolCard } from './ChatToolCard.vue'; export { default as ChatVariantNavigator } from './ChatVariantNavigator.vue'; export type { + ChatArtifactAttachment, + ChatArtifactLoader, + ChatArtifactStatus, ChatDocumentAttachment, ChatDocumentLoader, ChatImageAttachment, ChatImageLoader, + ChatTimelineArtifactItem, ChatTimelineCustomItem, ChatTimelineErrorItem, ChatTimelineItem, @@ -27,6 +32,7 @@ export type { ChatTimelineMessageItem, ChatTimelineMessagePart, ChatTimelineRole, + ChatTimelineSkillInvocationStatus, ChatTimelineStatusItem, ChatTimelineStatusStatus, ChatTimelineStatusTone, diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/types.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/types.ts index 7a7fb0dd..d65a3b00 100644 --- a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/types.ts +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/types.ts @@ -9,8 +9,38 @@ export type ChatTimelineToolStatus = | 'rejected' | 'running' | 'success'; -export type ChatTimelineStatusStatus = 'done' | 'running'; -export type ChatTimelineStatusTone = 'muted'; +export type ChatTimelineStatusStatus = + | 'cancelled' + | 'done' + | 'error' + | 'incomplete' + | 'running'; +export type ChatTimelineStatusTone = 'danger' | 'muted'; +export type ChatTimelineSkillInvocationStatus = + | 'CANCELLED' + | 'FAILED' + | 'INCOMPLETE' + | 'RUNNING' + | 'SUCCESS'; +export type ChatArtifactStatus = + | 'available' + | 'delete_failed' + | 'expired' + | 'unavailable'; + +export interface ChatArtifactAttachment { + artifactId: string; + downloadUrl?: string; + fileName: string; + mimeType?: string; + sha256?: string; + size?: number; + status: ChatArtifactStatus; +} + +export type ChatArtifactLoader = ( + artifact: ChatArtifactAttachment, +) => Promise; export interface ChatImageAttachment { error?: string; @@ -47,8 +77,7 @@ export type ChatDocumentLoader = ( ) => Promise; export interface ChatTimelineToolApprovalPayload { - requestId: string; - resumeToken: string; + approvalId: string; toolName: string; toolDisplayName?: string; toolCallId?: string; @@ -81,6 +110,11 @@ export interface ChatTimelineKnowledgeHit { export interface ChatTimelineItemBase { createdAt?: number; id: string; + roundCompleted?: boolean; + roundId?: string; + turnFinishedAt?: number; + turnStartedAt?: number; + turnSucceeded?: boolean; } export interface ChatTimelineMessageItem extends ChatTimelineItemBase { @@ -90,8 +124,6 @@ export interface ChatTimelineMessageItem extends ChatTimelineItemBase { parts: ChatTimelineMessagePart[]; regenerable?: boolean; role: ChatTimelineRole; - roundId?: string; - roundCompleted?: boolean; roundNo?: number; status?: ChatTimelineItemStatus; selectedVariantIndex?: number; @@ -127,8 +159,14 @@ export interface ChatTimelineKnowledgeItem extends ChatTimelineItemBase { type: 'knowledge'; } +export interface ChatTimelineArtifactItem + extends ChatArtifactAttachment, + ChatTimelineItemBase { + type: 'artifact'; +} + export interface ChatTimelineStatusItem extends ChatTimelineItemBase { - icon?: 'book' | 'none'; + icon?: 'book' | 'none' | 'skill'; label: string; presentation?: 'inline' | 'separator'; status: ChatTimelineStatusStatus; @@ -149,6 +187,7 @@ export interface ChatTimelineCustomItem extends ChatTimelineItemBase { } export type ChatTimelineItem = + | ChatTimelineArtifactItem | ChatTimelineCustomItem | ChatTimelineErrorItem | ChatTimelineKnowledgeItem diff --git a/easyflow-ui-admin/pnpm-lock.yaml b/easyflow-ui-admin/pnpm-lock.yaml index 1d610abd..4e117864 100644 --- a/easyflow-ui-admin/pnpm-lock.yaml +++ b/easyflow-ui-admin/pnpm-lock.yaml @@ -589,6 +589,9 @@ importers: app: dependencies: + '@ag-ui/client': + specifier: 0.0.57 + version: 0.0.57 '@codemirror/commands': specifier: ^6.10.2 version: 6.10.2 @@ -1862,6 +1865,18 @@ importers: packages: + '@ag-ui/client@0.0.57': + resolution: {integrity: sha512-Xap2alG9Z0/j5kb3x4D7oTpe2sw1dfrC9rgJJr2NZu5vKcm8dzIPNd31mF2B4zS3BKqYIu245yxKPhEtT30MHw==} + + '@ag-ui/core@0.0.57': + resolution: {integrity: sha512-gho1OWjNE6E3Rl7ZEZ1wr2CEpUHjLFU0FqzCZZk439TicLu+BfLCMkMokB07bMGlRmbJ60hM6LW60iOVauCx+Q==} + + '@ag-ui/encoder@0.0.57': + resolution: {integrity: sha512-ifD9NctR4xyPDR58xF9GK1bj/S8oECFkTeDfuYD8tXdbcOstIJ2TOqU2zhiCKnw7Vw+zR9Qv3TbsM9E7Gi9X3Q==} + + '@ag-ui/proto@0.0.57': + resolution: {integrity: sha512-pPENOZt0P6ibH8sCTgq05wLYXi5t3P9B5r/1bWYehXjUxtyOdnukSlWM++SsCIwUXsQdm/b3aBgGjEeTF7RenA==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -2482,6 +2497,9 @@ packages: '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bufbuild/protobuf@2.14.0': + resolution: {integrity: sha512-C3UGsiCwSprE2NKIIFA3hCDlpXTMCAXRZuEVp88L1GY36Y41+rYL5fryE+nOFhp4p4JPQvdV8PQ4DWgHgeTE+w==} + '@cacheable/memoize@2.0.3': resolution: {integrity: sha512-hl9wfQgpiydhQEIv7fkjEzTGE+tcosCXLKFDO707wYJ/78FVOlowb36djex5GdbSyeHnG62pomYLMuV/OT8Pbw==} @@ -3997,6 +4015,10 @@ packages: '@poppinss/exception@1.2.2': resolution: {integrity: sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==} + '@protobuf-ts/protoc@2.11.1': + resolution: {integrity: sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg==} + hasBin: true + '@publint/pack@0.1.2': resolution: {integrity: sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw==} engines: {node: '>=18'} @@ -4616,6 +4638,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/web-bluetooth@0.0.16': resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==} @@ -6914,6 +6939,9 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-json-patch@3.1.1: + resolution: {integrity: sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -9920,6 +9948,9 @@ packages: rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -10919,6 +10950,9 @@ packages: uploadthing: optional: true + untruncate-json@0.0.1: + resolution: {integrity: sha512-4W9enDK4X1y1s2S/Rz7ysw6kDuMS3VmRjMFg7GZrNO+98OSe+x5Lh7PKYoVjy3lW/1wmhs6HW0lusnQRHgMarA==} + untun@0.1.3: resolution: {integrity: sha512-4luGP9LMYszMRZwsvyUd9MrxgEGZdZuZgpVQHEEX0lCYFESasVRvZd0EYpCkOIbJKHMuv0LskpXc/8Un+MJzEQ==} hasBin: true @@ -10953,6 +10987,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + uuid@14.0.0: resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} hasBin: true @@ -11514,6 +11552,34 @@ packages: snapshots: + '@ag-ui/client@0.0.57': + dependencies: + '@ag-ui/core': 0.0.57 + '@ag-ui/encoder': 0.0.57 + '@ag-ui/proto': 0.0.57 + '@types/uuid': 10.0.0 + compare-versions: 6.1.1 + fast-json-patch: 3.1.1 + rxjs: 7.8.1 + untruncate-json: 0.0.1 + uuid: 11.1.1 + zod: 3.25.76 + + '@ag-ui/core@0.0.57': + dependencies: + zod: 3.25.76 + + '@ag-ui/encoder@0.0.57': + dependencies: + '@ag-ui/core': 0.0.57 + '@ag-ui/proto': 0.0.57 + + '@ag-ui/proto@0.0.57': + dependencies: + '@ag-ui/core': 0.0.57 + '@bufbuild/protobuf': 2.14.0 + '@protobuf-ts/protoc': 2.11.1 + '@alloc/quick-lru@5.2.0': {} '@antfu/install-pkg@1.1.0': @@ -12278,6 +12344,8 @@ snapshots: '@braintree/sanitize-url@7.1.2': {} + '@bufbuild/protobuf@2.14.0': {} + '@cacheable/memoize@2.0.3': dependencies: '@cacheable/utils': 2.2.0 @@ -14428,6 +14496,8 @@ snapshots: '@poppinss/exception@1.2.2': {} + '@protobuf-ts/protoc@2.11.1': {} + '@publint/pack@0.1.2': {} '@rolldown/pluginutils@1.0.0-beta.29': {} @@ -15087,6 +15157,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/uuid@10.0.0': {} + '@types/web-bluetooth@0.0.16': {} '@types/web-bluetooth@0.0.21': {} @@ -17802,6 +17874,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-json-patch@3.1.1: {} + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} @@ -21174,6 +21248,10 @@ snapshots: rw@1.3.3: {} + rxjs@7.8.1: + dependencies: + tslib: 2.8.1 + sade@1.8.1: dependencies: mri: 1.2.0 @@ -22304,6 +22382,8 @@ snapshots: db0: 0.3.4 ioredis: 5.8.2 + untruncate-json@0.0.1: {} + untun@0.1.3: dependencies: citty: 0.1.6 @@ -22356,6 +22436,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.1: {} + uuid@14.0.0: {} vee-validate@4.15.1(vue@3.5.24(typescript@5.9.3)): diff --git a/pom.xml b/pom.xml index 0c7f54df..ddb7b0f9 100644 --- a/pom.xml +++ b/pom.xml @@ -214,6 +214,11 @@ easy-agents-agent-runtime ${easy-agents.version}
+ + com.easyagents + easy-agents-agui + ${easy-agents.version} + com.easyagents easy-agents-skill