From e37e13908f0e67a9c2101a11016998cff91e4b67 Mon Sep 17 00:00:00 2001 From: WMC001 <46217886+WMC001@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:25:42 +0800 Subject: [PATCH] Revert "Release/v2.2.1 (#3269)" This reverts commit 9ff420ecce6b2ca21a67ce51053205860a76e41a. --- .github/workflows/auto-unit-test.yml | 2 +- .github/workflows/sdk_publish.yml | 2 +- .gitignore | 7 +- backend/adapters/__init__.py | 13 - backend/adapters/exception.py | 13 - backend/adapters/jiuwen_sdk_adapter.py | 514 ------- backend/agents/create_agent_info.py | 238 +-- backend/apps/agent_app.py | 4 +- backend/apps/agent_repository_app.py | 134 -- backend/apps/app_factory.py | 10 - backend/apps/cas_app.py | 156 -- backend/apps/config_app.py | 4 - backend/apps/northbound_app.py | 158 +- backend/apps/northbound_knowledge_app.py | 101 +- backend/apps/prompt_app.py | 156 +- backend/apps/tool_config_app.py | 3 - backend/apps/user_management_app.py | 25 +- backend/apps/vectordatabase_app.py | 93 +- backend/consts/const.py | 58 +- backend/consts/model.py | 75 +- backend/data_process/tasks.py | 645 +++----- backend/database/agent_db.py | 41 +- backend/database/agent_repository_db.py | 358 ----- backend/database/cas_session_db.py | 134 -- backend/database/conversation_db.py | 68 - backend/database/db_models.py | 387 ++--- backend/database/knowledge_db.py | 27 +- backend/database/user_tenant_db.py | 31 - backend/mcp_service.py | 15 +- .../managed_system_prompt_template_en.yaml | 17 +- .../managed_system_prompt_template_zh.yaml | 17 +- .../manager_system_prompt_template_en.yaml | 17 +- .../manager_system_prompt_template_zh.yaml | 17 +- .../prompts/utils/greeting_generate_en.yaml | 54 - .../prompts/utils/greeting_generate_zh.yaml | 53 - backend/pyproject.toml | 12 +- backend/services/agent_repository_service.py | 306 ---- backend/services/agent_service.py | 538 ++----- backend/services/agent_version_service.py | 41 +- backend/services/cas_service.py | 424 ------ .../conversation_management_service.py | 62 +- backend/services/data_process_service.py | 109 +- backend/services/file_management_service.py | 40 +- backend/services/northbound_service.py | 283 +--- backend/services/prompt_service.py | 456 +----- backend/services/remote_mcp_service.py | 2 +- .../services/tool_configuration_service.py | 7 +- backend/services/user_management_service.py | 2 - backend/services/vectordatabase_service.py | 136 +- backend/utils/auth_utils.py | 62 +- backend/utils/context_utils.py | 32 +- backend/utils/http_client_utils.py | 2 - backend/utils/memory_utils.py | 13 +- backend/utils/prompt_template_utils.py | 4 - doc/docs/en/quick-start/installation.md | 108 -- .../en/quick-start/kubernetes-installation.md | 116 -- doc/docs/en/user-guide/agent-development.md | 12 - .../agent-development/dataagent_deploy.png | Bin 60851 -> 0 bytes doc/docs/zh/quick-start/installation.md | 105 -- .../zh/quick-start/kubernetes-installation.md | 116 -- doc/docs/zh/sdk/vector-database.md | 13 +- doc/docs/zh/user-guide/agent-development.md | 11 - .../agent-development/dataagent_deploy.png | Bin 60851 -> 0 bytes doc/procedural-memory-verification.md | 315 ---- docker/.env.example | 24 - docker/deploy.sh | 2 +- docker/init.sql | 39 +- docker/official-skills-zip/create-docx.zip | Bin 39296 -> 0 bytes docker/sql/v2.2.0_0526_add_cas_session_t.sql | 27 - ...2.1_0601_add_agent_verification_config.sql | 7 - ...erve_source_file_to_knowledge_record_t.sql | 8 - ...d_greeting_fields_to_ag_tenant_agent_t.sql | 15 - .../v2.2.1_0605_add_ag_agent_repository_t.sql | 96 -- ...d_agent_version_no_to_agent_relation_t.sql | 15 - .../agents/components/AgentConfigComp.tsx | 18 +- .../components/agentConfig/McpConfigModal.tsx | 26 +- .../agentConfig/SkillBuildModal.tsx | 2 +- .../agentConfig/SkillDetailModal.tsx | 2 +- .../agentConfig/SkillManagement.tsx | 30 +- .../components/agentConfig/ToolManagement.tsx | 60 +- .../agentConfig/skill/SkillConfigModal.tsx | 2 +- .../agentInfo/AgentGenerateDetail.tsx | 145 +- .../components/agentInfo/DebugConfig.tsx | 129 +- .../components/agentInfo/DebugMessageList.tsx | 97 +- .../agentInfo/DebugOptimizeModal.tsx | 230 --- .../agentInfo/DebugPromptCompareModal.tsx | 76 - .../agentInfo/PromptOptimizeModal.tsx | 284 +--- .../agentInfo/PromptTemplateManagerModal.tsx | 19 +- frontend/app/[locale]/agents/page.tsx | 24 - .../chat/components/chatAgentSelector.tsx | 3 +- .../chat/components/chatAttachment.tsx | 2 +- .../[locale]/chat/components/chatInput.tsx | 61 +- .../[locale]/chat/internal/chatInterface.tsx | 12 +- .../chat/streaming/chatStreamFinalMessage.tsx | 19 +- .../chat/streaming/chatStreamHandler.tsx | 68 - .../chat/streaming/chatStreamMain.tsx | 6 - .../[locale]/chat/streaming/taskWindow.tsx | 114 +- .../knowledges/KnowledgeBaseConfiguration.tsx | 85 +- .../components/document/DocumentList.tsx | 306 ++-- .../knowledge/KnowledgeBaseList.tsx | 10 +- .../knowledges/contexts/DocumentContext.tsx | 304 ++-- .../contexts/KnowledgeBaseContext.tsx | 7 +- .../[locale]/space/components/AgentCard.tsx | 9 +- .../components/AssetOwnerResourcesComp.tsx | 16 +- .../components/UserManageComp.tsx | 324 ++-- .../components/resources/AgentList.tsx | 27 +- .../components/resources/GroupList.tsx | 4 +- .../components/resources/InvitationList.tsx | 8 +- .../components/resources/KnowledgeList.tsx | 11 +- .../components/resources/McpList.tsx | 104 +- .../components/resources/ModelList.tsx | 9 +- .../components/resources/SkillList.tsx | 10 +- .../components/resources/UserList.tsx | 12 +- .../users/components/UserProfileComp.tsx | 34 +- .../components/agent/AgentImportWizard.tsx | 1 + frontend/components/auth/avatarDropdown.tsx | 23 +- frontend/components/auth/loginModal.tsx | 38 +- .../components/navigation/SideNavigation.tsx | 76 +- .../skill/InstallOfficialSkillsModal.tsx | 3 +- .../components/{common => ui}/Diagram.tsx | 0 .../components/{common => ui}/PdfViewer.tsx | 0 .../components/{common => ui}/copyButton.tsx | 0 .../{common => ui}/filePreviewDrawer.tsx | 1032 +++++-------- .../{common => ui}/markdownRenderer.tsx | 4 +- .../{common => ui}/tokenUsageIndicator.tsx | 2 +- frontend/const/agentConfig.ts | 2 - frontend/const/chatConfig.ts | 3 - frontend/hooks/agent/useAgentGeneration.ts | 13 +- frontend/hooks/agent/useSaveGuard.ts | 3 - frontend/hooks/auth/useAuthenticationState.ts | 147 +- frontend/hooks/auth/useAuthenticationUI.ts | 93 +- frontend/lib/agentGenerationCache.ts | 8 +- frontend/lib/auth.ts | 7 +- frontend/lib/authFlow.ts | 13 - frontend/lib/chat/chatMessageExtractor.ts | 292 ++++ frontend/lib/chatMessageExtractor.ts | 86 +- frontend/lib/filePreviewUtils.ts | 40 - frontend/pnpm-workspace.yaml | 6 +- frontend/public/locales/en/common.json | 50 - frontend/public/locales/zh/common.json | 44 +- frontend/server.js | 350 ++--- frontend/services/agentConfigService.ts | 10 +- frontend/services/agentVersionService.ts | 3 +- frontend/services/api.ts | 5 - frontend/services/authService.ts | 38 +- frontend/services/casService.ts | 69 - frontend/services/knowledgeBaseService.ts | 49 +- frontend/services/promptService.ts | 4 - frontend/services/sessionService.ts | 4 - frontend/stores/agentConfigStore.ts | 36 +- frontend/types/agentConfig.ts | 75 +- frontend/types/auth.ts | 1 - frontend/types/chat.ts | 10 +- frontend/types/knowledgeBase.ts | 2 - k8s/helm/deploy.sh | 2 +- .../charts/nexent-common/files/init.sql | 241 --- .../nexent-common/templates/configmap.yaml | 20 - .../nexent/charts/nexent-common/values.yaml | 23 - .../charts/nexent-data-process/values.yaml | 2 +- k8s/helm/nexent/values.yaml | 27 - make/data_process/Dockerfile | 5 +- make/main/Dockerfile | 2 +- make/mcp/Dockerfile | 4 +- scripts/deployment/common.sh | 5 + scripts/offline/build_offline_package.sh | 116 +- sdk/nexent/__init__.py | 3 +- sdk/nexent/container/docker_client.py | 38 +- sdk/nexent/container/k8s_client.py | 50 +- sdk/nexent/core/agents/agent_model.py | 65 - sdk/nexent/core/agents/core_agent.py | 229 +-- sdk/nexent/core/agents/nexent_agent.py | 40 +- sdk/nexent/core/agents/verification.py | 732 --------- sdk/nexent/core/tools/__init__.py | 6 +- .../core/tools/knowledge_base_search_tool.py | 71 - sdk/nexent/core/tools/search_memory_tool.py | 109 -- sdk/nexent/core/tools/store_memory_tool.py | 110 -- sdk/nexent/core/utils/observer.py | 4 +- sdk/nexent/core/utils/tools_common_message.py | 2 - sdk/nexent/memory/memory_core.py | 4 +- sdk/nexent/skills/skill_manager.py | 7 +- sdk/pyproject.toml | 8 +- sonar-project.properties | 5 - test/backend/agents/test_create_agent_info.py | 557 ++----- test/backend/app/test_agent_app.py | 2 +- test/backend/app/test_agent_repository_app.py | 161 -- test/backend/app/test_cas_app.py | 184 --- test/backend/app/test_idata_app.py | 80 +- .../backend/app/test_knowledge_summary_app.py | 23 +- test/backend/app/test_northbound_app.py | 1225 +++++++-------- test/backend/app/test_northbound_base_app.py | 16 +- .../app/test_northbound_knowledge_app.py | 14 +- test/backend/app/test_prompt_app.py | 213 +-- test/backend/app/test_prompt_template_app.py | 13 - test/backend/app/test_tool_config_app.py | 54 - test/backend/app/test_user_management_app.py | 99 -- test/backend/app/test_vectordatabase_app.py | 154 +- test/backend/data_process/test_tasks.py | 450 ++---- test/backend/data_process/test_worker.py | 42 +- test/backend/database/test_agent_db.py | 77 +- .../backend/database/test_agent_version_db.py | 21 - .../services/test_agent_repository_service.py | 398 ----- test/backend/services/test_agent_service.py | 407 +---- .../services/test_agent_version_service.py | 67 +- test/backend/services/test_cas_service.py | 240 --- .../services/test_data_process_service.py | 264 +++- test/backend/services/test_mcp_service.py | 161 +- .../services/test_northbound_service.py | 1310 ++--------------- test/backend/services/test_prompt_service.py | 210 +-- .../services/test_prompt_template_service.py | 33 +- .../test_tool_configuration_service.py | 31 - .../services/test_vectordatabase_service.py | 245 +-- test/backend/utils/test_auth_utils.py | 16 - test/backend/utils/test_context_utils.py | 3 + test/backend/utils/test_memory_utils.py | 573 +++---- test/conftest.py | 5 - test/sdk/container/test_docker_client.py | 16 +- test/sdk/container/test_k8s_client.py | 168 +-- test/sdk/core/agents/test_agent_model.py | 28 - test/sdk/core/agents/test_core_agent.py | 124 +- test/sdk/core/agents/test_nexent_agent.py | 82 -- .../tools/test_knowledge_base_search_tool.py | 368 +---- .../sdk/core/tools/test_search_memory_tool.py | 209 --- test/sdk/core/tools/test_store_memory_tool.py | 285 ---- test/sdk/data_process/test_core.py | 16 - test/sdk/data_process/test_file_splitter.py | 15 - .../data_process/test_openpyxl_processor.py | 16 - .../test_unstructured_processor.py | 14 - test/sdk/skills/test_skill_manager.py | 3 +- 228 files changed, 4085 insertions(+), 19095 deletions(-) delete mode 100644 backend/adapters/__init__.py delete mode 100644 backend/adapters/exception.py delete mode 100644 backend/adapters/jiuwen_sdk_adapter.py delete mode 100644 backend/apps/agent_repository_app.py delete mode 100644 backend/apps/cas_app.py delete mode 100644 backend/database/agent_repository_db.py delete mode 100644 backend/database/cas_session_db.py delete mode 100644 backend/prompts/utils/greeting_generate_en.yaml delete mode 100644 backend/prompts/utils/greeting_generate_zh.yaml delete mode 100644 backend/services/agent_repository_service.py delete mode 100644 backend/services/cas_service.py delete mode 100644 doc/docs/en/user-guide/assets/agent-development/dataagent_deploy.png delete mode 100644 doc/docs/zh/user-guide/assets/agent-development/dataagent_deploy.png delete mode 100644 doc/procedural-memory-verification.md delete mode 100644 docker/official-skills-zip/create-docx.zip delete mode 100644 docker/sql/v2.2.0_0526_add_cas_session_t.sql delete mode 100644 docker/sql/v2.2.1_0601_add_agent_verification_config.sql delete mode 100644 docker/sql/v2.2.1_0601_add_preserve_source_file_to_knowledge_record_t.sql delete mode 100644 docker/sql/v2.2.1_0603_add_greeting_fields_to_ag_tenant_agent_t.sql delete mode 100644 docker/sql/v2.2.1_0605_add_ag_agent_repository_t.sql delete mode 100644 docker/sql/v2.2.1_0609_add_selected_agent_version_no_to_agent_relation_t.sql delete mode 100644 frontend/app/[locale]/agents/components/agentInfo/DebugOptimizeModal.tsx delete mode 100644 frontend/app/[locale]/agents/components/agentInfo/DebugPromptCompareModal.tsx rename frontend/components/{common => ui}/Diagram.tsx (100%) rename frontend/components/{common => ui}/PdfViewer.tsx (100%) rename frontend/components/{common => ui}/copyButton.tsx (100%) rename frontend/components/{common => ui}/filePreviewDrawer.tsx (50%) rename frontend/components/{common => ui}/markdownRenderer.tsx (99%) rename frontend/components/{common => ui}/tokenUsageIndicator.tsx (98%) delete mode 100644 frontend/lib/authFlow.ts create mode 100644 frontend/lib/chat/chatMessageExtractor.ts delete mode 100644 frontend/services/casService.ts delete mode 100644 sdk/nexent/core/agents/verification.py delete mode 100644 sdk/nexent/core/tools/search_memory_tool.py delete mode 100644 sdk/nexent/core/tools/store_memory_tool.py delete mode 100644 sonar-project.properties delete mode 100644 test/backend/app/test_agent_repository_app.py delete mode 100644 test/backend/app/test_cas_app.py delete mode 100644 test/backend/services/test_agent_repository_service.py delete mode 100644 test/backend/services/test_cas_service.py delete mode 100644 test/sdk/core/tools/test_search_memory_tool.py delete mode 100644 test/sdk/core/tools/test_store_memory_tool.py diff --git a/.github/workflows/auto-unit-test.yml b/.github/workflows/auto-unit-test.yml index dace8dab6..1595fc769 100644 --- a/.github/workflows/auto-unit-test.yml +++ b/.github/workflows/auto-unit-test.yml @@ -36,7 +36,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.11' + python-version: '3.10' - name: Install uv run: pip install --upgrade uv diff --git a/.github/workflows/sdk_publish.yml b/.github/workflows/sdk_publish.yml index 3cc413381..1e5759277 100644 --- a/.github/workflows/sdk_publish.yml +++ b/.github/workflows/sdk_publish.yml @@ -21,7 +21,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.10' - name: Install build dependencies run: | diff --git a/.gitignore b/.gitignore index e0bac2b47..ec5b3a3f9 100644 --- a/.gitignore +++ b/.gitignore @@ -61,9 +61,4 @@ data/ sdk/benchmark/.env /docker/.env.bak -.venv - -.pytest-tmp -doc/mermaid - -.claude/skills/python-import-triage \ No newline at end of file +.venv \ No newline at end of file diff --git a/backend/adapters/__init__.py b/backend/adapters/__init__.py deleted file mode 100644 index ed46fc888..000000000 --- a/backend/adapters/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from adapters.exception import JiuwenSDKError, JiuwenSDKUnavailableError, NexentCapabilityError - -try: - from adapters.jiuwen_sdk_adapter import JiuwenSDKAdapter -except ModuleNotFoundError: - JiuwenSDKAdapter = None # type: ignore[assignment, misc] - -__all__ = [ - "JiuwenSDKError", - "JiuwenSDKUnavailableError", - "NexentCapabilityError", - "JiuwenSDKAdapter", -] diff --git a/backend/adapters/exception.py b/backend/adapters/exception.py deleted file mode 100644 index 63812d3af..000000000 --- a/backend/adapters/exception.py +++ /dev/null @@ -1,13 +0,0 @@ -class JiuwenSDKError(Exception): - """Jiuwen SDK 调用失败的通用异常""" - pass - - -class JiuwenSDKUnavailableError(JiuwenSDKError): - """Jiuwen SDK 不可用(依赖缺失或未启用)""" - pass - - -class NexentCapabilityError(Exception): - """nexent 原生模式不支持该能力""" - pass diff --git a/backend/adapters/jiuwen_sdk_adapter.py b/backend/adapters/jiuwen_sdk_adapter.py deleted file mode 100644 index f62ce9d06..000000000 --- a/backend/adapters/jiuwen_sdk_adapter.py +++ /dev/null @@ -1,514 +0,0 @@ -""" -openjiuwen SDK adapter for Nexent. - -This module must be imported lazily (not at module load time) because -openjiuwen 0.1.13 has circular import bugs in its __init__.py files that -prevent the SDK from loading unless we bypass them. - -Import flow: - backend/adapters/__init__.py -> try/except -> JiuwenSDKAdapter = None - -> when needed: _install_jiuwen_bypasser() -> openjiuwen imports work -""" -import asyncio -import importlib.abc -import importlib.machinery -import json -import logging -import os -import sys -import types -from typing import Any, List, Literal, Optional - -logger = logging.getLogger("jiuwen_adapter") - -from adapters.exception import JiuwenSDKError - - -# ---------------------------------------------------------------------- -# Circular import bypasser for openjiuwen 0.1.13 -# -# openjiuwen has broken __init__.py files that create circular import chains: -# tune/__init__.py -> tune.optimizer -> core.operator -> agent_evolving -> ... -# This bypasser prevents those __init__.py files from executing while still -# allowing regular .py submodule files to load normally. -# ---------------------------------------------------------------------- -_CIRCULAR_CHAIN = { - "openjiuwen.agent_evolving", - "openjiuwen.agent_evolving.trainer", - "openjiuwen.agent_evolving.trainer.trainer", - "openjiuwen.agent_evolving.trainer.progress", - "openjiuwen.core", - "openjiuwen.dev_tools", - "openjiuwen.dev_tools.tune", - "openjiuwen.dev_tools.tune.optimizer", - "openjiuwen.dev_tools.tune.optimizer.instruction_optimizer", - "openjiuwen.dev_tools.prompt_builder", - "openjiuwen.dev_tools.prompt_builder.builder", -} - - -class _JiuwenInitBypasser(importlib.abc.MetaPathFinder, importlib.abc.Loader): - """ - Meta path finder that intercepts __init__.py loading within openjiuwen, - blocking only the packages in the circular import chain while letting - all other modules (including base.py files) load normally. - """ - - def find_spec(self, fullname: str, path: Any, target: Any = None) -> Any: - if not fullname.startswith("openjiuwen") or fullname == "openjiuwen": - return None - - try: - import openjiuwen as _oj - - pkg_root = _oj.__path__[0] - except ImportError: - return None - - parts = fullname.split(".")[1:] - file_path = pkg_root - for p in parts: - file_path = os.path.join(file_path, p) - - is_package = os.path.isdir(file_path) - if not is_package: - return None - - init_path = os.path.join(file_path, "__init__.py") - if not os.path.exists(init_path): - return None - - if fullname not in _CIRCULAR_CHAIN: - return None - - spec = importlib.machinery.ModuleSpec( - fullname, self, is_package=True, origin="" - ) - spec.submodule_search_locations = [file_path] - return spec - - def create_module(self, module: Any) -> None: - return None - - def exec_module(self, module: Any) -> None: - import openjiuwen as _oj - - pkg_root = _oj.__path__[0] - parts = module.__name__.split(".")[1:] - file_path = pkg_root - for p in parts: - file_path = os.path.join(file_path, p) - module.__path__ = [file_path] - module.__file__ = os.path.join(file_path, "__init__.py") - - def __getattr__(self, name: str) -> Any: - """Handle special attributes like find_distributions to prevent recursion.""" - import openjiuwen as _oj - import importlib - - # Prevent recursion when Python scans sys.meta_path for find_distributions etc. - if name in ( - "find_distributions", - "find_module", - "__path__", - "__name__", - "__file__", - "__loader__", - "__package__", - "__spec__", - ): - raise AttributeError(name) - - pkg_root = _oj.__path__[0] - parts = self.__name__.split(".")[1:] + [name] - file_path = pkg_root - for p in parts: - file_path = os.path.join(file_path, p) - - # If it's a package directory, import it as a submodule - if os.path.isdir(file_path) and os.path.exists(os.path.join(file_path, "__init__.py")): - return importlib.import_module(f"{self.__name__}.{name}") - # If it's a regular .py file - if os.path.exists(file_path + ".py"): - return importlib.import_module(f"{self.__name__}.{name}") - raise AttributeError(name) - - -_bypasser_installed = False - - -def _install_jiuwen_bypasser() -> bool: - """ - Install the circular import bypasser for openjiuwen. - Returns True if installed, False if already installed or openjiuwen not available. - """ - global _bypasser_installed - if _bypasser_installed: - return True - - # Stub missing optional dependencies before openjiuwen import chain reaches them - _stubbed = [ - ("pymilvus", {"is_successful": lambda *args, **kwargs: True}), - ("dashscope", {}), - ("pdfplumber", {}), - ] - for _name, _attrs in _stubbed: - if _name not in sys.modules: - _mod = types.ModuleType(_name) - for _k, _v in _attrs.items(): - setattr(_mod, _k, _v) - sys.modules[_name] = _mod - _mod.__path__ = [] - - # Pre-create nested stub modules for pymilvus.client.utils chain - if "pymilvus.client" not in sys.modules: - _client_mod = types.ModuleType("pymilvus.client") - _client_mod.__path__ = [] - sys.modules["pymilvus.client"] = _client_mod - if "pymilvus.client.utils" not in sys.modules: - _utils_mod = types.ModuleType("pymilvus.client.utils") - _utils_mod.is_successful = lambda *args, **kwargs: True - sys.modules["pymilvus.client.utils"] = _utils_mod - - # Stub dashscope sub-modules that may be imported lazily - _dashscope_subs = [ - ("dashscope.api_entities", {}), - ("dashscope.api_entities.data", {}), - ("dashscope.api_entities.dashscope_response", {"DashScopeAPIResponse": object}), - ("dashscope.common", {"REQUEST_TIMEOUT_KEYWORD": "timeout"}), - ("dashscope.common.constants", {"REQUEST_TIMEOUT_KEYWORD": "timeout"}), - ] - for _name, _attrs in _dashscope_subs: - if _name not in sys.modules: - _m = types.ModuleType(_name) - _m.__path__ = [] - for _k, _v in _attrs.items(): - setattr(_m, _k, _v) - sys.modules[_name] = _m - - try: - import openjiuwen # noqa: F401 - except ImportError: - return False - - for finder in sys.meta_path: - if isinstance(finder, _JiuwenInitBypasser): - _bypasser_installed = True - return True - - sys.meta_path.insert(0, _JiuwenInitBypasser()) - _bypasser_installed = True - return True - - -# ---------------------------------------------------------------------- -# Language helpers -# ---------------------------------------------------------------------- -LANGUAGE_MAP = {"zh": "zh-CN", "en": "en-US"} - - -def normalize_language(language: str) -> str: - return LANGUAGE_MAP.get(language, "zh-CN") - - -def run_async(coro): - """ - Safely run async coroutine from sync context (FastAPI or Celery). - Handles existing event loops properly. - """ - try: - loop = asyncio.get_running_loop() - except RuntimeError: - return asyncio.run(coro) - - if loop.is_running(): - try: - import nest_asyncio - nest_asyncio.apply() - return loop.run_until_complete(coro) - except ImportError: - import concurrent.futures - - def run_in_thread(): - new_loop = asyncio.new_event_loop() - asyncio.set_event_loop(new_loop) - try: - return new_loop.run_until_complete(coro) - finally: - new_loop.close() - - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: - future = executor.submit(run_in_thread) - return future.result() - - return loop.run_until_complete(coro) - - -# ---------------------------------------------------------------------- -# Jiuwen SDK lazy import helpers -# ---------------------------------------------------------------------- -def _lazy_import_jiuwen_config(): - """Lazily import only lightweight Jiuwen config classes.""" - _install_jiuwen_bypasser() - - try: - import openjiuwen # noqa: F401 - except ImportError as e: - raise JiuwenSDKError(f"Jiuwen SDK 未安装: {e}") from e - - from openjiuwen.core.foundation.llm.schema.config import ( - ModelRequestConfig, - ModelClientConfig, - ProviderType, - ) - - return ModelRequestConfig, ModelClientConfig, ProviderType - - -def build_jiuwen_model_configs(model_id: int, tenant_id: str): - """将 nexent 模型配置转换为 Jiuwen 配置对象""" - from database.model_management_db import get_model_by_model_id - from utils.config_utils import get_model_name_from_config - - ModelRequestConfig, ModelClientConfig, ProviderType = _lazy_import_jiuwen_config() - - model_config = get_model_by_model_id(model_id, tenant_id) - if not model_config: - raise JiuwenSDKError(f"model_id={model_id} not found") - - api_base = (model_config.get("base_url", "") or "").strip() - if not api_base: - api_base = "https://api.openai.com/v1" - - # Jiuwen ModelClientConfig defaults to timeout=60.0, max_retries=3. - # For prompt optimization calls, 60s can be too small. Reuse Nexent model config timeout_seconds. - timeout_seconds = model_config.get("timeout_seconds") - if timeout_seconds is None: - timeout_seconds = 120 - - ssl_cert = model_config.get("ssl_cert") or None - ssl_verify = model_config.get("ssl_verify", True) - if ssl_verify and not ssl_cert: - ssl_verify = False - - client_config = ModelClientConfig( - client_provider=ProviderType.OpenAI, - api_key=model_config["api_key"], - api_base=api_base, - timeout=float(timeout_seconds), - verify_ssl=ssl_verify, - ssl_cert=ssl_cert, - ) - - request_config = ModelRequestConfig( - model_name=get_model_name_from_config(model_config), - temperature=0.3, - ) - return request_config, client_config - - -def _lazy_import_jiuwen_builders(): - """Lazily import prompt builders only when optimization paths need them.""" - _install_jiuwen_bypasser() - - try: - import openjiuwen # noqa: F401 - except ImportError as e: - raise JiuwenSDKError(f"Jiuwen SDK 未安装: {e}") from e - - from openjiuwen.dev_tools.prompt_builder.builder.feedback_prompt_builder import ( - FeedbackPromptBuilder, - ) - from openjiuwen.dev_tools.prompt_builder.builder.badcase_prompt_builder import ( - BadCasePromptBuilder, - ) - - return FeedbackPromptBuilder, BadCasePromptBuilder - - -def _unwrap_prompt_response(text: str) -> str: - """Strip JSON wrapper or markdown fence that Jiuwen LLM sometimes generates.""" - _logger = logging.getLogger("jiuwen_adapter") - _logger.debug(f"[unwrap] raw ({len(text)} chars): {text[:200]}") - - # Step 1: strip markdown code fences - text = text.strip() - if text.startswith("```"): - for lang in ("json", ""): - fence = f"```{lang}\n" - if text.startswith(fence): - text = text[len(fence):] - if text.endswith("\n```"): - text = text[:-4] - elif text.endswith("```"): - text = text[:-3] - break - text = text.strip() - _logger.debug(f"[unwrap] after fence strip ({len(text)} chars)") - - # Step 2: try standard JSON parse (handles format 1 and 2) - if text.startswith("{"): - try: - parsed = json.loads(text) - if isinstance(parsed, dict) and "prompt" in parsed: - result = parsed["prompt"].strip() - _logger.debug(f"[unwrap] extracted prompt ({len(result)} chars)") - return result - if isinstance(parsed, dict) and "result" in parsed: - result = parsed["result"].strip() - _logger.debug(f"[unwrap] extracted result ({len(result)} chars)") - return result - except Exception: - pass - - # Step 3: format 3 and 4 - raw text (possibly multi-line), return as-is - _logger.debug(f"[unwrap] no JSON wrapper, returning raw ({len(text)} chars)") - return text - - -def _lazy_import_jiuwen_tune_types(): - """Lazily import Jiuwen tune types only when badcase flow needs them.""" - _install_jiuwen_bypasser() - from openjiuwen.dev_tools.tune.base import Case, EvaluatedCase - return Case, EvaluatedCase - - -def to_jiuwen_evaluated_case(bad_case) -> Any: - """将 nexent BadCase 转换为 Jiuwen EvaluatedCase""" - Case, EvaluatedCase = _lazy_import_jiuwen_tune_types() - - case = Case( - inputs={"question": bad_case.question}, - label={"answer": bad_case.label or ""}, - ) - return EvaluatedCase( - case=case, - answer={"content": bad_case.answer}, - score=0.0, - reason=bad_case.reason or "", - ) - - -# ---------------------------------------------------------------------- -# Main adapter class -# ---------------------------------------------------------------------- -class JiuwenSDKAdapter: - """ - Jiuwen SDK 调用适配器 - - 封装 Jiuwen SDK 的所有调用,内部不处理降级, - 失败时抛出 JiuwenSDKError,由上层 PromptOptimizationService 决定是否降级 - """ - - def __init__(self, model_id: int, tenant_id: str): - self.model_id = model_id - self.tenant_id = tenant_id - self.logger = logging.getLogger("jiuwen_adapter") - - def _ensure_available(self): - """确保 Jiuwen SDK 可用""" - if not _bypasser_installed: - _install_jiuwen_bypasser() - - try: - import openjiuwen # noqa: F401 - except ImportError as e: - raise JiuwenSDKError(f"Jiuwen SDK 未安装: {e}") from e - - def optimize( - self, - prompt: str, - feedback: str, - mode: Literal["general", "insert", "select"] = "general", - start_pos: Optional[int] = None, - end_pos: Optional[int] = None, - language: str = "zh", - ) -> str: - """ - 调用 Jiuwen FeedbackPromptBuilder - - Raises: - JiuwenSDKError: SDK 调用失败 - """ - self._ensure_available() - - logger.info(f"[jiuwen-adapter] mode={mode}, start_pos={start_pos}, end_pos={end_pos}") - - request_config, client_config = build_jiuwen_model_configs( - self.model_id, self.tenant_id - ) - logger.info( - f"[jiuwen-adapter] model_id={self.model_id}, tenant_id={self.tenant_id}, " - f"api_base={client_config.api_base}, model={request_config.model_name}, " - f"timeout={getattr(client_config, 'timeout', None)}, max_retries={getattr(client_config, 'max_retries', None)}" - ) - FeedbackPromptBuilder, _ = _lazy_import_jiuwen_builders() - - builder = FeedbackPromptBuilder( - model_config=request_config, - model_client_config=client_config, - ) - - try: - result = run_async( - builder.build( - prompt=prompt, - feedback=feedback, - mode=mode, - start_pos=start_pos, - end_pos=end_pos, - language=normalize_language(language), - ) - ) - if result is None: - raise JiuwenSDKError("Jiuwen FeedbackPromptBuilder 返回为空") - return _unwrap_prompt_response(str(result)) - except Exception as e: - self.logger.error(f"Jiuwen FeedbackPromptBuilder 调用失败: {e}") - raise JiuwenSDKError(f"优化调用失败: {e}") from e - - def optimize_badcase( - self, - prompt: str, - bad_cases: List, - language: str = "zh", - ) -> str: - """ - 调用 Jiuwen BadCasePromptBuilder - - Raises: - JiuwenSDKError: SDK 调用失败 - """ - self._ensure_available() - - _, BadCasePromptBuilder = _lazy_import_jiuwen_builders() - - request_config, client_config = build_jiuwen_model_configs( - self.model_id, self.tenant_id - ) - builder = BadCasePromptBuilder( - model_config=request_config, - model_client_config=client_config, - ) - - jiuwen_cases = [to_jiuwen_evaluated_case(bc) for bc in bad_cases] - - try: - result = run_async( - builder.build( - prompt=prompt, - cases=jiuwen_cases, - language=normalize_language(language), - ) - ) - if result is None: - raise JiuwenSDKError("Jiuwen BadCasePromptBuilder 返回为空") - return _unwrap_prompt_response(str(result)) - except Exception as e: - self.logger.error(f"Jiuwen BadCasePromptBuilder 调用失败: {e}") - raise JiuwenSDKError(f"BadCasePromptBuilder 调用失败: {e}") from e - - def generate(self, **kwargs) -> dict: - """调用 Jiuwen 提示词生成能力""" - self._ensure_available() - raise JiuwenSDKError("Jiuwen 提示词生成能力尚未实现") diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py index 7e3b42e28..50df7eb99 100644 --- a/backend/agents/create_agent_info.py +++ b/backend/agents/create_agent_info.py @@ -1,12 +1,12 @@ -import json -import threading +import threading import logging -from typing import Any, Dict, List, Optional +from typing import List, Optional from urllib.parse import urljoin +from datetime import datetime from jinja2 import Template, StrictUndefined from nexent.core.utils.observer import MessageObserver -from nexent.core.agents.agent_model import AgentRunInfo, ModelConfig, AgentConfig, ToolConfig, ExternalA2AAgentConfig, AgentHistory, AgentVerificationConfig +from nexent.core.agents.agent_model import AgentRunInfo, ModelConfig, AgentConfig, ToolConfig, ExternalA2AAgentConfig, AgentHistory from nexent.core.agents.agent_context import ContextManagerConfig from nexent.memory.memory_service import search_memory_in_levels @@ -22,11 +22,7 @@ from database.a2a_agent_db import PROTOCOL_JSONRPC from services.memory_config_service import build_memory_context from services.image_service import get_video_understanding_model, get_vlm_model -from database.agent_db import ( - search_agent_info_by_agent_id, - query_sub_agent_relations, - resolve_sub_agent_version_no, -) +from database.agent_db import search_agent_info_by_agent_id, query_sub_agents_id_list from database.agent_version_db import query_current_version_no from database.tool_db import search_tools_for_sub_agent from database.model_management_db import get_model_records, get_model_by_model_id @@ -37,71 +33,12 @@ from utils.config_utils import tenant_config_manager, get_model_name_from_config from utils.context_utils import build_context_components from consts.const import LOCAL_MCP_SERVER, MODEL_CONFIG_MAPPING, LANGUAGE, DATA_PROCESS_SERVICE, MINIO_DEFAULT_BUCKET -from consts.model import AgentToolParamsRequest, ToolParamsRequest from consts.exceptions import ValidationError logger = logging.getLogger("create_agent_info") logger.setLevel(logging.DEBUG) -def _normalize_tool_params_request(tool_params: Optional[ToolParamsRequest | Dict[str, Any]]) -> ToolParamsRequest: - """Normalize request-scoped tool parameter overrides into a ToolParamsRequest.""" - if tool_params is None: - return ToolParamsRequest() - if isinstance(tool_params, ToolParamsRequest): - return tool_params - if not isinstance(tool_params, dict): - raise ValidationError("tool_params must be an object.") - try: - return ToolParamsRequest.model_validate(tool_params) - except Exception as exc: - raise ValidationError(f"Invalid tool_params payload: {exc}") from exc - - -def _get_agent_tool_overrides( - tool_params: Optional[ToolParamsRequest], - agent_name: Optional[str], -) -> Dict[str, Dict[str, Any]]: - """Resolve tool overrides for a specific agent by its name.""" - if tool_params is None: - return {} - if not agent_name: - return {} - agent_override = tool_params.agents.get(agent_name) - if agent_override is None: - return {} - return dict(agent_override.tools) - - -def _merge_tool_params( - tool_record: Dict[str, Any], - override_params: Optional[Dict[str, Any]], - extra_params: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - """Merge request overrides on top of tool instance defaults from DB. - - Args: - tool_record: Tool configuration from database - override_params: Request-scoped overrides from tool_params - extra_params: Additional internal params not in DB schema (e.g., document_paths) - - Returns: - Merged params dict with DB defaults, overrides, and extra params - """ - merged_params: Dict[str, Any] = {} - for param in tool_record.get("params", []): - merged_params[param["name"]] = param.get("default") - - if override_params: - merged_params.update(override_params) - - # Extra params (e.g., internal access control params) always take precedence - if extra_params: - merged_params.update(extra_params) - - return merged_params - - def _build_internal_s3_url(file: dict) -> str: """Build a valid S3 URL for internal tools from uploaded file metadata.""" if not isinstance(file, dict): @@ -373,23 +310,18 @@ async def create_agent_config( allow_memory_search: bool = True, version_no: int = 0, override_model_id: int | None = None, - tool_params: Optional[ToolParamsRequest | Dict[str, Any]] = None, ): - normalized_tool_params = _normalize_tool_params_request(tool_params) agent_info = search_agent_info_by_agent_id( agent_id=agent_id, tenant_id=tenant_id, version_no=version_no) # create sub agent - sub_agent_relations = query_sub_agent_relations( + sub_agent_id_list = query_sub_agents_id_list( main_agent_id=agent_id, tenant_id=tenant_id, version_no=version_no) managed_agents = [] - for rel in sub_agent_relations: - sub_agent_id = rel['selected_agent_id'] - sub_agent_version_no = resolve_sub_agent_version_no( - selected_agent_id=sub_agent_id, - selected_agent_version_no=rel.get('selected_agent_version_no'), - tenant_id=tenant_id, - ) + for sub_agent_id in sub_agent_id_list: + # Get the current published version for this sub-agent (from draft version 0) + sub_agent_version_no = query_current_version_no( + agent_id=sub_agent_id, tenant_id=tenant_id) or 0 sub_agent_config = await create_agent_config( agent_id=sub_agent_id, tenant_id=tenant_id, @@ -399,20 +331,13 @@ async def create_agent_config( allow_memory_search=allow_memory_search, version_no=sub_agent_version_no, override_model_id=None, - tool_params=normalized_tool_params, ) managed_agents.append(sub_agent_config) # create external A2A agents (synchronous function, no await needed) external_a2a_agents = _get_external_a2a_agents(agent_id, tenant_id, version_no) - tool_list = await create_tool_config_list( - agent_id, - tenant_id, - user_id, - version_no=version_no, - tool_params=normalized_tool_params, - ) + tool_list = await create_tool_config_list(agent_id, tenant_id, user_id, version_no=version_no) # Build system prompt: prioritize segmented fields, fallback to original prompt field if not available duty_prompt = agent_info.get("duty_prompt", "") @@ -458,77 +383,6 @@ async def create_agent_config( # Bubble up to streaming layer so it can emit and fall back raise Exception(f"Failed to retrieve memory list: {e}") - # Append active memory tools if memory is enabled - if memory_context.user_config.memory_switch and memory_context.memory_config: - try: - memory_metadata = { - "memory_config": memory_context.memory_config, - "memory_user_config": memory_context.user_config, - "tenant_id": memory_context.tenant_id, - "user_id": memory_context.user_id, - "agent_id": memory_context.agent_id, - } - - store_tool_config = ToolConfig( - class_name="StoreMemoryTool", - name="store_memory", - description=( - "Save important information to long-term memory for future recall. " - "Use this when the user shares personal preferences, facts about themselves, " - "project context, or instructions that should persist across conversations. " - "Do NOT store transient information like temporary calculations, information " - "already in the knowledge base, or data the user explicitly says to forget." - ), - inputs=json.dumps({ - "content": { - "type": "string", - "description": "The information to remember", - "description_zh": "需要记住的信息" - } - }, ensure_ascii=False), - output_type="string", - params={}, - source="local", - usage=None, - metadata=memory_metadata, - ) - tool_list.append(store_tool_config) - - search_tool_config = ToolConfig( - class_name="SearchMemoryTool", - name="search_memory", - description=( - "Search long-term memory for relevant information from previous interactions. " - "Use this when you need context about the user's preferences, past decisions, " - "or previously discussed topics that aren't in the current conversation. " - "The system already provides some memory context automatically -- use this tool " - "when you need to search for specific information not already available." - ), - inputs=json.dumps({ - "query": { - "type": "string", - "description": "Natural language query describing what to search for", - "description_zh": "描述要搜索内容的自然语言查询" - }, - "top_k": { - "type": "integer", - "description": "Maximum number of results to return", - "description_zh": "返回结果的最大数量", - "default": 5, - "nullable": True - } - }, ensure_ascii=False), - output_type="string", - params={}, - source="local", - usage=None, - metadata=memory_metadata, - ) - tool_list.append(search_tool_config) - logger.debug("Active memory tools appended to agent tool list") - except Exception as e: - logger.warning(f"Failed to append active memory tools: {e}") - # Build knowledge base summary knowledge_base_summary = "" try: @@ -559,6 +413,7 @@ async def create_agent_config( # Get skills list for prompt template skills = _get_skills_for_template(agent_id, tenant_id, version_no) + time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") is_manager = len(managed_agents) > 0 or len(external_a2a_agents) > 0 render_kwargs = { @@ -573,6 +428,7 @@ async def create_agent_config( "APP_DESCRIPTION": app_description, "memory_list": memory_list, "knowledge_base_summary": knowledge_base_summary, + "time": time_str, "user_id": user_id, } system_prompt = Template(prompt_template["system_prompt"], undefined=StrictUndefined).render(render_kwargs) @@ -601,6 +457,7 @@ async def create_agent_config( few_shots=few_shots_prompt, app_name=app_name, app_description=app_description, + time_str=time_str, user_id=user_id, language=language, is_manager=is_manager, @@ -633,48 +490,21 @@ async def create_agent_config( external_a2a_agents=external_a2a_agents, context_manager_config=cm_config, context_components=context_components, - verification_config=AgentVerificationConfig.model_validate(agent_info.get("verification_config") or {}), ) return agent_config -async def create_tool_config_list( - agent_id, - tenant_id, - user_id, - version_no: int = 0, - tool_params: Optional[ToolParamsRequest | Dict[str, Any]] = None, -): +async def create_tool_config_list(agent_id, tenant_id, user_id, version_no: int = 0): + # create tool tool_config_list = [] langchain_tools = await discover_langchain_tools() - normalized_tool_params = _normalize_tool_params_request(tool_params) # now only admin can modify the agent, user_id is not used tools_list = search_tools_for_sub_agent(agent_id, tenant_id, version_no=version_no) - - # Look up agent name for use in error messages. - # Agent name is optional for tool_params matching (matching uses tool identifiers only), - # but we include it in error messages so callers can identify which agent/tool caused a failure. - agent_info = search_agent_info_by_agent_id(agent_id=agent_id, tenant_id=tenant_id, version_no=version_no) - agent_name = agent_info.get("name") if agent_info else None - agent_tool_overrides = _get_agent_tool_overrides(normalized_tool_params, agent_name) - - tool_keys_seen = set() for tool in tools_list: - tool_identifier = tool.get("name") or tool.get("class_name") - if tool_identifier in tool_keys_seen: - raise ValidationError( - f"Duplicate tool identifier '{tool_identifier}' found in agent '{agent_name or agent_id}'." - ) - tool_keys_seen.add(tool_identifier) - - override_params = None - if tool.get("name") in agent_tool_overrides: - override_params = agent_tool_overrides[tool.get("name")] - elif tool.get("class_name") in agent_tool_overrides: - override_params = agent_tool_overrides[tool.get("class_name")] - - param_dict = _merge_tool_params(tool, override_params) + param_dict = {} + for param in tool.get("params", []): + param_dict[param["name"]] = param.get("default") tool_config = ToolConfig( class_name=tool.get("class_name"), name=tool.get("name"), @@ -693,21 +523,12 @@ async def create_tool_config_list( tool_config.metadata = langchain_tool break - # Extract document_paths for KnowledgeBaseSearchTool (internal access control, not in DB schema) - document_paths = None - if override_params and "document_paths" in override_params: - document_paths = override_params.get("document_paths") - # Also check using the tool name as key - if not document_paths: - kb_overrides = agent_tool_overrides.get("knowledge_base_search") - if kb_overrides and "document_paths" in kb_overrides: - document_paths = kb_overrides.get("document_paths") - # special logic for search tools that may use reranking models if tool_config.class_name == "KnowledgeBaseSearchTool": - rerank = tool_config.params.get("rerank", False) - rerank_model_name = tool_config.params.get("rerank_model_name", "") + rerank = param_dict.get("rerank", False) + rerank_model_name = param_dict.get("rerank_model_name", "") rerank_model = None + is_multimodal = bool(tool_config.params.pop("multimodal", False)) if rerank and rerank_model_name: rerank_model = get_rerank_model( tenant_id=tenant_id, model_name=rerank_model_name @@ -715,7 +536,7 @@ async def create_tool_config_list( # Build display_name to index_name mapping for LLM parameter conversion # Also build reverse mapping (index_name -> display_name) for knowledge_base_summary - index_names = tool_config.params.get("index_names", []) + index_names = param_dict.get("index_names", []) display_name_to_index_map = {} index_name_to_display_map = {} if index_names: @@ -731,14 +552,12 @@ async def create_tool_config_list( "rerank_model": rerank_model, "display_name_to_index_map": display_name_to_index_map, "index_name_to_display_map": index_name_to_display_map, - # Internal access control: restrict results to specific document paths (path_or_urls) - "document_paths": document_paths, } + # Must have embedding model for knowledge base search if not index_names: raise ValidationError( - f"[{agent_name or agent_id}] knowledge_base_search tool requires index_names, " - f"but it is not configured in the agent and not provided via tool_params.") + "Embedding model is required for knowledge_base_search but index_names is empty") embedding_model, _, _ = get_embedding_model_by_index_name(tenant_id, index_names[0]) if not embedding_model: @@ -747,8 +566,8 @@ async def create_tool_config_list( f"Please configure an embedding model for this knowledge base.") tool_config.metadata["embedding_model"] = embedding_model elif tool_config.class_name in ["DifySearchTool", "DataMateSearchTool"]: - rerank = tool_config.params.get("rerank", False) - rerank_model_name = tool_config.params.get("rerank_model_name", "") + rerank = param_dict.get("rerank", False) + rerank_model_name = param_dict.get("rerank_model_name", "") rerank_model = None if rerank and rerank_model_name: rerank_model = get_rerank_model( @@ -1042,7 +861,6 @@ async def create_agent_run_info( is_debug: bool = False, override_version_no: int | None = None, override_model_id: int | None = None, - tool_params: Optional[ToolParamsRequest | Dict[str, Any]] = None, ): # Determine which version_no to use based on is_debug flag # If is_debug=false, use the current published version (current_version_no) @@ -1075,7 +893,7 @@ async def create_agent_run_info( if override_model_id is not None: create_config_kwargs["override_model_id"] = override_model_id - agent_config = await create_agent_config(**create_config_kwargs, tool_params=tool_params) + agent_config = await create_agent_config(**create_config_kwargs) remote_mcp_list = await get_remote_mcp_server_list(tenant_id=tenant_id, is_need_auth=True) default_mcp_url = urljoin(LOCAL_MCP_SERVER, "sse") diff --git a/backend/apps/agent_app.py b/backend/apps/agent_app.py index 87abbf9e8..e280ff422 100644 --- a/backend/apps/agent_app.py +++ b/backend/apps/agent_app.py @@ -195,6 +195,8 @@ async def export_agent_api(request: AgentIDRequest, authorization: Optional[str] "Content-Disposition": f"attachment; filename=\"{result.get('filename', 'agent_export.zip')}\"" } ) + if isinstance(result, str): + result = json.loads(result) return ConversationResponse(code=0, message="success", data=result) except Exception as e: logger.error(f"Agent export error: {str(e)}") @@ -619,5 +621,3 @@ async def list_published_agents_api( raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Published agents list error." ) - - diff --git a/backend/apps/agent_repository_app.py b/backend/apps/agent_repository_app.py deleted file mode 100644 index e9da2fde0..000000000 --- a/backend/apps/agent_repository_app.py +++ /dev/null @@ -1,134 +0,0 @@ -import logging -from http import HTTPStatus -from typing import Optional - -from fastapi import APIRouter, Body, Header, HTTPException, Query -from starlette.responses import JSONResponse - -from consts.exceptions import SkillDuplicateError, UnauthorizedError -from services.agent_repository_service import ( - create_agent_repository_listing_impl, - import_agent_from_repository_impl, - list_agent_repository_listings_impl, - update_agent_repository_status_impl, -) -from utils.auth_utils import get_current_user_id - -agent_repository_router = APIRouter(prefix="/repository/agent") -logger = logging.getLogger("agent_repository_app") - - -@agent_repository_router.get("") -async def list_agent_repository_listings_api( - status: Optional[str] = Query(None, description="Filter by listing status"), - authorization: str = Header(None), -): - """List all marketplace repository listings with optional status filter.""" - try: - get_current_user_id(authorization) - result = list_agent_repository_listings_impl(status=status) - return JSONResponse(status_code=HTTPStatus.OK, content=result) - except UnauthorizedError as e: - raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) - except ValueError as e: - raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) - except Exception as e: - logger.error(f"List agent repository listings error: {str(e)}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="List agent repository listings error.", - ) - - -@agent_repository_router.patch("/{agent_repository_id}/status") -async def update_agent_repository_status_api( - agent_repository_id: int, - status: str = Body( - ..., - embed=True, - description=( - "New status: NOT_SHARED (未共享) / PENDING_REVIEW (待审核) / " - "REJECTED (审核驳回) / SHARED (已共享)" - ), - ), - authorization: str = Header(None), -): - """Update marketplace repository listing status (share, unshare, approve, reject).""" - try: - user_id, _ = get_current_user_id(authorization) - result = update_agent_repository_status_impl( - agent_repository_id=agent_repository_id, - status=status, - user_id=user_id, - ) - return JSONResponse(status_code=HTTPStatus.OK, content=result) - except UnauthorizedError as e: - raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) - except ValueError as e: - raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) - except Exception as e: - logger.error(f"Update agent repository status error: {str(e)}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Update agent repository status error.", - ) - - -@agent_repository_router.post("/{agent_id}/versions/{version_no}") -async def create_agent_repository_listing_api( - agent_id: int, - version_no: int, - authorization: str = Header(None), -): - """Create or update a marketplace repository listing from an agent version snapshot.""" - try: - user_id, tenant_id = get_current_user_id(authorization) - result = await create_agent_repository_listing_impl( - agent_id=agent_id, - tenant_id=tenant_id, - user_id=user_id, - version_no=version_no, - ) - return JSONResponse(status_code=HTTPStatus.OK, content=result) - except UnauthorizedError as e: - raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) - except ValueError as e: - raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) - except Exception as e: - logger.error(f"Create agent repository listing error: {str(e)}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Create agent repository listing error.", - ) - - -@agent_repository_router.post("/{agent_repository_id}/import") -async def import_agent_from_repository_api( - agent_repository_id: int, - authorization: Optional[str] = Header(None), -): - """Import an agent tree from a marketplace repository listing into the current tenant.""" - try: - await import_agent_from_repository_impl( - agent_repository_id=agent_repository_id, - authorization=authorization, - ) - return JSONResponse(status_code=HTTPStatus.OK, content={}) - except UnauthorizedError as e: - raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) - except SkillDuplicateError as exc: - raise HTTPException( - status_code=HTTPStatus.CONFLICT, - detail={ - "type": "skill_duplicate", - "duplicate_skills": exc.duplicate_names, - }, - ) - except ValueError as e: - raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) - except Exception as e: - logger.error(f"Import agent from repository error: {str(e)}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Import agent from repository error.", - ) diff --git a/backend/apps/app_factory.py b/backend/apps/app_factory.py index 02816cec1..219da5b82 100644 --- a/backend/apps/app_factory.py +++ b/backend/apps/app_factory.py @@ -101,16 +101,6 @@ async def generic_exception_handler(request, exc): if isinstance(exc, AppException): return await app_exception_handler(request, exc) - # Handle NexentCapabilityError with a friendly message - from adapters.exception import NexentCapabilityError as _NCE - - if isinstance(exc, _NCE): - logger.warning(f"NexentCapabilityError: {exc}") - return JSONResponse( - status_code=400, - content={"message": str(exc)}, - ) - logger.error(f"Generic Exception: {exc}") return JSONResponse( status_code=500, diff --git a/backend/apps/cas_app.py b/backend/apps/cas_app.py deleted file mode 100644 index dbf4815f8..000000000 --- a/backend/apps/cas_app.py +++ /dev/null @@ -1,156 +0,0 @@ -import html -import logging -from http import HTTPStatus -from typing import Optional -from urllib.parse import parse_qs, urlsplit - -from fastapi import APIRouter, HTTPException, Query, Request -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse - -from services.cas_service import ( - CAS_SERVER_URL, - CasAuthenticationError, - build_login_url, - build_renew_url, - get_cas_config, - login_with_ticket, - renew_with_ticket, - revoke_from_logout_request, -) - -logger = logging.getLogger(__name__) -router = APIRouter(prefix="/user/cas", tags=["cas"]) - - -@router.get("/config") -async def config(): - return JSONResponse( - status_code=HTTPStatus.OK, - content={"message": "success", "data": get_cas_config()}, - ) - - -@router.get("/login") -async def login(redirect: str = Query("/", description="URL to return to after login")): - try: - login_url = _require_cas_server_redirect(build_login_url(redirect)) - return RedirectResponse(url=login_url, status_code=HTTPStatus.FOUND) - except CasAuthenticationError as exc: - logger.warning("CAS login rejected: %s", exc) - raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="CAS login is not available") - - -@router.get("/callback") -async def callback(ticket: str = "", redirect: str = "/"): - try: - result = await login_with_ticket(ticket, redirect) - return JSONResponse( - status_code=HTTPStatus.OK, - content={"message": "CAS login successful", "data": result}, - ) - except CasAuthenticationError as exc: - logger.warning("CAS callback rejected: %s", exc) - raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="CAS authentication failed") - except Exception as exc: - logger.error(f"CAS callback failed: {exc}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="CAS login failed") - - -@router.post("/callback") -async def callback_logout(request: Request, logout_request: Optional[str] = None): - return await _handle_logout_request(request, logout_request, endpoint="callback") - - -@router.get("/renew") -async def renew(): - try: - return RedirectResponse(url=build_renew_url(), status_code=HTTPStatus.FOUND) - except CasAuthenticationError as exc: - logger.warning("CAS renew rejected: %s", exc) - return _renew_html(False, "CAS renew failed") - - -@router.get("/renew_callback") -async def renew_callback(ticket: str = ""): - if not ticket: - return _renew_html(False, "CAS session is not active") - try: - result = await renew_with_ticket(ticket) - return JSONResponse( - status_code=HTTPStatus.OK, - content={"message": "CAS renew successful", "data": result}, - ) - except Exception as exc: - logger.warning(f"CAS renew failed: {exc}") - return _renew_html(False, "CAS renew failed") - - -@router.post("/logout_callback") -async def logout_callback( - request: Request, - logout_request: Optional[str] = None, -): - return await _handle_logout_request(request, logout_request, endpoint="logout_callback") - - -async def _handle_logout_request( - request: Request, - logout_request: Optional[str] = None, - endpoint: str = "unknown", -): - logout_request = await _extract_logout_request(request, logout_request) - logger.info( - "CAS SLO %s received logoutRequest: present=%s length=%s", - endpoint, - bool(logout_request), - len(logout_request or ""), - ) - result = revoke_from_logout_request(logout_request) - logger.info("CAS SLO %s revoke result: %s", endpoint, result) - return JSONResponse( - status_code=HTTPStatus.OK, - content={"message": "success", "data": result}, - ) - - -async def _extract_logout_request(request: Request, logout_request: Optional[str] = None) -> str: - if logout_request: - return logout_request - - query_logout_request = request.query_params.get("logoutRequest") or request.query_params.get("logout_request") - if query_logout_request: - return query_logout_request - - body = await request.body() - raw_body = body.decode("utf-8") if body else "" - if not raw_body: - return "" - - parsed = parse_qs(raw_body) - return (parsed.get("logoutRequest") or parsed.get("logout_request") or [raw_body])[0] - - -def _renew_html(success: bool, reason: str = "") -> HTMLResponse: - status = "success" if success else "failed" - safe_reason = html.escape(reason) - return HTMLResponse( - status_code=HTTPStatus.OK, - content=f""" -""", - ) - - -def _require_cas_server_redirect(url: str) -> str: - parsed_url = urlsplit(url) - parsed_cas = urlsplit(CAS_SERVER_URL) - if ( - parsed_url.scheme not in {"http", "https"} - or not parsed_url.netloc - or parsed_url.scheme != parsed_cas.scheme - or parsed_url.netloc != parsed_cas.netloc - ): - logger.warning("Blocked CAS redirect outside configured server: %s", url) - raise CasAuthenticationError("Invalid CAS redirect URL") - return url diff --git a/backend/apps/config_app.py b/backend/apps/config_app.py index a818ec7cb..8cb383df7 100644 --- a/backend/apps/config_app.py +++ b/backend/apps/config_app.py @@ -2,7 +2,6 @@ from apps.app_factory import create_app from apps.agent_app import agent_config_router as agent_router -from apps.agent_repository_app import agent_repository_router from apps.config_sync_app import router as config_sync_router from apps.datamate_app import router as datamate_router from apps.vectordatabase_app import router as vectordatabase_router @@ -33,7 +32,6 @@ from apps.monitoring_app import router as monitoring_router from apps.a2a_server_app import router as a2a_server_router from apps.haotian_app import router as haotian_router -from apps.cas_app import router as cas_router from consts.const import IS_SPEED_MODE from services.prompt_template_service import sync_system_default_prompt_template @@ -56,7 +54,6 @@ async def sync_default_prompt_template_on_startup(): app.include_router(model_manager_router) app.include_router(config_sync_router) app.include_router(agent_router) -app.include_router(agent_repository_router) app.include_router(vectordatabase_router) app.include_router(datamate_router) app.include_router(voice_router) @@ -76,7 +73,6 @@ async def sync_default_prompt_template_on_startup(): app.include_router(user_management_router) app.include_router(oauth_router) -app.include_router(cas_router) app.include_router(summary_router) app.include_router(prompt_router) diff --git a/backend/apps/northbound_app.py b/backend/apps/northbound_app.py index 9f3b7e323..e6aff8e06 100644 --- a/backend/apps/northbound_app.py +++ b/backend/apps/northbound_app.py @@ -1,16 +1,14 @@ import logging from http import HTTPStatus from typing import Optional, Dict, Any -from urllib.parse import urlparse, unquote -import re +from urllib.parse import urlparse import uuid import httpx -from fastapi import APIRouter, Body, File, Header, HTTPException, Query, Request, UploadFile +from fastapi import APIRouter, Body, Header, Request, HTTPException, Query from fastapi.responses import JSONResponse, StreamingResponse -from consts.exceptions import LimitExceededError, UnauthorizedError, ConversationNotFoundError -from consts.model import ToolParamsRequest +from consts.exceptions import LimitExceededError, UnauthorizedError from services.northbound_service import ( NorthboundContext, get_conversation_history, @@ -19,35 +17,16 @@ stop_chat, get_agent_info_list, update_conversation_title, - upload_files_for_northbound, ) from utils.auth_utils import validate_bearer_token, get_user_and_tenant_by_access_key -from .file_management_app import build_content_disposition_header - router = APIRouter(prefix="/nb/v1", tags=["northbound"]) __all__ = ["router", "_get_northbound_context"] -def _resolve_proxy_download_filename(presigned_url: str, content_disposition: str) -> str: - """Resolve a stable download filename for the northbound file proxy.""" - if content_disposition: - filename_star_match = re.search(r"filename\*=UTF-8''([^;]+)", content_disposition) - if filename_star_match: - return unquote(filename_star_match.group(1)) or "download" - - filename_match = re.search(r'filename="?([^";]+)"?', content_disposition) - if filename_match: - return filename_match.group(1) or "download" - - path = unquote(urlparse(presigned_url).path) - filename = path.split("/")[-1].strip() - return filename or "download" - - async def _get_northbound_context(request: Request) -> NorthboundContext: """ Build northbound context from request. @@ -130,119 +109,13 @@ async def health_check(): return {"status": "healthy", "service": "northbound-api"} -@router.post( - "/chat/attachments/upload", - summary="Upload chat attachments for northbound runs", - description=( - "Upload one or more files for later use in `/nb/v1/chat/run`. " - "Successful uploads return reusable `s3_url` references." - ), -) -async def upload_chat_attachments( - request: Request, - files: list[UploadFile] = File( - ..., - description="List of files to upload", - examples=["report.pdf", "diagram.png"], - ), -): - try: - ctx: NorthboundContext = await _get_northbound_context(request) - return JSONResponse( - status_code=HTTPStatus.OK, - content=await upload_files_for_northbound(ctx=ctx, files=files), - ) - except LimitExceededError as e: - logging.error(f"Too Many Requests: rate limit exceeded: {str(e)}", exc_info=e) - raise HTTPException(status_code=HTTPStatus.TOO_MANY_REQUESTS, - detail="Too Many Requests: rate limit exceeded") - except ValueError as e: - logging.error(f"Invalid northbound upload request: {str(e)}", exc_info=e) - raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) - except PermissionError as e: - logging.error(f"Permission denied while uploading northbound files: {str(e)}", exc_info=e) - raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e)) - except HTTPException as e: - raise e - except Exception as e: - logging.error(f"Failed to upload northbound files: {str(e)}", exc_info=e) - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Internal Server Error") - - -@router.post( - "/chat/run", - summary="Start a northbound chat run with optional attachments", - description=( - "Run a northbound chat request. Upload attachments first through " - "`/nb/v1/chat/attachments/upload`, then pass the returned `s3_url` values " - "through the `attachments` field." - ), -) +@router.post("/chat/run") async def run_chat( request: Request, - conversation_id: Optional[int] = Body( - None, - embed=True, - description="Existing conversation ID. Omit to create a new conversation.", - examples=[123], - ), - agent_name: str = Body( - ..., - embed=True, - description="Target agent name.", - examples=["general-assistant"], - ), - query: str = Body( - ..., - embed=True, - description="User input to send to the agent.", - examples=["Summarize the uploaded report and list the key risks."], - ), - attachments: Optional[list] = Body( - None, - embed=True, - description="Attachments for the chat. Can be either a list of S3 URL strings" - "or a list of attachment objects with full metadata.", - examples=[["s3://nexent/attachments/user123/20260609_report.pdf"]], - ), - meta_data: Optional[Dict[str, Any]] = Body( - None, - embed=True, - description="Optional metadata passed through for audit and usage logging.", - examples=[{"source": "crm", "ticket_id": "INC-1001"}], - ), - tool_params: Optional[ToolParamsRequest] = Body( - None, - embed=True, - description="Optional request-scoped overrides for tool initialization parameters. " - "Overrides DB-persisted params (ag_tool_instance_t.params) on a per-run basis. " - "Conflict resolution: request value wins over DB value. " - "Structure: agents -> {agent_name} -> tools -> {tool_name} -> {param_name: param_value}. " - "tool_name matching: first by tool.name, then by tool.class_name. " - "Unknown param names cause a ValidationError (400). " - "Metadata-derived fields (e.g., vdb_core, embedding_model) are recalculated " - "from merged params for tools like KnowledgeBaseSearchTool, DifySearchTool, DataMateSearchTool.", - examples=[{ - "agents": { - "common_sense_qa_assistant": { - "tools": { - "analyze_text_file": { - "chunk_size": 4000, - "summary_only": True, - "prompt": "Please provide a concise summary of this document focusing on key facts." - }, - "knowledge_base_search": { - "top_k": 10, - "rerank": True, - "rerank_model_name": "gte-rerank-v2", - "index_names": ["nexent-docs", "faq-index"] - } - } - } - } - }], - ), + conversation_id: Optional[int] = Body(None, embed=True), + agent_name: str = Body(..., embed=True), + query: str = Body(..., embed=True), + meta_data: Optional[Dict[str, Any]] = Body(None, embed=True), idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"), ): try: @@ -252,21 +125,13 @@ async def run_chat( conversation_id=conversation_id, agent_name=agent_name, query=query, - attachments=attachments, meta_data=meta_data, - tool_params=tool_params, idempotency_key=idempotency_key, ) except LimitExceededError as e: logging.error(f"Too Many Requests: rate limit exceeded: {str(e)}", exc_info=e) raise HTTPException(status_code=HTTPStatus.TOO_MANY_REQUESTS, detail="Too Many Requests: rate limit exceeded") - except ValueError as e: - logging.error(f"Invalid northbound chat request: {str(e)}", exc_info=e) - raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) - except PermissionError as e: - logging.error(f"Permission denied while running northbound chat: {str(e)}", exc_info=e) - raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e)) except HTTPException as e: raise e except Exception as e: @@ -389,9 +254,6 @@ async def update_convs_title( logging.error(f"Too Many Requests: rate limit exceeded: {str(e)}", exc_info=e) raise HTTPException(status_code=HTTPStatus.TOO_MANY_REQUESTS, detail="Too Many Requests: rate limit exceeded") - except ConversationNotFoundError as e: - logging.error(f"Conversation not found while updating title: {str(e)}", exc_info=e) - raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) except HTTPException as e: raise e except Exception as e: @@ -450,12 +312,12 @@ async def fetch_file_from_presigned_url( content_type = response.headers.get("Content-Type", "application/octet-stream") content_disposition = response.headers.get("Content-Disposition", "") - download_filename = _resolve_proxy_download_filename(presigned_url, content_disposition) headers = { "Content-Type": content_type, - "Content-Disposition": build_content_disposition_header(download_filename), } + if content_disposition: + headers["Content-Disposition"] = content_disposition return StreamingResponse( content=response.aiter_bytes(), diff --git a/backend/apps/northbound_knowledge_app.py b/backend/apps/northbound_knowledge_app.py index 02739d138..775d6c567 100644 --- a/backend/apps/northbound_knowledge_app.py +++ b/backend/apps/northbound_knowledge_app.py @@ -51,8 +51,7 @@ async def _require_asset_owner_context(request: Request) -> NorthboundContext: @router.get("/indices") async def get_list_indices( request: Request, - pattern: Annotated[str, Query( - description="Pattern to match index names")] = "*", + pattern: Annotated[str, Query(description="Pattern to match index names")] = "*", ): """List knowledge bases visible to the asset-owner tenant. @@ -93,7 +92,7 @@ async def create_new_index( Optional[Dict[str, Any]], Body( description=( - "Request body with optional fields (ingroup_permission, group_ids, embedding_model_name, preserve_source_file)" + "Request body with optional fields (ingroup_permission, group_ids, embedding_model_name)" ), ), ] = None, @@ -111,12 +110,10 @@ async def create_new_index( ingroup_permission = None group_ids = None embedding_model_name = None - preserve_source_file = None if body: ingroup_permission = body.get("ingroup_permission") group_ids = body.get("group_ids") embedding_model_name = body.get("embedding_model_name") - preserve_source_file = body.get("preserve_source_file") return ElasticSearchService.create_knowledge_base( knowledge_name=index_name, @@ -127,7 +124,6 @@ async def create_new_index( ingroup_permission=ingroup_permission, group_ids=group_ids, embedding_model_name=embedding_model_name, - preserve_source_file=preserve_source_file, ) except LimitExceededError as e: logger.exception("Rate limit exceeded while creating index") @@ -226,65 +222,52 @@ async def delete_documents( request: Request, index_name: Annotated[str, Path(..., description="Name of the index")], path_or_url: Annotated[str, Query(..., description="Path or URL of documents to delete")], - scope: Annotated[ - str, - Query( - description=( - "source_only: delete MinIO source only; " - "full: delete ES, MinIO, and Redis records" - ), - ), - ] = "full", ): - """Delete a document by scope. Restricted to asset administrators.""" + """Delete documents by path or URL and clean up related Redis records. + + Restricted to asset administrators (same auth as get_list_indices). + """ try: - await _require_asset_owner_context(request) + ctx = await _require_asset_owner_context(request) vdb_core = get_vector_db_core(db_type=VectorDatabaseType.ELASTICSEARCH) - logger.debug( - "Deleting documents for index %s scope=%s", index_name, scope - ) - result = await ElasticSearchService.delete_document_by_scope( - index_name, path_or_url, scope, vdb_core - ) + logger.debug("Deleting documents for index %s", index_name) + result = ElasticSearchService.delete_documents( + index_name, path_or_url, vdb_core) + + try: + redis_service = get_redis_service() + redis_cleanup_result = redis_service.delete_document_records( + index_name, path_or_url) + + result["redis_cleanup"] = redis_cleanup_result + + original_message = result.get( + "message", "Documents deleted successfully") + result["message"] = ( + f"{original_message}. " + f"Cleaned up {redis_cleanup_result['total_deleted']} Redis records " + f"({redis_cleanup_result['celery_tasks_deleted']} tasks, " + f"{redis_cleanup_result['cache_keys_deleted']} cache keys)." + ) - if scope == "full": - try: - redis_service = get_redis_service() - redis_cleanup_result = redis_service.delete_document_records( - index_name, path_or_url - ) - result["redis_cleanup"] = redis_cleanup_result - original_message = result.get( - "message", "Documents deleted successfully" - ) - result["message"] = ( - f"{original_message}. " - f"Cleaned up {redis_cleanup_result['total_deleted']} Redis records " - f"({redis_cleanup_result['celery_tasks_deleted']} tasks, " - f"{redis_cleanup_result['cache_keys_deleted']} cache keys)." - ) - if redis_cleanup_result.get("errors"): - result["redis_warnings"] = redis_cleanup_result["errors"] - except Exception as redis_error: - logger.warning( - "Redis cleanup failed for index %s: %s", - index_name, - redis_error, - ) - result["redis_cleanup_error"] = str(redis_error) - original_message = result.get( - "message", "Documents deleted successfully" - ) - result["message"] = ( - f"{original_message}, but Redis cleanup encountered an error: " - f"{str(redis_error)}" - ) + if redis_cleanup_result.get("errors"): + result["redis_warnings"] = redis_cleanup_result["errors"] + + except Exception as redis_error: + logger.warning( + "Redis cleanup failed for index %s: %s", + index_name, + redis_error, + ) + result["redis_cleanup_error"] = str(redis_error) + original_message = result.get( + "message", "Documents deleted successfully") + result["message"] = ( + f"{original_message}, but Redis cleanup encountered an error: " + f"{str(redis_error)}" + ) return result - except ValueError as exc: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) - ) except LimitExceededError as e: logger.exception("Rate limit exceeded while deleting documents") raise HTTPException( diff --git a/backend/apps/prompt_app.py b/backend/apps/prompt_app.py index 6b82a5c82..987729e69 100644 --- a/backend/apps/prompt_app.py +++ b/backend/apps/prompt_app.py @@ -4,19 +4,11 @@ from fastapi import APIRouter, Header, Request from fastapi.responses import JSONResponse, StreamingResponse -from consts.model import ( - GeneratePromptRequest, - OptimizePromptSectionRequest, - OptimizePromptBadCaseRequest, - OptimizePromptFromDebugRequest, -) +from consts.model import GeneratePromptRequest, OptimizePromptSectionRequest from services.prompt_service import ( gen_system_prompt_streamable, - OptimizeRequest, - OptimizeResult, - PromptOptimizationService, + optimize_prompt_section_impl, ) -from adapters.exception import NexentCapabilityError from utils.auth_utils import get_current_user_info router = APIRouter(prefix="/prompt") @@ -56,140 +48,30 @@ async def optimize_prompt_section_api( http_request: Request, authorization: Optional[str] = Header(None) ): - _, tenant_id, language = get_current_user_info( - authorization, http_request) - - service = PromptOptimizationService( - model_id=optimize_request.model_id, - tenant_id=tenant_id, - language=language, - ) - - try: - result = service.optimize( - OptimizeRequest( - agent_id=optimize_request.agent_id, - model_id=optimize_request.model_id, - task_description=optimize_request.task_description, - section_type=optimize_request.section_type, - section_title=optimize_request.section_title, - current_content=optimize_request.current_content, - feedback=optimize_request.feedback, - mode=optimize_request.mode, - start_pos=optimize_request.start_pos, - end_pos=optimize_request.end_pos, - tool_ids=optimize_request.tool_ids, - sub_agent_ids=optimize_request.sub_agent_ids, - knowledge_base_display_names=optimize_request.knowledge_base_display_names, - ) - ) - return JSONResponse( - status_code=HTTPStatus.OK, - content={ - "message": "Success", - "data": { - "optimized_content": result.optimized_content, - "section_type": result.section_type, - "section_title": result.section_title, - "original_content": result.original_content, - } - }, - headers={"X-Prompt-Source": result.source}, - ) - except NexentCapabilityError as e: - return JSONResponse( - status_code=HTTPStatus.BAD_REQUEST, - content={"message": str(e)}, - ) - except Exception as exc: - logger.exception(f"Error occurred while optimizing prompt section: {exc}") - raise - - -@router.post("/optimize/badcase") -async def optimize_prompt_badcase_api( - badcase_request: OptimizePromptBadCaseRequest, - http_request: Request, - authorization: Optional[str] = Header(None) -): - _, tenant_id, language = get_current_user_info( - authorization, http_request) - - service = PromptOptimizationService( - model_id=badcase_request.model_id, - tenant_id=tenant_id, - language=language, - ) - - try: - result = service.optimize_badcase( - current_content=badcase_request.current_content, - bad_cases=badcase_request.bad_cases, - agent_id=badcase_request.agent_id, - section_type=badcase_request.section_type, - section_title=badcase_request.section_title, - tool_ids=badcase_request.tool_ids, - sub_agent_ids=badcase_request.sub_agent_ids, - knowledge_base_display_names=badcase_request.knowledge_base_display_names, - ) - return JSONResponse( - status_code=HTTPStatus.OK, - content={ - "message": "Success", - "data": { - "optimized_content": result.optimized_content, - "section_type": result.section_type, - "section_title": result.section_title, - "original_content": result.original_content, - } - }, - headers={"X-Prompt-Source": result.source}, - ) - except NexentCapabilityError as e: - return JSONResponse( - status_code=HTTPStatus.BAD_REQUEST, - content={"message": str(e)}, - ) - - -@router.post("/optimize/from_debug") -async def optimize_prompt_from_debug_api( - optimize_request: OptimizePromptFromDebugRequest, - http_request: Request, - authorization: Optional[str] = Header(None) -): - _, tenant_id, language = get_current_user_info( - authorization, http_request) - - service = PromptOptimizationService( - model_id=optimize_request.model_id, - tenant_id=tenant_id, - language=language, - ) - try: - result = service.optimize_from_debug( + _, tenant_id, language = get_current_user_info( + authorization, http_request) + optimized_section = optimize_prompt_section_impl( agent_id=optimize_request.agent_id, + model_id=optimize_request.model_id, + task_description=optimize_request.task_description, + tenant_id=tenant_id, + language=language, + section_type=optimize_request.section_type, + section_title=optimize_request.section_title, + current_content=optimize_request.current_content, feedback=optimize_request.feedback, - selected=optimize_request.selected, - history=optimize_request.history, + tool_ids=optimize_request.tool_ids, + sub_agent_ids=optimize_request.sub_agent_ids, + knowledge_base_display_names=optimize_request.knowledge_base_display_names, ) return JSONResponse( status_code=HTTPStatus.OK, content={ - "message": "Success", - "data": { - "original_full_prompt": result.original_content, - "optimized_full_prompt": result.optimized_content, - } - }, - headers={"X-Prompt-Source": result.source}, - ) - except NexentCapabilityError as e: - return JSONResponse( - status_code=HTTPStatus.BAD_REQUEST, - content={"message": str(e)}, + "message": "Prompt section optimized successfully", + "data": optimized_section, + } ) except Exception as exc: - logger.exception(f"Error occurred while optimizing prompt from debug: {exc}") + logger.exception(f"Error occurred while optimizing prompt section: {exc}") raise diff --git a/backend/apps/tool_config_app.py b/backend/apps/tool_config_app.py index bfc8d5ca0..f0b7f9304 100644 --- a/backend/apps/tool_config_app.py +++ b/backend/apps/tool_config_app.py @@ -160,14 +160,12 @@ async def import_openapi_service_api( server_url: Base URL of the REST API server openapi_json: Complete OpenAPI JSON specification service_description: Optional service description - headers_template: Optional default headers template force_update: If True, replace all existing tools for this service """ service_name = openapi_service_request.get("service_name") server_url = openapi_service_request.get("server_url") openapi_json = openapi_service_request.get("openapi_json") service_description = openapi_service_request.get("service_description") - headers_template = openapi_service_request.get("headers_template") force_update = openapi_service_request.get("force_update", False) if not service_name: @@ -194,7 +192,6 @@ async def import_openapi_service_api( tenant_id=tenant_id, user_id=user_id, service_description=service_description, - headers_template=headers_template, force_update=force_update ) diff --git a/backend/apps/user_management_app.py b/backend/apps/user_management_app.py index e79fde887..edbcdf27d 100644 --- a/backend/apps/user_management_app.py +++ b/backend/apps/user_management_app.py @@ -19,13 +19,12 @@ ValidationError, ) from consts.error_code import ErrorCode -from services.cas_service import build_logout_url, CasAuthenticationError from services.user_management_service import get_authorized_client, validate_token, \ check_auth_service_health, signup_user_with_invitation, signin_user, refresh_user_token, \ get_session_by_authorization, get_user_info, create_token, list_tokens_by_user, delete_token, \ update_password from services.user_service import delete_user_and_cleanup -from utils.auth_utils import get_current_user_id, extract_session_id_from_authorization +from utils.auth_utils import get_current_user_id load_dotenv() @@ -151,18 +150,7 @@ async def logout(request: Request): authorization = request.headers.get("Authorization") try: # Make logout idempotent: if no token or token expired, still return success - session_id = None - cas_logout_url = "" if authorization: - session_id = extract_session_id_from_authorization(authorization) - if session_id: - from database.cas_session_db import revoke_cas_session_by_session_id - - revoke_cas_session_by_session_id(session_id, actor="user") - try: - cas_logout_url = build_logout_url() - except CasAuthenticationError as cas_err: - logging.warning(f"CAS logout URL is unavailable: {str(cas_err)}") client = get_authorized_client(authorization) try: client.auth.sign_out() @@ -171,12 +159,7 @@ async def logout(request: Request): logging.warning( f"Sign out encountered an error but will be ignored: {str(signout_err)}") return JSONResponse(status_code=HTTPStatus.OK, - content={ - "message": "Logout successful", - "data": { - "cas_logout_url": cas_logout_url - } - }) + content={"message": "Logout successful"}) except Exception as e: logging.error(f"User logout failed: {str(e)}") @@ -231,10 +214,6 @@ async def get_user_information(request: Request): if not user_info: raise UnauthorizedError("User information not found") - user_info["user"]["auth_provider"] = ( - "cas" if extract_session_id_from_authorization(authorization) else "local" - ) - return JSONResponse(status_code=HTTPStatus.OK, content={"message": "Success", "data": user_info}) diff --git a/backend/apps/vectordatabase_app.py b/backend/apps/vectordatabase_app.py index 505c39559..118537766 100644 --- a/backend/apps/vectordatabase_app.py +++ b/backend/apps/vectordatabase_app.py @@ -76,7 +76,7 @@ def create_new_index( embedding_dim: Optional[int] = Query( None, description="Dimension of the embedding vectors"), request: Dict[str, Any] = Body( - None, description="Request body with optional fields (ingroup_permission, group_ids, embedding_model_name, preserve_source_file)"), + None, description="Request body with optional fields (ingroup_permission, group_ids, embedding_model_name)"), vdb_core: VectorDatabaseCore = Depends(get_vector_db_core), authorization: Optional[str] = Header(None) ): @@ -89,13 +89,11 @@ def create_new_index( group_ids = None embedding_model_name: Optional[str] = None is_multimodal: Optional[bool] = None - preserve_source_file: Optional[bool] = None if request: ingroup_permission = request.get("ingroup_permission") group_ids = request.get("group_ids") embedding_model_name = request.get("embeddingModel") is_multimodal = request.get("is_multimodal") - preserve_source_file = request.get("preserve_source_file") # Treat path parameter as user-facing knowledge base name for new creations return ElasticSearchService.create_knowledge_base( @@ -108,7 +106,6 @@ def create_new_index( group_ids=group_ids, embedding_model_name=embedding_model_name, is_multimodal=is_multimodal, - preserve_source_file=preserve_source_file, ) except Exception as e: raise HTTPException( @@ -508,70 +505,54 @@ async def get_index_files( @router.delete("/{index_name}/documents") -async def delete_documents( +def delete_documents( index_name: str = Path(..., description="Name of the index"), path_or_url: str = Query(..., description="Path or URL of documents to delete"), - scope: str = Query( - "full", - description=( - "source_only: delete MinIO source only, keep ES chunks/vectors; " - "full: delete ES documents, MinIO source, and Redis task records" - ), - ), vdb_core: VectorDatabaseCore = Depends(get_vector_db_core) ): - """Delete a document by scope: source file only or full removal from the index.""" + """Delete documents by path or URL and clean up related Redis records""" try: - result = await ElasticSearchService.delete_document_by_scope( - index_name, path_or_url, scope, vdb_core - ) + # First delete the documents using existing service + result = ElasticSearchService.delete_documents( + index_name, path_or_url, vdb_core) + + # Then clean up Redis records related to this specific document + try: + redis_service = get_redis_service() + redis_cleanup_result = redis_service.delete_document_records( + index_name, path_or_url) + + # Add Redis cleanup info to the result + result["redis_cleanup"] = redis_cleanup_result + + # Update the message to include Redis cleanup info + original_message = result.get( + "message", "Documents deleted successfully") + result["message"] = ( + f"{original_message}. " + f"Cleaned up {redis_cleanup_result['total_deleted']} Redis records " + f"({redis_cleanup_result['celery_tasks_deleted']} tasks, " + f"{redis_cleanup_result['cache_keys_deleted']} cache keys)." + ) - if scope == "full": - try: - redis_service = get_redis_service() - redis_cleanup_result = redis_service.delete_document_records( - index_name, path_or_url - ) - result["redis_cleanup"] = redis_cleanup_result - original_message = result.get( - "message", "Documents deleted successfully" - ) - result["message"] = ( - f"{original_message}. " - f"Cleaned up {redis_cleanup_result['total_deleted']} Redis records " - f"({redis_cleanup_result['celery_tasks_deleted']} tasks, " - f"{redis_cleanup_result['cache_keys_deleted']} cache keys)." - ) - if redis_cleanup_result.get("errors"): - result["redis_warnings"] = redis_cleanup_result["errors"] - except Exception as redis_error: - logger.warning( - "Redis cleanup failed for document %s in index %s: %s", - path_or_url, - index_name, - redis_error, - ) - result["redis_cleanup_error"] = str(redis_error) - original_message = result.get( - "message", "Documents deleted successfully" - ) - result["message"] = ( - f"{original_message}, but Redis cleanup encountered an error: " - f"{str(redis_error)}" - ) + if redis_cleanup_result.get("errors"): + result["redis_warnings"] = redis_cleanup_result["errors"] + + except Exception as redis_error: + logger.warning( + f"Redis cleanup failed for document {path_or_url} in index {index_name}: {str(redis_error)}") + result["redis_cleanup_error"] = str(redis_error) + original_message = result.get( + "message", "Documents deleted successfully") + result[ + "message"] = f"{original_message}, but Redis cleanup encountered an error: {str(redis_error)}" return result - except ValueError as exc: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) - ) except Exception as e: raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Error delete indexing documents: {e}", - ) + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"Error delete indexing documents: {e}") @router.get("/{index_name}/documents/{path_or_url:path}/error-info") diff --git a/backend/consts/const.py b/backend/consts/const.py index 574d550c0..ac2196c2a 100644 --- a/backend/consts/const.py +++ b/backend/consts/const.py @@ -90,31 +90,6 @@ class VectorDatabaseType(str, Enum): OAUTH_CA_BUNDLE = os.getenv("OAUTH_CA_BUNDLE", "") -# CAS SSO Configuration -CAS_ENABLED = os.getenv("CAS_ENABLED", "false").lower() in ("true", "1", "yes", "on") -CAS_SERVER_URL = os.getenv("CAS_SERVER_URL", "").rstrip("/") -CAS_VALIDATE_PATH = os.getenv("CAS_VALIDATE_PATH", "/p3/serviceValidate") -CAS_CALLBACK_BASE_URL = os.getenv("CAS_CALLBACK_BASE_URL", OAUTH_CALLBACK_BASE_URL).rstrip("/") -# CAS login mode: -# - disabled: disable CAS login entry and automatic CAS redirects. -# - button: show CAS as an optional login entry. -# - force: automatically redirect unauthenticated users to CAS login. -CAS_LOGIN_MODE = os.getenv("CAS_LOGIN_MODE", "disabled").lower() -CAS_USER_ATTRIBUTE = os.getenv("CAS_USER_ATTRIBUTE", "") -CAS_EMAIL_ATTRIBUTE = os.getenv("CAS_EMAIL_ATTRIBUTE", "email") -CAS_ROLE_ATTRIBUTE = os.getenv("CAS_ROLE_ATTRIBUTE", "role") -CAS_TENANT_ATTRIBUTE = os.getenv("CAS_TENANT_ATTRIBUTE", "tenant_id") -CAS_ROLE_MAP_JSON = os.getenv("CAS_ROLE_MAP_JSON", "") -CAS_SESSION_MAX_AGE_SECONDS = int(os.getenv("CAS_SESSION_MAX_AGE_SECONDS", "3600") or 3600) -LOCAL_SESSION_MAX_AGE_SECONDS = int(os.getenv("LOCAL_SESSION_MAX_AGE_SECONDS", "3600") or 3600) -CAS_RENEW_BEFORE_SECONDS = int(os.getenv("CAS_RENEW_BEFORE_SECONDS", "300") or 300) -CAS_RENEW_TIMEOUT_SECONDS = int(os.getenv("CAS_RENEW_TIMEOUT_SECONDS", "10") or 10) -CAS_SYNTHETIC_EMAIL_DOMAIN = os.getenv("CAS_SYNTHETIC_EMAIL_DOMAIN", "cas.local") -CAS_LOGOUT_URL = os.getenv("CAS_LOGOUT_URL", "") -CAS_SSL_VERIFY = os.getenv("CAS_SSL_VERIFY", "true").lower() == "true" -CAS_CA_BUNDLE = os.getenv("CAS_CA_BUNDLE", "") - - # ===== To be migrated to frontend configuration ===== # Email Configuration IMAP_SERVER = os.getenv('IMAP_SERVER') @@ -233,7 +208,6 @@ class VectorDatabaseType(str, Enum): "NEXENT_MCP_DOCKER_IMAGE", "nexent/nexent-mcp:latest") ENABLE_UPLOAD_IMAGE = os.getenv( "ENABLE_UPLOAD_IMAGE", "false").lower() == "true" -ENABLE_JIUWEN_SDK = os.getenv("NEXENT_ENABLE_JIUWEN_SDK", "true").lower() == "true" # Celery Configuration @@ -401,47 +375,36 @@ class VectorDatabaseType(str, Enum): OTEL_SERVICE_NAME = OTEL_SERVICE_NAME_RAW or "nexent-backend" OTEL_EXPORTER_OTLP_ENDPOINT_RAW = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT") OTEL_EXPORTER_OTLP_ENDPOINT = OTEL_EXPORTER_OTLP_ENDPOINT_RAW or "http://localhost:4318" -OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = os.getenv( - "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "") -OTEL_EXPORTER_OTLP_METRICS_ENDPOINT = os.getenv( - "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "") +OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "") +OTEL_EXPORTER_OTLP_METRICS_ENDPOINT = os.getenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "") OTEL_EXPORTER_OTLP_PROTOCOL_RAW = os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL") OTEL_EXPORTER_OTLP_PROTOCOL = OTEL_EXPORTER_OTLP_PROTOCOL_RAW or "http" OTEL_EXPORTER_OTLP_HEADERS_RAW = os.getenv("OTEL_EXPORTER_OTLP_HEADERS") OTEL_EXPORTER_OTLP_HEADERS = OTEL_EXPORTER_OTLP_HEADERS_RAW or "" -OTEL_EXPORTER_OTLP_AUTHORIZATION = os.getenv( - "OTEL_EXPORTER_OTLP_AUTHORIZATION", "") +OTEL_EXPORTER_OTLP_AUTHORIZATION = os.getenv("OTEL_EXPORTER_OTLP_AUTHORIZATION", "") OTEL_EXPORTER_OTLP_X_API_KEY = os.getenv("OTEL_EXPORTER_OTLP_X_API_KEY", "") OTEL_EXPORTER_OTLP_LANGFUSE_INGESTION_VERSION = os.getenv( "OTEL_EXPORTER_OTLP_LANGFUSE_INGESTION_VERSION", "") LANGSMITH_API_KEY = os.getenv("LANGSMITH_API_KEY", "") LANGSMITH_PROJECT = os.getenv("LANGSMITH_PROJECT", "") -OTEL_EXPORTER_OTLP_METRICS_ENABLED_RAW = os.getenv( - "OTEL_EXPORTER_OTLP_METRICS_ENABLED") +OTEL_EXPORTER_OTLP_METRICS_ENABLED_RAW = os.getenv("OTEL_EXPORTER_OTLP_METRICS_ENABLED") OTEL_EXPORTER_OTLP_METRICS_ENABLED = ( OTEL_EXPORTER_OTLP_METRICS_ENABLED_RAW or "true").lower() == "true" -MONITORING_INSTRUMENT_REQUESTS_RAW = os.getenv( - "MONITORING_INSTRUMENT_REQUESTS") +MONITORING_INSTRUMENT_REQUESTS_RAW = os.getenv("MONITORING_INSTRUMENT_REQUESTS") MONITORING_INSTRUMENT_REQUESTS = ( MONITORING_INSTRUMENT_REQUESTS_RAW or "false").lower() == "true" -MONITORING_FASTAPI_INCLUDED_URLS = os.getenv( - "MONITORING_FASTAPI_INCLUDED_URLS", "") -MONITORING_FASTAPI_EXCLUDED_URLS = os.getenv( - "MONITORING_FASTAPI_EXCLUDED_URLS", "") -MONITORING_FASTAPI_EXCLUDE_SPANS = os.getenv( - "MONITORING_FASTAPI_EXCLUDE_SPANS", "receive,send") +MONITORING_FASTAPI_INCLUDED_URLS = os.getenv("MONITORING_FASTAPI_INCLUDED_URLS", "") +MONITORING_FASTAPI_EXCLUDED_URLS = os.getenv("MONITORING_FASTAPI_EXCLUDED_URLS", "") +MONITORING_FASTAPI_EXCLUDE_SPANS = os.getenv("MONITORING_FASTAPI_EXCLUDE_SPANS", "receive,send") MONITORING_PROJECT_NAME = os.getenv("MONITORING_PROJECT_NAME", "") MONITORING_DASHBOARD_URL = os.getenv("MONITORING_DASHBOARD_URL", "") -MONITORING_TRACE_CONTENT_MODE = os.getenv( - "MONITORING_TRACE_CONTENT_MODE", "summary") +MONITORING_TRACE_CONTENT_MODE = os.getenv("MONITORING_TRACE_CONTENT_MODE", "summary") MONITORING_TRACE_MAX_CHARS = os.getenv("MONITORING_TRACE_MAX_CHARS", "4000") MONITORING_TRACE_MAX_ITEMS = os.getenv("MONITORING_TRACE_MAX_ITEMS", "20") TELEMETRY_SAMPLE_RATE_RAW = os.getenv("TELEMETRY_SAMPLE_RATE") TELEMETRY_SAMPLE_RATE = float(TELEMETRY_SAMPLE_RATE_RAW or "1.0") # Parse OTLP headers into dict format - - def _parse_otlp_headers(headers_str: str) -> dict: """Parse OTLP headers string into dict. Format: 'key1=value1,key2=value2'""" if not headers_str: @@ -453,7 +416,6 @@ def _parse_otlp_headers(headers_str: str) -> dict: headers[key.strip()] = value.strip() return headers - OTLP_HEADERS = _parse_otlp_headers(OTEL_EXPORTER_OTLP_HEADERS) if OTEL_EXPORTER_OTLP_AUTHORIZATION: OTLP_HEADERS["Authorization"] = OTEL_EXPORTER_OTLP_AUTHORIZATION @@ -486,7 +448,7 @@ def _parse_otlp_headers(headers_str: str) -> dict: # APP Version -APP_VERSION = "v2.2.1" +APP_VERSION = "v2.2.0" # Skill Creation Streaming Configuration diff --git a/backend/consts/model.py b/backend/consts/model.py index 00e5b8a0a..6969999fe 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -1,8 +1,8 @@ from enum import Enum -from typing import Optional, Any, List, Dict, Literal +from typing import Optional, Any, List, Dict from pydantic import BaseModel, Field, EmailStr, ConfigDict, field_validator -from nexent.core.agents.agent_model import AgentVerificationConfig, ToolConfig +from nexent.core.agents.agent_model import ToolConfig from consts.prompt_template import PROMPT_GENERATE_TEMPLATE_FIELD_ALIAS_MAP @@ -230,24 +230,6 @@ class HistoryItem(BaseModel): minio_files: Optional[List[Dict[str, Any]]] = None -class AgentToolParamsRequest(BaseModel): - """Request-scoped tool parameter overrides for a single agent.""" - - tools: Dict[str, Dict[str, Any]] = Field( - default_factory=dict, - description="Mapping from tool identifier to request-scoped override params", - ) - - -class ToolParamsRequest(BaseModel): - """Request-scoped tool parameter overrides for main and managed agents.""" - - agents: Dict[str, AgentToolParamsRequest] = Field( - default_factory=dict, - description="Mapping from agent identifier to tool parameter overrides", - ) - - class AgentRequest(BaseModel): query: str conversation_id: Optional[int] = None @@ -258,7 +240,6 @@ class AgentRequest(BaseModel): model_id: Optional[int] = None version_no: Optional[int] = None is_debug: Optional[bool] = False - tool_params: Optional[ToolParamsRequest] = None class MessageUnit(BaseModel): @@ -433,9 +414,6 @@ class OptimizePromptSectionRequest(BaseModel): section_title: str current_content: str feedback: str - mode: Literal["general", "insert", "select"] = "general" - start_pos: Optional[int] = Field(None, description="Start position for insert/select mode") - end_pos: Optional[int] = Field(None, description="End position for insert/select mode") tool_ids: Optional[List[int]] = Field( None, description="Optional: tool IDs from frontend (takes precedence over database query)") sub_agent_ids: Optional[List[int]] = Field( @@ -444,38 +422,6 @@ class OptimizePromptSectionRequest(BaseModel): None, description="Optional: knowledge base display names from frontend (takes precedence over database query)") -class BadCaseItem(BaseModel): - question: str - answer: str - label: Optional[str] = None - reason: Optional[str] = None - - -class OptimizePromptBadCaseRequest(BaseModel): - agent_id: int - model_id: int - current_content: str - bad_cases: List[BadCaseItem] - section_type: str - section_title: str - tool_ids: Optional[List[int]] = Field(None) - sub_agent_ids: Optional[List[int]] = Field(None) - knowledge_base_display_names: Optional[List[str]] = Field(None) - - -class OptimizeFromDebugSelected(BaseModel): - user_question: str - assistant_answer: str - - -class OptimizePromptFromDebugRequest(BaseModel): - agent_id: int - model_id: int - feedback: str - selected: OptimizeFromDebugSelected - history: Optional[List[HistoryItem]] = None - - class GenerateTitleRequest(BaseModel): conversation_id: int question: str @@ -508,18 +454,8 @@ class AgentInfoRequest(BaseModel): group_ids: Optional[List[int]] = None ingroup_permission: Optional[str] = None enable_context_manager: Optional[bool] = None - verification_config: Optional[Dict[str, Any]] = None - greeting_message: Optional[str] = None - example_questions: Optional[List[str]] = None version_no: int = 0 - @field_validator("verification_config", mode="before") - @classmethod - def normalize_verification_config(cls, value): - if value is None: - return None - return AgentVerificationConfig.model_validate(value).model_dump() - class AgentIDRequest(BaseModel): agent_id: int @@ -584,7 +520,6 @@ class MessageIdRequest(BaseModel): class ExportAndImportAgentInfo(BaseModel): agent_id: int - tenant_id: Optional[str] = None name: str display_name: Optional[str] = None description: str @@ -592,7 +527,6 @@ class ExportAndImportAgentInfo(BaseModel): author: Optional[str] = None max_steps: int provide_run_summary: bool - verification_config: Optional[Dict[str, Any]] = None duty_prompt: Optional[str] = None constraint_prompt: Optional[str] = None few_shots_prompt: Optional[str] = None @@ -622,11 +556,6 @@ class ExportAndImportDataFormat(BaseModel): mcp_info: List[MCPInfo] -class AgentRepositorySnapshot(ExportAndImportDataFormat): - """Frozen marketplace snapshot: export format plus optional skill ZIP payloads.""" - skills: Optional[List["SkillZipEntry"]] = None - - class SkillZipEntry(BaseModel): """A skill bundled inside an agent export ZIP.""" skill_name: str diff --git a/backend/data_process/tasks.py b/backend/data_process/tasks.py index 4dd6edd69..f2a30f9b7 100644 --- a/backend/data_process/tasks.py +++ b/backend/data_process/tasks.py @@ -8,11 +8,9 @@ import os import threading import time -from dataclasses import dataclass from typing import Any, Dict, Optional, List, Tuple import aiohttp -import requests import re import ray from celery import Task, chain, states, group, chord @@ -21,7 +19,6 @@ from utils.file_management_utils import get_file_size from database.attachment_db import get_file_stream -from database.knowledge_db import get_knowledge_record from services.redis_service import get_redis_service from .app import app from .ray_actors import DataProcessorRayActor @@ -46,12 +43,10 @@ logger = logging.getLogger("data_process.tasks") -ASYNC_SPLIT_RETRY_MAX = max( - FORWARD_REDIS_RETRY_MAX * 5, FORWARD_REDIS_RETRY_MAX) +ASYNC_SPLIT_RETRY_MAX = max(FORWARD_REDIS_RETRY_MAX * 5, FORWARD_REDIS_RETRY_MAX) FORWARD_ES_CHUNK_BATCH_SIZE = 64 IMAGE_METADATA_PROCESS_SOURCE = "UniversalImageExtractor" - def _wait_for_split_ready(redis_key: str, timeout_s: int, poll_interval_ms: int) -> int: """ Wait until async split aggregation is marked ready in Redis. @@ -96,8 +91,7 @@ def _estimate_parallel_parts() -> int: def _compute_split_wait_timeout(parts_count: int) -> int: base_timeout = DP_REDIS_CHUNKS_WAIT_TIMEOUT_S waves = math.ceil(max(1, parts_count) / _estimate_parallel_parts()) - dynamic_timeout = base_timeout + \ - max(0, waves - 1) * max(1, PER_WAVE_TIMEOUT) + dynamic_timeout = base_timeout + max(0, waves - 1) * max(1, PER_WAVE_TIMEOUT) return min(MAX_TIMEOUT, max(base_timeout, dynamic_timeout)) @@ -184,6 +178,7 @@ def _build_balanced_batches( return batches + # Thread lock for initializing Ray to prevent race conditions ray_init_lock = threading.Lock() @@ -332,35 +327,6 @@ def run_in_thread(): raise -def _delete_source_file_via_http_sync( - *, - base_url: str, - index_name: str, - path_or_url: str, - scope: str, - timeout_s: float = 30.0, -) -> Dict[str, Any]: - base = (base_url or "").rstrip("/") - if not base: - raise RuntimeError("ELASTICSEARCH_SERVICE is not configured") - url = f"{base}/indices/{index_name}/documents" - params = {"path_or_url": path_or_url, "scope": scope} - - resp = requests.delete(url, params=params, timeout=timeout_s) - body_text = getattr(resp, "text", "") - parsed = None - try: - parsed = resp.json() - except Exception: - parsed = _parse_json_or_none(body_text) if body_text else None - - return { - "http_status": getattr(resp, "status_code", None), - "response_json": parsed if isinstance(parsed, dict) else None, - "response_text": body_text if not isinstance(parsed, dict) else None, - } - - def _build_forward_error( message: str, index_name: str, @@ -384,206 +350,6 @@ def _parse_json_or_none(text: str) -> Optional[Dict[str, Any]]: return None -@dataclass(frozen=True) -class _ForwardContext: - task_id: str - request_id: str - start_time: float - source: str - index_name: str - source_type: str - original_filename: Optional[str] - - -def _init_forward_context( - *, - task_id: str, - request_id: str, - start_time: float, - source: str, - index_name: str, - source_type: str, - original_filename: Optional[str], -) -> _ForwardContext: - return _ForwardContext( - task_id=task_id, - request_id=request_id, - start_time=start_time, - source=source, - index_name=index_name, - source_type=source_type, - original_filename=original_filename, - ) - - -def _is_forward_task_cancelled(ctx: _ForwardContext) -> bool: - try: - redis_service = get_redis_service() - return bool(redis_service.is_task_cancelled(ctx.task_id)) - except Exception as exc: - logger.warning( - f"[{ctx.request_id}] FORWARD TASK: Failed to check cancellation flag for task {ctx.task_id}: " - f"{exc}" - ) - return False - - -def _build_forward_cancelled_result(ctx: _ForwardContext) -> Dict[str, Any]: - return { - 'task_id': ctx.task_id, - 'source': ctx.source, - 'index_name': ctx.index_name, - 'original_filename': ctx.original_filename, - 'chunks_stored': 0, - 'storage_time': 0, - 'es_result': { - "success": False, - "message": "Indexing cancelled because document was deleted.", - "total_indexed": 0, - "total_submitted": 0, - }, - } - - -def _load_forward_chunks( - self: Task, - *, - processed_data: Dict[str, Any], - original_source: str, - original_index_name: str, - filename: Optional[str], -) -> Tuple[Optional[List[Dict[str, Any]]], bool, str, str, Optional[str]]: - chunks = processed_data.get('chunks') - split_async = bool(processed_data.get('split_async')) - - # If chunks are not in payload, try loading from Redis via the redis_key - if (not chunks) and processed_data.get('redis_key'): - redis_key = processed_data.get('redis_key') - if not REDIS_BACKEND_URL: - raise Exception(json.dumps({ - "message": "REDIS_BACKEND_URL not configured to retrieve chunks", - "index_name": original_index_name, - "task_name": "forward", - "source": original_source, - "original_filename": filename - }, ensure_ascii=False)) - try: - import redis - client = redis.Redis.from_url( - REDIS_BACKEND_URL, decode_responses=True) - ready_key = f"{redis_key}:ready" - if split_async: - ready_flag = client.get(ready_key) - if not ready_flag: - retry_num = getattr(self.request, 'retries', 0) - logger.info( - f"[{self.request.id}] FORWARD TASK: Async split not ready for key {redis_key}. Retry {retry_num + 1}/{ASYNC_SPLIT_RETRY_MAX} in {FORWARD_REDIS_RETRY_DELAY_S}s") - raise self.retry( - countdown=FORWARD_REDIS_RETRY_DELAY_S, - max_retries=ASYNC_SPLIT_RETRY_MAX, - exc=Exception(json.dumps({ - "message": "Async split not ready; will retry", - "index_name": original_index_name, - "task_name": "forward", - "source": original_source, - "original_filename": filename - }, ensure_ascii=False)) - ) - cached = client.get(redis_key) - if cached: - try: - logger.debug( - f"[{self.request.id}] FORWARD TASK: Retrieved Redis key '{redis_key}', payload_length={len(cached)}") - chunks = json.loads(cached) - except json.JSONDecodeError as jde: - # Log raw prefix to help diagnose incorrect writes - raw_preview = cached[:120] if isinstance( - cached, str) else str(type(cached)) - logger.error( - f"[{self.request.id}] FORWARD TASK: JSON decode error for key '{redis_key}': {str(jde)}; raw_prefix={raw_preview!r}") - raise - else: - if split_async: - retry_num = getattr(self.request, 'retries', 0) - logger.info( - f"[{self.request.id}] FORWARD TASK: Async split ready but chunks missing for key {redis_key}. Retry {retry_num + 1}/{ASYNC_SPLIT_RETRY_MAX} in {FORWARD_REDIS_RETRY_DELAY_S}s") - raise self.retry( - countdown=FORWARD_REDIS_RETRY_DELAY_S, - max_retries=ASYNC_SPLIT_RETRY_MAX, - exc=Exception(json.dumps({ - "message": "Async split ready but chunks missing; will retry", - "index_name": original_index_name, - "task_name": "forward", - "source": original_source, - "original_filename": filename - }, ensure_ascii=False)) - ) - # No busy-wait: release the worker slot and retry later - retry_num = getattr(self.request, 'retries', 0) - logger.info( - f"[{self.request.id}] FORWARD TASK: Chunks not yet available for key {redis_key}. Retry {retry_num + 1}/{FORWARD_REDIS_RETRY_MAX} in {FORWARD_REDIS_RETRY_DELAY_S}s") - raise self.retry( - countdown=FORWARD_REDIS_RETRY_DELAY_S, - max_retries=FORWARD_REDIS_RETRY_MAX, - exc=Exception(json.dumps({ - "message": "Chunks not ready in Redis; will retry", - "index_name": original_index_name, - "task_name": "forward", - "source": original_source, - "original_filename": filename - }, ensure_ascii=False)) - ) - except Retry: - raise - except Exception as exc: - raise Exception(json.dumps({ - "message": f"Failed to retrieve chunks from Redis: {str(exc)}", - "index_name": original_index_name, - "task_name": "forward", - "source": original_source, - "original_filename": filename - }, ensure_ascii=False)) - - if processed_data.get('source'): - original_source = processed_data.get('source') - if processed_data.get('index_name'): - original_index_name = processed_data.get('index_name') - if processed_data.get('original_filename'): - filename = processed_data.get('original_filename') - - logger.info( - f"[{self.request.id}] FORWARD TASK: Received data for source '{original_source}' with {len(chunks) if chunks else 'None'} chunks") - - if chunks is None: - raise Exception(json.dumps({ - "message": "No chunks received for forwarding", - "index_name": original_index_name, - "task_name": "forward", - "source": original_source, - "original_filename": filename - }, ensure_ascii=False)) - if len(chunks) == 0: - if split_async and processed_data.get('redis_key'): - retry_num = getattr(self.request, 'retries', 0) - logger.info( - f"[{self.request.id}] FORWARD TASK: Empty chunks while waiting for async split. Retry {retry_num + 1}/{ASYNC_SPLIT_RETRY_MAX} in {FORWARD_REDIS_RETRY_DELAY_S}s") - raise self.retry( - countdown=FORWARD_REDIS_RETRY_DELAY_S, - max_retries=ASYNC_SPLIT_RETRY_MAX, - exc=Exception(json.dumps({ - "message": "Chunks not ready in Redis (empty); will retry", - "index_name": original_index_name, - "task_name": "forward", - "source": original_source, - "original_filename": filename - }, ensure_ascii=False)) - ) - logger.warning( - f"[{self.request.id}] FORWARD TASK: Empty chunks list received for source {original_source}") - - return chunks, split_async, original_source, original_index_name, filename - - def _extract_error_code_from_es_response( parsed_body: Optional[Dict[str, Any]], text: str, @@ -638,7 +404,7 @@ async def _post(): try: connector = aiohttp.TCPConnector(verify_ssl=False) timeout = aiohttp.ClientTimeout(total=600) - + request_params: Dict[str, str] = {} if large_mode: @@ -657,8 +423,7 @@ async def _post(): parsed_body = _parse_json_or_none(text) if status >= 400: - error_code = _extract_error_code_from_es_response( - parsed_body, text) + error_code = _extract_error_code_from_es_response(parsed_body, text) if error_code: raise Exception(json.dumps({ "error_code": error_code @@ -743,8 +508,7 @@ def get_actor(self) -> Any: if not self.actors: actor = self._create_and_warm_actor() if actor is None: - raise RuntimeError( - "Global actor pool is empty and actor warm-up failed") + raise RuntimeError("Global actor pool is empty and actor warm-up failed") self.actors.append(actor) idx = self.rr_index % len(self.actors) self.rr_index += 1 @@ -788,12 +552,10 @@ def prewarm_ray_actors(target_size: Optional[int] = None) -> int: """ Ensure a global shared pool of warm Ray actors exists for low-latency task execution. """ - desired = RAY_GLOBAL_ACTOR_POOL_SIZE if target_size is None else max( - 0, int(target_size)) + desired = RAY_GLOBAL_ACTOR_POOL_SIZE if target_size is None else max(0, int(target_size)) manager = _get_or_create_global_pool_manager() current_after = ray.get( - manager.ensure_pool.remote( - desired=desired, max_allowed=_estimate_parallel_parts()) + manager.ensure_pool.remote(desired=desired, max_allowed=_estimate_parallel_parts()) ) logger.info( f"Global Ray actor pool ready: current={current_after}, desired={desired}" @@ -816,7 +578,6 @@ def _get_split_actor() -> Any: """ return get_ray_actor() - class LoggingTask(Task): """Base task class with enhanced logging""" @@ -884,8 +645,7 @@ def process_part( "chunks_count": len(chunks), } except Exception as e: - logger.error( - f"[process_part] Failed to process part for '{filename}': {str(e)}") + logger.error(f"[process_part] Failed to process part for '{filename}': {str(e)}") return { "part_redis_key": part_redis_key, "chunks_count": 0, @@ -1399,8 +1159,7 @@ def process( fetch_start = time.perf_counter() file_stream = get_file_stream(source) if file_stream is None: - raise FileNotFoundError( - f"Unable to fetch file from URL: {source}") + raise FileNotFoundError(f"Unable to fetch file from URL: {source}") file_data = file_stream.read() fetch_elapsed = time.perf_counter() - fetch_start logger.info( @@ -1449,8 +1208,7 @@ def process( if cached: cached_chunks = json.loads(cached) if isinstance(cached_chunks, list): - image_metadata_chunk_count = _count_image_metadata_chunks( - cached_chunks) + image_metadata_chunk_count = _count_image_metadata_chunks(cached_chunks) except Exception as image_count_exc: logger.warning( f"[{self.request.id}] PROCESS TASK: Failed counting image metadata chunks for async split: {image_count_exc}") @@ -1474,17 +1232,17 @@ def process( self.update_state( state=states.SUCCESS, meta={ - 'chunks_count': chunk_count, - 'processing_time': elapsed_time, - 'source': source, - 'index_name': index_name, - 'original_filename': original_filename, - 'task_name': 'process', - 'stage': 'text_extracted', - 'file_size_mb': file_size_mb, - 'processing_speed_mb_s': file_size_mb / elapsed_time if file_size_mb > 0 and elapsed_time > 0 else 0 - } - ) + 'chunks_count': chunk_count, + 'processing_time': elapsed_time, + 'source': source, + 'index_name': index_name, + 'original_filename': original_filename, + 'task_name': 'process', + 'stage': 'text_extracted', + 'file_size_mb': file_size_mb, + 'processing_speed_mb_s': file_size_mb / elapsed_time if file_size_mb > 0 and elapsed_time > 0 else 0 + } + ) logger.info( f"[{self.request.id}] PROCESS TASK: Processing complete, waiting for forward task") @@ -1650,34 +1408,165 @@ def forward( filename = original_filename try: - ctx = _init_forward_context( - task_id=task_id, - request_id=str(self.request.id), - start_time=start_time, - source=source, - index_name=index_name, - source_type=source_type, - original_filename=original_filename, - ) - - # Before doing any heavy work, check whether this task has been explicitly cancelled. - if _is_forward_task_cancelled(ctx): - logger.info( - f"[{self.request.id}] FORWARD TASK: Detected cancellation flag for task {task_id}; " - f"skipping chunk forwarding for source '{source}' in index '{index_name}'." + # Before doing any heavy work, check whether this task has been + # explicitly cancelled (for example, because the user deleted the + # document from the knowledge base configuration page). + try: + redis_service = get_redis_service() + if redis_service.is_task_cancelled(task_id): + logger.info( + f"[{self.request.id}] FORWARD TASK: Detected cancellation flag for task {task_id}; " + f"skipping chunk forwarding for source '{source}' in index '{index_name}'." + ) + # Treat this as a graceful early exit. We still return a + # structured payload so callers can consider the task done. + return { + 'task_id': task_id, + 'source': source, + 'index_name': index_name, + 'original_filename': original_filename, + 'chunks_stored': 0, + 'storage_time': 0, + 'es_result': { + "success": False, + "message": "Indexing cancelled because document was deleted.", + "total_indexed": 0, + "total_submitted": 0, + }, + } + except Exception as cancel_check_exc: + logger.warning( + f"[{self.request.id}] FORWARD TASK: Failed to check cancellation flag for task {task_id}: " + f"{cancel_check_exc}" ) - return _build_forward_cancelled_result(ctx) - - chunks, split_async, original_source, original_index_name, filename = _load_forward_chunks( - self, - processed_data=processed_data, - original_source=original_source, - original_index_name=original_index_name, - filename=filename, - ) + + chunks = processed_data.get('chunks') + split_async = bool(processed_data.get('split_async')) + # If chunks are not in payload, try loading from Redis via the redis_key + if (not chunks) and processed_data.get('redis_key'): + redis_key = processed_data.get('redis_key') + if not REDIS_BACKEND_URL: + raise Exception(json.dumps({ + "message": "REDIS_BACKEND_URL not configured to retrieve chunks", + "index_name": original_index_name, + "task_name": "forward", + "source": original_source, + "original_filename": filename + }, ensure_ascii=False)) + try: + import redis + client = redis.Redis.from_url( + REDIS_BACKEND_URL, decode_responses=True) + ready_key = f"{redis_key}:ready" + if split_async: + ready_flag = client.get(ready_key) + if not ready_flag: + retry_num = getattr(self.request, 'retries', 0) + logger.info( + f"[{self.request.id}] FORWARD TASK: Async split not ready for key {redis_key}. Retry {retry_num + 1}/{ASYNC_SPLIT_RETRY_MAX} in {FORWARD_REDIS_RETRY_DELAY_S}s") + raise self.retry( + countdown=FORWARD_REDIS_RETRY_DELAY_S, + max_retries=ASYNC_SPLIT_RETRY_MAX, + exc=Exception(json.dumps({ + "message": "Async split not ready; will retry", + "index_name": original_index_name, + "task_name": "forward", + "source": original_source, + "original_filename": filename + }, ensure_ascii=False)) + ) + cached = client.get(redis_key) + if cached: + try: + logger.debug( + f"[{self.request.id}] FORWARD TASK: Retrieved Redis key '{redis_key}', payload_length={len(cached)}") + chunks = json.loads(cached) + except json.JSONDecodeError as jde: + # Log raw prefix to help diagnose incorrect writes + raw_preview = cached[:120] if isinstance( + cached, str) else str(type(cached)) + logger.error( + f"[{self.request.id}] FORWARD TASK: JSON decode error for key '{redis_key}': {str(jde)}; raw_prefix={raw_preview!r}") + raise + else: + if split_async: + retry_num = getattr(self.request, 'retries', 0) + logger.info( + f"[{self.request.id}] FORWARD TASK: Async split ready but chunks missing for key {redis_key}. Retry {retry_num + 1}/{ASYNC_SPLIT_RETRY_MAX} in {FORWARD_REDIS_RETRY_DELAY_S}s") + raise self.retry( + countdown=FORWARD_REDIS_RETRY_DELAY_S, + max_retries=ASYNC_SPLIT_RETRY_MAX, + exc=Exception(json.dumps({ + "message": "Async split ready but chunks missing; will retry", + "index_name": original_index_name, + "task_name": "forward", + "source": original_source, + "original_filename": filename + }, ensure_ascii=False)) + ) + # No busy-wait: release the worker slot and retry later + retry_num = getattr(self.request, 'retries', 0) + logger.info( + f"[{self.request.id}] FORWARD TASK: Chunks not yet available for key {redis_key}. Retry {retry_num + 1}/{FORWARD_REDIS_RETRY_MAX} in {FORWARD_REDIS_RETRY_DELAY_S}s") + raise self.retry( + countdown=FORWARD_REDIS_RETRY_DELAY_S, + max_retries=FORWARD_REDIS_RETRY_MAX, + exc=Exception(json.dumps({ + "message": "Chunks not ready in Redis; will retry", + "index_name": original_index_name, + "task_name": "forward", + "source": original_source, + "original_filename": filename + }, ensure_ascii=False)) + ) + except Retry: + raise + except Exception as exc: + raise Exception(json.dumps({ + "message": f"Failed to retrieve chunks from Redis: {str(exc)}", + "index_name": original_index_name, + "task_name": "forward", + "source": original_source, + "original_filename": filename + }, ensure_ascii=False)) + if processed_data.get('source'): + original_source = processed_data.get('source') + if processed_data.get('index_name'): + original_index_name = processed_data.get('index_name') + if processed_data.get('original_filename'): + filename = processed_data.get('original_filename') + logger.info( + f"[{self.request.id}] FORWARD TASK: Received data for source '{original_source}' with {len(chunks) if chunks else 'None'} chunks") # Calculate total chunks for progress tracking total_chunks = len(chunks) if chunks else 0 + + if chunks is None: + raise Exception(json.dumps({ + "message": "No chunks received for forwarding", + "index_name": original_index_name, + "task_name": "forward", + "source": original_source, + "original_filename": original_filename + }, ensure_ascii=False)) + if len(chunks) == 0: + if split_async and processed_data.get('redis_key'): + retry_num = getattr(self.request, 'retries', 0) + logger.info( + f"[{self.request.id}] FORWARD TASK: Empty chunks while waiting for async split. Retry {retry_num + 1}/{ASYNC_SPLIT_RETRY_MAX} in {FORWARD_REDIS_RETRY_DELAY_S}s") + raise self.retry( + countdown=FORWARD_REDIS_RETRY_DELAY_S, + max_retries=ASYNC_SPLIT_RETRY_MAX, + exc=Exception(json.dumps({ + "message": "Chunks not ready in Redis (empty); will retry", + "index_name": original_index_name, + "task_name": "forward", + "source": original_source, + "original_filename": filename + }, ensure_ascii=False)) + ) + logger.warning( + f"[{self.request.id}] FORWARD TASK: Empty chunks list received for source {original_source}") formatted_chunks = [] # Compute once per file to avoid repeated IO/MinIO calls inside loop file_size = get_file_size(source_type, original_source) if isinstance( @@ -1868,7 +1757,6 @@ def forward( logger.info( f"[{self.request.id}] FORWARD TASK: Successfully stored {len(chunks)} chunks to index {original_index_name} in {end_time - start_time:.2f}s") - return { 'task_id': task_id, 'source': original_source, @@ -1951,106 +1839,9 @@ def forward( raise -@app.task( - bind=True, - base=LoggingTask, - name="data_process.tasks.cleanup_source", - queue="forward_q", -) -def cleanup_source(self, forward_result: Dict[str, Any]) -> Dict[str, Any]: - """ - Conditionally delete the MinIO source file after successful indexing. - - If the knowledge base is configured with preserve_source_file=false, call: - DELETE /indices/{index_name}/documents?path_or_url=...&scope=source_only - """ - index_name = (forward_result or {}).get("index_name") - source = (forward_result or {}).get("source") - - cleanup_info: Dict[str, Any] = { - "attempted": False, - "skipped_reason": None, - "success": None, - "http_status": None, - "response": None, - "error": None, - } - - if not index_name or not source: - cleanup_info["skipped_reason"] = "missing_index_name_or_source" - forward_result = dict(forward_result or {}) - forward_result["source_cleanup"] = cleanup_info - return forward_result - - try: - record = get_knowledge_record({"index_name": index_name}) or {} - preserve_source_file = record.get("preserve_source_file", True) - except Exception as exc: - logger.warning( - "[%s] CLEANUP TASK: Failed to load knowledge config for index '%s': %s", - getattr(self.request, "id", "unknown"), - index_name, - exc, - ) - cleanup_info["skipped_reason"] = "knowledge_record_lookup_failed" - forward_result = dict(forward_result or {}) - forward_result["source_cleanup"] = cleanup_info - return forward_result - - if preserve_source_file: - cleanup_info["skipped_reason"] = "preserve_source_file_true" - forward_result = dict(forward_result or {}) - forward_result["source_cleanup"] = cleanup_info - return forward_result - - cleanup_info["attempted"] = True - try: - resp = _delete_source_file_via_http_sync( - base_url=ELASTICSEARCH_SERVICE, - index_name=index_name, - path_or_url=source, - scope="source_only", - ) - cleanup_info["http_status"] = resp.get("http_status") - cleanup_info["response"] = ( - resp.get("response_json") - if resp.get("response_json") is not None - else resp.get("response_text") - ) - - ok = False - if isinstance(resp.get("response_json"), dict): - ok = bool(resp["response_json"].get("status") == "success") - elif resp.get("http_status") and 200 <= int(resp["http_status"]) < 300: - ok = True - - cleanup_info["success"] = ok - if not ok: - logger.warning( - "[%s] CLEANUP TASK: Source-only delete did not succeed. index='%s' source='%s' http_status=%s", - getattr(self.request, "id", "unknown"), - index_name, - source, - cleanup_info["http_status"], - ) - except Exception as exc: - cleanup_info["success"] = False - cleanup_info["error"] = str(exc) - logger.warning( - "[%s] CLEANUP TASK: Source-only delete failed. index='%s' source='%s' error=%s", - getattr(self.request, "id", "unknown"), - index_name, - source, - exc, - ) - - forward_result = dict(forward_result or {}) - forward_result["source_cleanup"] = cleanup_info - return forward_result - - -def submit_process_forward_chain( - *, +@app.task(bind=True, base=LoggingTask, name='data_process.tasks.process_and_forward') +def process_and_forward( + self, source: str, source_type: str, chunking_strategy: str, @@ -2058,14 +1849,30 @@ def submit_process_forward_chain( original_filename: Optional[str] = None, authorization: Optional[str] = None, embedding_model_id: Optional[int] = None, - tenant_id: Optional[str] = None, + tenant_id: Optional[str] = None ) -> str: """ - Build and enqueue a Celery chain: process -> forward. + Combined task that chains processing and forwarding + + This task delegates to a chain of process -> forward + + Args: + source: Source file path, URL, or text content + source_type: source of the file("local", "minio") + chunking_strategy: Strategy for chunking the document + index_name: Name of the index to store documents + original_filename: The original name of the file + authorization: Authorization header for API calls + embedding_model_id: Embedding model ID for chunk size configuration + tenant_id: Tenant ID for retrieving model configuration Returns: - Celery chain task ID, or empty string if enqueue failed. + Task ID of the chain """ + logger.info( + f"Starting processing chain for {source}, original_filename={original_filename}, strategy={chunking_strategy}, index={index_name}, model_id={embedding_model_id}") + + # Create a task chain task_chain = chain( process.s( source=source, @@ -2082,64 +1889,18 @@ def submit_process_forward_chain( source_type=source_type, original_filename=original_filename, authorization=authorization - ).set(queue='forward_q'), - cleanup_source.s().set(queue='forward_q'), + ).set(queue='forward_q') ) + # Execute the chain result = task_chain.apply_async() if result is None or not hasattr(result, 'id') or result.id is None: logger.error( "Celery chain apply_async() did not return a valid result or result.id") return "" - return result.id - + logger.info(f"Created task chain ID: {result.id}") -@app.task(bind=True, base=LoggingTask, name='data_process.tasks.process_and_forward') -def process_and_forward( - self, - source: str, - source_type: str, - chunking_strategy: str, - index_name: Optional[str] = None, - original_filename: Optional[str] = None, - authorization: Optional[str] = None, - embedding_model_id: Optional[int] = None, - tenant_id: Optional[str] = None -) -> str: - """ - Combined task that chains processing and forwarding - - This task delegates to a chain of process -> forward - - Args: - source: Source file path, URL, or text content - source_type: source of the file("local", "minio") - chunking_strategy: Strategy for chunking the document - index_name: Name of the index to store documents - original_filename: The original name of the file - authorization: Authorization header for API calls - embedding_model_id: Embedding model ID for chunk size configuration - tenant_id: Tenant ID for retrieving model configuration - - Returns: - Task ID of the chain - """ - logger.info( - f"Starting processing chain for {source}, original_filename={original_filename}, strategy={chunking_strategy}, index={index_name}, model_id={embedding_model_id}") - - chain_id = submit_process_forward_chain( - source=source, - source_type=source_type, - chunking_strategy=chunking_strategy, - index_name=index_name, - original_filename=original_filename, - authorization=authorization, - embedding_model_id=embedding_model_id, - tenant_id=tenant_id, - ) - if chain_id: - logger.info(f"Created task chain ID: {chain_id}") - return chain_id + return result.id @app.task(bind=True, base=LoggingTask, name='data_process.tasks.process_sync') diff --git a/backend/database/agent_db.py b/backend/database/agent_db.py index 533659b0f..82696ffab 100644 --- a/backend/database/agent_db.py +++ b/backend/database/agent_db.py @@ -1,10 +1,9 @@ import logging -from typing import List, Optional +from typing import List from sqlalchemy import or_, update from database.client import get_db_session, as_dict, filter_property from database.db_models import AgentInfo, ToolInstance, AgentRelation -from database.agent_version_db import query_current_version_no from consts.const import ASSET_OWNER_TENANT_ID from utils.str_utils import convert_list_to_string @@ -103,40 +102,6 @@ def query_sub_agents_id_list(main_agent_id: int, tenant_id: str, version_no: int return [relation.selected_agent_id for relation in relations] -def query_sub_agent_relations(main_agent_id: int, tenant_id: str, version_no: int = 0) -> List[dict]: - """ - Query sub-agent relations by main agent id, including pinned version info. - Default version_no=0 queries the draft version. - - Args: - main_agent_id: Parent agent ID - tenant_id: Tenant ID - version_no: Version number to filter. Default 0 = draft/editing state - """ - with get_db_session() as session: - query = session.query(AgentRelation).filter( - AgentRelation.parent_agent_id == main_agent_id, - AgentRelation.tenant_id == tenant_id, - AgentRelation.version_no == version_no, - AgentRelation.delete_flag != 'Y') - relations = query.all() - return [as_dict(relation) for relation in relations] - - -def resolve_sub_agent_version_no( - selected_agent_id: int, - selected_agent_version_no: Optional[int], - tenant_id: str, -) -> int: - """ - Resolve the effective version number for a sub-agent relation. - Uses pinned version when set; otherwise falls back to child's current published version. - """ - if selected_agent_version_no is not None: - return selected_agent_version_no - return query_current_version_no(agent_id=selected_agent_id, tenant_id=tenant_id) or 0 - - def clear_agent_new_mark(agent_id: int, tenant_id: str, user_id: str, version_no: int = 0): """ Clear the NEW mark for an agent. @@ -198,7 +163,6 @@ def create_agent(agent_info, tenant_id: str, user_id: str): """ info_with_metadata = dict(agent_info) info_with_metadata.setdefault("max_steps", 15) - info_with_metadata.setdefault("verification_config", None) info_with_metadata.update({ "tenant_id": tenant_id, "version_no": 0, # Default to draft version @@ -237,9 +201,6 @@ def create_agent(agent_info, tenant_id: str, user_id: str): "group_ids": new_agent.group_ids, "is_new": new_agent.is_new, "enable_context_manager": new_agent.enable_context_manager, - "verification_config": new_agent.verification_config, - "greeting_message": new_agent.greeting_message, - "example_questions": new_agent.example_questions, "current_version_no": new_agent.current_version_no, "version_no": new_agent.version_no, "created_by": new_agent.created_by, diff --git a/backend/database/agent_repository_db.py b/backend/database/agent_repository_db.py deleted file mode 100644 index a6bb4f48b..000000000 --- a/backend/database/agent_repository_db.py +++ /dev/null @@ -1,358 +0,0 @@ -import logging -import math -from typing import Any, Dict, List, Optional - -from sqlalchemy import func, or_, update - -from database.client import as_dict, filter_property, get_db_session -from database.db_models import AgentRepository - -logger = logging.getLogger("agent_repository_db") - -# Listing status: NOT_SHARED (未共享), PENDING_REVIEW (待审核), -# REJECTED (审核驳回), SHARED (已共享) -STATUS_NOT_SHARED = "NOT_SHARED" -STATUS_PENDING_REVIEW = "PENDING_REVIEW" -STATUS_REJECTED = "REJECTED" -STATUS_SHARED = "SHARED" - -VALID_REPOSITORY_STATUSES = frozenset({ - STATUS_NOT_SHARED, - STATUS_PENDING_REVIEW, - STATUS_REJECTED, - STATUS_SHARED, -}) - -_UPSERT_IMMUTABLE_FIELDS = frozenset({ - "agent_id", - "agent_repository_id", - "publisher_tenant_id", -}) - -_UPSERT_SNAPSHOT_FIELDS = frozenset({ - "source_version_no", - "name", - "display_name", - "description", - "author", - "category_id", - "tags", - "tool_count", - "version_label", - "agent_info_json", -}) - - -def insert_agent_repository_record( - repository_data: Dict[str, Any], - publisher_tenant_id: str, - publisher_user_id: str, -) -> int: - """Insert a new agent repository listing record.""" - with get_db_session() as session: - payload = { - **repository_data, - "publisher_tenant_id": publisher_tenant_id, - "publisher_user_id": publisher_user_id, - "created_by": publisher_user_id, - "updated_by": publisher_user_id, - "delete_flag": "N", - } - if payload.get("status") is None: - payload["status"] = STATUS_NOT_SHARED - - new_record = AgentRepository( - **filter_property(payload, AgentRepository) - ) - session.add(new_record) - session.flush() - return int(new_record.agent_repository_id) - - -def get_agent_repository_by_id(repository_id: int) -> Optional[dict]: - """Fetch a repository listing by primary key.""" - with get_db_session() as session: - record = session.query(AgentRepository).filter( - AgentRepository.agent_repository_id == repository_id, - AgentRepository.delete_flag != "Y", - ).first() - return as_dict(record) if record else None - - -def get_agent_repository_by_id_and_publisher( - repository_id: int, - publisher_tenant_id: str, -) -> Optional[dict]: - """Fetch a repository listing scoped to the publisher tenant.""" - with get_db_session() as session: - record = session.query(AgentRepository).filter( - AgentRepository.agent_repository_id == repository_id, - AgentRepository.publisher_tenant_id == publisher_tenant_id, - AgentRepository.delete_flag != "Y", - ).first() - return as_dict(record) if record else None - - -def get_agent_repository_by_agent_id(agent_id: int) -> Optional[dict]: - """Fetch an active repository listing by root agent_id.""" - with get_db_session() as session: - record = session.query(AgentRepository).filter( - AgentRepository.agent_id == agent_id, - AgentRepository.delete_flag != "Y", - ).first() - return as_dict(record) if record else None - - -def upsert_agent_repository_record( - repository_data: Dict[str, Any], - publisher_tenant_id: str, - publisher_user_id: str, -) -> tuple[int, bool]: - """Insert or update a repository listing keyed by agent_id. - - When no record exists, inserts a new listing. When a record exists: - - Same source_version_no: updates status (and updated_by) only. - - Different source_version_no: updates all snapshot fields, preserving - agent_id, agent_repository_id, and publisher_tenant_id. - - Returns: - Tuple of (agent_repository_id, is_updated). is_updated is False on insert. - """ - agent_id = repository_data.get("agent_id") - if agent_id is None: - raise ValueError("agent_id is required for repository upsert") - - existing = get_agent_repository_by_agent_id(int(agent_id)) - if not existing: - repository_id = insert_agent_repository_record( - repository_data=repository_data, - publisher_tenant_id=publisher_tenant_id, - publisher_user_id=publisher_user_id, - ) - return repository_id, False - - existing_version = existing.get("source_version_no") - incoming_version = repository_data.get("source_version_no") - repository_id = int(existing["agent_repository_id"]) - - if existing_version == incoming_version: - update_fields: Dict[str, Any] = { - "status": repository_data.get("status", STATUS_NOT_SHARED), - "updated_by": publisher_user_id, - } - else: - update_fields = { - key: repository_data[key] - for key in _UPSERT_SNAPSHOT_FIELDS - if key in repository_data - } - update_fields["publisher_user_id"] = publisher_user_id - update_fields["updated_by"] = publisher_user_id - update_fields["status"] = repository_data.get("status", STATUS_NOT_SHARED) - - with get_db_session() as session: - session.execute( - update(AgentRepository) - .where( - AgentRepository.agent_repository_id == repository_id, - AgentRepository.publisher_tenant_id == publisher_tenant_id, - AgentRepository.delete_flag != "Y", - ) - .values(**update_fields) - ) - return repository_id, True - - -def list_agent_repository_summaries( - *, - status: Optional[str] = None, -) -> List[dict]: - """List all active repository summaries without heavy JSON blobs.""" - with get_db_session() as session: - query = session.query( - AgentRepository.agent_repository_id, - AgentRepository.author, - AgentRepository.name, - AgentRepository.display_name, - AgentRepository.description, - AgentRepository.status, - ).filter( - AgentRepository.delete_flag != "Y", - ) - if status: - query = query.filter(AgentRepository.status == status) - rows = query.order_by(AgentRepository.agent_repository_id.desc()).all() - return [ - { - "agent_repository_id": row.agent_repository_id, - "author": row.author, - "name": row.name, - "display_name": row.display_name, - "description": row.description, - "status": row.status, - } - for row in rows - ] - - -def query_agent_repository_list( - *, - page: int = 1, - page_size: int = 20, - search: Optional[str] = None, - tag: Optional[str] = None, - category_id: Optional[int] = None, - status: Optional[str] = STATUS_SHARED, - publisher_tenant_id: Optional[str] = None, -) -> Dict[str, Any]: - """Query repository listings with offset pagination.""" - page = max(page, 1) - page_size = max(min(page_size, 100), 1) - offset = (page - 1) * page_size - - with get_db_session() as session: - query = session.query(AgentRepository).filter( - AgentRepository.delete_flag != "Y", - ) - - if status: - query = query.filter(AgentRepository.status == status) - if publisher_tenant_id: - query = query.filter( - AgentRepository.publisher_tenant_id == publisher_tenant_id - ) - if category_id is not None: - query = query.filter(AgentRepository.category_id == category_id) - if tag: - query = query.filter(AgentRepository.tags.any(tag)) - if search: - keyword = f"%{search}%" - query = query.filter( - or_( - AgentRepository.name.ilike(keyword), - AgentRepository.display_name.ilike(keyword), - AgentRepository.description.ilike(keyword), - AgentRepository.author.ilike(keyword), - func.array_to_string(AgentRepository.tags, ",").ilike(keyword), - ) - ) - - total = query.count() - rows = ( - query.order_by(AgentRepository.agent_repository_id.desc()) - .offset(offset) - .limit(page_size) - .all() - ) - - total_pages = math.ceil(total / page_size) if total else 0 - return { - "items": [as_dict(row) for row in rows], - "pagination": { - "page": page, - "page_size": page_size, - "total": total, - "total_pages": total_pages, - }, - } - - -def update_agent_repository_by_id( - *, - repository_id: int, - publisher_tenant_id: str, - user_id: str, - updates: Dict[str, Any], -) -> int: - """Update a repository listing owned by the publisher tenant. Returns affected row count.""" - allowed_fields = { - "display_name", - "description", - "author", - "category_id", - "tags", - "tool_count", - "version_label", - "source_version_no", - "agent_info_json", - "status", - } - update_fields = { - key: value - for key, value in updates.items() - if key in allowed_fields - } - if not update_fields: - return 0 - - update_fields["updated_by"] = user_id - - with get_db_session() as session: - result = session.execute( - update(AgentRepository) - .where( - AgentRepository.agent_repository_id == repository_id, - AgentRepository.publisher_tenant_id == publisher_tenant_id, - AgentRepository.delete_flag != "Y", - ) - .values(**update_fields) - ) - return int(result.rowcount or 0) - - -def update_agent_repository_status_by_id( - *, - repository_id: int, - status: str, - user_id: str, -) -> int: - """Update repository listing status by primary key. Returns affected row count.""" - with get_db_session() as session: - result = session.execute( - update(AgentRepository) - .where( - AgentRepository.agent_repository_id == repository_id, - AgentRepository.delete_flag != "Y", - ) - .values(status=status, updated_by=user_id) - ) - return int(result.rowcount or 0) - - -def soft_delete_agent_repository_by_id( - *, - repository_id: int, - publisher_tenant_id: str, - user_id: str, -) -> int: - """Soft-delete a repository listing owned by the publisher tenant.""" - with get_db_session() as session: - result = session.execute( - update(AgentRepository) - .where( - AgentRepository.agent_repository_id == repository_id, - AgentRepository.publisher_tenant_id == publisher_tenant_id, - AgentRepository.delete_flag != "Y", - ) - .values(delete_flag="Y", updated_by=user_id) - ) - return int(result.rowcount or 0) - - -def list_agent_repository_by_publisher( - publisher_tenant_id: str, - *, - publisher_user_id: Optional[str] = None, -) -> List[dict]: - """List all repository listings published by a tenant.""" - with get_db_session() as session: - query = session.query(AgentRepository).filter( - AgentRepository.publisher_tenant_id == publisher_tenant_id, - AgentRepository.delete_flag != "Y", - ) - if publisher_user_id: - query = query.filter( - AgentRepository.publisher_user_id == publisher_user_id - ) - rows = query.order_by(AgentRepository.agent_repository_id.desc()).all() - return [as_dict(row) for row in rows] diff --git a/backend/database/cas_session_db.py b/backend/database/cas_session_db.py deleted file mode 100644 index 57d1aa8ea..000000000 --- a/backend/database/cas_session_db.py +++ /dev/null @@ -1,134 +0,0 @@ -""" -Database operations for CAS-backed web sessions. -""" - -from datetime import datetime -from typing import Any, Dict, Optional - -from database.client import as_dict, get_db_session -from database.db_models import UserCasSession - -CAS_SESSION_ACTIVE = "active" -CAS_SESSION_REVOKED = "revoked" - - -def create_cas_session( - *, - session_id: str, - user_id: str, - cas_user_id: str, - expires_at: datetime, - cas_session_index: Optional[str] = None, -) -> Dict[str, Any]: - with get_db_session() as session: - record = UserCasSession( - session_id=session_id, - user_id=user_id, - cas_user_id=cas_user_id, - cas_session_index=cas_session_index, - status=CAS_SESSION_ACTIVE, - expires_at=expires_at, - created_by=user_id, - updated_by=user_id, - ) - session.add(record) - session.flush() - return as_dict(record) - - -def get_cas_session_by_session_id(session_id: str) -> Optional[Dict[str, Any]]: - if not session_id: - return None - with get_db_session() as session: - result = ( - session.query(UserCasSession) - .filter( - UserCasSession.session_id == session_id, - UserCasSession.delete_flag == "N", - ) - .first() - ) - return as_dict(result) if result else None - - -def is_cas_session_active(session_id: str) -> bool: - if not session_id: - return False - with get_db_session() as session: - result = ( - session.query(UserCasSession) - .filter( - UserCasSession.session_id == session_id, - UserCasSession.status == CAS_SESSION_ACTIVE, - UserCasSession.expires_at > datetime.now(), - UserCasSession.delete_flag == "N", - ) - .first() - ) - return result is not None - - -def revoke_cas_session_by_session_id(session_id: str, actor: str = "cas") -> int: - if not session_id: - return 0 - with get_db_session() as session: - result = ( - session.query(UserCasSession) - .filter( - UserCasSession.session_id == session_id, - UserCasSession.status == CAS_SESSION_ACTIVE, - UserCasSession.delete_flag == "N", - ) - .update( - { - "status": CAS_SESSION_REVOKED, - "revoked_at": datetime.now(), - "updated_by": actor, - } - ) - ) - return result - - -def revoke_cas_sessions_by_user_id(cas_user_id: str, actor: str = "cas") -> int: - if not cas_user_id: - return 0 - with get_db_session() as session: - result = ( - session.query(UserCasSession) - .filter( - UserCasSession.cas_user_id == cas_user_id, - UserCasSession.status == CAS_SESSION_ACTIVE, - UserCasSession.delete_flag == "N", - ) - .update( - { - "status": CAS_SESSION_REVOKED, - "revoked_at": datetime.now(), - "updated_by": actor, - } - ) - ) - return result - - -def revoke_cas_session_by_index(cas_session_index: str, actor: str = "cas") -> int: - if not cas_session_index: - return 0 - with get_db_session() as session: - result = ( - session.query(UserCasSession) - .filter( - UserCasSession.cas_session_index == cas_session_index, - UserCasSession.status == CAS_SESSION_ACTIVE, - UserCasSession.delete_flag == "N", - ) - .update( - { - "status": CAS_SESSION_REVOKED, - "revoked_at": datetime.now(), - "updated_by": actor, - } - ) - ) - return result diff --git a/backend/database/conversation_db.py b/backend/database/conversation_db.py index 2d06bb9be..18c0ee9fc 100644 --- a/backend/database/conversation_db.py +++ b/backend/database/conversation_db.py @@ -1016,71 +1016,3 @@ def get_message_id_by_index(conversation_id: int, message_index: int) -> Optiona result = session.execute(stmt).scalar() return result - - -def get_latest_assistant_message_id(conversation_id: int, user_id: Optional[str] = None) -> Optional[int]: - """ - Get the most recent assistant message ID for a conversation. - - Args: - conversation_id: Conversation ID (integer) - user_id: Optional user ID for ownership check - - Returns: - Optional[int]: The latest assistant message ID, or None if not found - """ - with get_db_session() as session: - conversation_id = int(conversation_id) - - stmt = select(ConversationMessage.message_id).where( - ConversationMessage.conversation_id == conversation_id, - ConversationMessage.delete_flag == 'N', - ConversationMessage.message_role == 'assistant' - ).order_by(desc(ConversationMessage.message_index)).limit(1) - - if user_id: - stmt = stmt.join( - ConversationRecord, - ConversationMessage.conversation_id == ConversationRecord.conversation_id - ).where(ConversationRecord.created_by == user_id) - - result = session.execute(stmt).scalar() - return result - - -def update_message_minio_files(message_id: int, skill_file_uploads: List[Dict[str, Any]]) -> bool: - """ - Merge skill file uploads into an existing message's minio_files field. - - Args: - message_id: Message ID to update - skill_file_uploads: List of skill file upload metadata dicts to append - - Returns: - bool: True if the message was updated, False if the message was not found - """ - with get_db_session() as session: - message_id = int(message_id) - - stmt = select(ConversationMessage).where( - ConversationMessage.message_id == message_id, - ConversationMessage.delete_flag == 'N' - ) - record = session.scalars(stmt).first() - if not record: - return False - - existing = record.minio_files - if existing: - try: - if isinstance(existing, str): - existing = json.loads(existing) - except (json.JSONDecodeError, TypeError): - existing = [] - else: - existing = [] - - existing.extend(skill_file_uploads) - record.minio_files = json.dumps(existing, ensure_ascii=False) - - return True diff --git a/backend/database/db_models.py b/backend/database/db_models.py index 5450b5f74..b779266c9 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -15,8 +15,6 @@ _TENANT_ID_DOC = "Tenant ID for multi-tenancy isolation" # Base class for tables without audit fields - - class SimpleTableBase(DeclarativeBase): pass @@ -299,16 +297,13 @@ class AgentInfo(TableBase): agent_id = Column(Integer, Sequence( "ag_tenant_agent_t_agent_id_seq", schema=SCHEMA), nullable=False, primary_key=True, autoincrement=True, doc="ID") - version_no = Column(Integer, default=0, nullable=False, primary_key=True, - doc="Version number. 0 = draft/editing state, >=1 = published snapshot") + version_no = Column(Integer, default=0, nullable=False, primary_key=True, doc="Version number. 0 = draft/editing state, >=1 = published snapshot") name = Column(String(100), doc="Agent name") display_name = Column(String(100), doc="Agent display name") description = Column(Text, doc="Description") author = Column(String(100), doc="Agent author") - model_name = Column( - String(100), doc="[DEPRECATED] Name of the model used, use model_id instead") - model_id = Column( - Integer, doc="Model ID, foreign key reference to model_record_t.model_id") + model_name = Column(String(100), doc="[DEPRECATED] Name of the model used, use model_id instead") + model_id = Column(Integer, doc="Model ID, foreign key reference to model_record_t.model_id") max_steps = Column(Integer, doc="Maximum number of steps") duty_prompt = Column(Text, doc="Duty prompt content") constraint_prompt = Column(Text, doc="Constraint prompt content") @@ -320,22 +315,15 @@ class AgentInfo(TableBase): Boolean, doc="Whether to provide the running summary to the manager agent") business_description = Column( Text, doc="Manually entered by the user to describe the entire business process") - business_logic_model_name = Column( - String(100), doc="Model name used for business logic prompt generation") - business_logic_model_id = Column( - Integer, doc="Model ID used for business logic prompt generation, foreign key reference to model_record_t.model_id") - prompt_template_id = Column( - Integer, doc="Prompt template ID used for business logic prompt generation") - prompt_template_name = Column(String( - 100), doc="Prompt template name used for business logic prompt generation") + business_logic_model_name = Column(String(100), doc="Model name used for business logic prompt generation") + business_logic_model_id = Column(Integer, doc="Model ID used for business logic prompt generation, foreign key reference to model_record_t.model_id") + prompt_template_id = Column(Integer, doc="Prompt template ID used for business logic prompt generation") + prompt_template_name = Column(String(100), doc="Prompt template name used for business logic prompt generation") group_ids = Column(String, doc="Agent group IDs list") is_new = Column(Boolean, default=False, doc="Whether this agent is marked as new for the user") current_version_no = Column(Integer, nullable=True, doc="Current published version number. NULL means no version published yet") ingroup_permission = Column(String(30), doc="In-group permission: EDIT, READ_ONLY, PRIVATE") enable_context_manager = Column(Boolean, default=False, doc="Whether to enable context management (compression) for this agent") - verification_config = Column(JSONB, doc="Layered ReAct self-verification configuration") - greeting_message = Column(Text, doc="Agent greeting message displayed on chat initial screen") - example_questions = Column(JSONB, doc="List of example questions for starting a conversation with this agent") class PromptTemplate(TableBase): @@ -364,15 +352,12 @@ class PromptTemplate(TableBase): template_id = Column(Integer, Sequence( "ag_prompt_template_t_template_id_seq", schema=SCHEMA), primary_key=True, nullable=False, autoincrement=True, doc="Prompt template ID") - template_name = Column(String(100), nullable=False, - doc="Prompt template name") + template_name = Column(String(100), nullable=False, doc="Prompt template name") description = Column(String(500), doc="Prompt template description") - template_type = Column(String(50), nullable=False, - default="agent_generate", doc="Prompt template type") + template_type = Column(String(50), nullable=False, default="agent_generate", doc="Prompt template type") tenant_id = Column(String(100), nullable=False, doc="Tenant ID") user_id = Column(String(100), nullable=False, doc="User ID") - template_content_zh = Column( - JSONB, nullable=False, doc="Chinese prompt template content") + template_content_zh = Column(JSONB, nullable=False, doc="Chinese prompt template content") template_content_en = Column(JSONB, doc="English prompt template content") @@ -396,8 +381,7 @@ class ToolInstance(TableBase): user_id = Column(String(100), doc="User ID") tenant_id = Column(String(100), doc="Tenant ID") enabled = Column(Boolean, doc="Enabled") - version_no = Column(Integer, default=0, primary_key=True, nullable=False, - doc="Version number. 0 = draft/editing state, >=1 = published snapshot") + version_no = Column(Integer, default=0, primary_key=True, nullable=False, doc="Version number. 0 = draft/editing state, >=1 = published snapshot") class KnowledgeRecord(TableBase): @@ -413,25 +397,18 @@ class KnowledgeRecord(TableBase): knowledge_name = Column(String(100), doc="User-facing knowledge base name") knowledge_describe = Column(String(3000), doc="Knowledge base description") knowledge_sources = Column(String(300), doc="Knowledge base sources") - embedding_model_name = Column(String( - 200), doc="Embedding model name, used to record the embedding model used by the knowledge base") - embedding_model_id = Column( - Integer, doc="Embedding model ID, foreign key reference to model_record_t.model_id") + embedding_model_name = Column(String(200), doc="Embedding model name, used to record the embedding model used by the knowledge base") + embedding_model_id = Column(Integer, doc="Embedding model ID, foreign key reference to model_record_t.model_id") tenant_id = Column(String(100), doc="Tenant ID") group_ids = Column(String, doc="Knowledge base group IDs list") ingroup_permission = Column( String(30), doc="In-group permission: EDIT, READ_ONLY, PRIVATE") summary_frequency = Column(String(10), nullable=True, - doc="Auto-summary frequency: '3h', '5h', '1d', '1w', or NULL (disabled)") + doc="Auto-summary frequency: '3h', '5h', '1d', '1w', or NULL (disabled)") last_summary_time = Column(TIMESTAMP(timezone=False), nullable=True, - doc="Timestamp of last summary generation") + doc="Timestamp of last summary generation") last_doc_update_time = Column(TIMESTAMP(timezone=False), nullable=True, - doc="Timestamp of last document add/delete operation") - preserve_source_file = Column( - Boolean, - default=True, - doc="Whether to preserve uploaded source documents after vectorization", - ) + doc="Timestamp of last document add/delete operation") class TenantConfig(TableBase): @@ -504,8 +481,7 @@ class McpRecord(TableBase): doc="Custom HTTP headers as JSON object for MCP server requests", default=None, ) - source = Column( - String(30), doc="Source type: local/mcp_registry/community") + source = Column(String(30), doc="Source type: local/mcp_registry/community") registry_json = Column(JSONB, doc="Full MCP registry server.json snapshot") config_json = Column(JSON, doc="MCP config data") enabled = Column(Boolean, default=True, doc="Enabled") @@ -533,13 +509,11 @@ class McpCommunityRecord(TableBase): source = Column(String(30), doc="Source type, fixed to community") version = Column(String(50), doc="MCP version") registry_json = Column(JSONB, doc="Full MCP metadata JSON") - transport_type = Column( - String(30), doc="Transport type: http/sse/container") + transport_type = Column(String(30), doc="Transport type: http/sse/container") config_json = Column(JSON, doc="Public-shareable MCP configuration JSON") tags = Column(ARRAY(Text), doc="Tags") description = Column(Text, doc="Description") - class UserTenant(TableBase): """ User and tenant relationship table @@ -551,8 +525,7 @@ class UserTenant(TableBase): primary_key=True, nullable=False, doc="User tenant relationship ID, unique primary key") user_id = Column(String(100), nullable=False, doc="User ID") tenant_id = Column(String(100), nullable=False, doc="Tenant ID") - user_role = Column( - String(30), doc="User role: SUPER_ADMIN, ADMIN, DEV, USER") + user_role = Column(String(30), doc="User role: SUPER_ADMIN, ADMIN, DEV, USER") user_email = Column(String(255), doc="User email address") @@ -563,18 +536,11 @@ class AgentRelation(TableBase): __tablename__ = "ag_agent_relation_t" __table_args__ = {"schema": SCHEMA} - relation_id = Column(Integer, Sequence("ag_agent_relation_t_relation_id_seq", schema=SCHEMA), - primary_key=True, nullable=False, doc="Relationship ID, primary key") - selected_agent_id = Column( - Integer, primary_key=True, doc="Selected agent ID") + relation_id = Column(Integer, Sequence("ag_agent_relation_t_relation_id_seq", schema=SCHEMA), primary_key=True, nullable=False, doc="Relationship ID, primary key") + selected_agent_id = Column(Integer, primary_key=True, doc="Selected agent ID") parent_agent_id = Column(Integer, doc="Parent agent ID") tenant_id = Column(String(100), doc="Tenant ID") - version_no = Column(Integer, default=0, nullable=False, - doc="Version number. 0 = draft/editing state, >=1 = published snapshot") - selected_agent_version_no = Column( - Integer, nullable=True, - doc="Pinned version of selected_agent_id. NULL = runtime fallback to child current_version_no", - ) + version_no = Column(Integer, default=0, nullable=False, doc="Version number. 0 = draft/editing state, >=1 = published snapshot") class PartnerMappingId(TableBase): @@ -690,51 +656,13 @@ class AgentVersion(TableBase): primary_key=True, nullable=False, doc=_PRIMARY_KEY_DOC) tenant_id = Column(String(100), nullable=False, doc="Tenant ID") agent_id = Column(Integer, nullable=False, doc="Agent ID") - version_no = Column(Integer, nullable=False, - doc="Version number, starts from 1. Does not include 0 (draft)") - version_name = Column( - String(100), doc="User-defined version name for display") + version_no = Column(Integer, nullable=False, doc="Version number, starts from 1. Does not include 0 (draft)") + version_name = Column(String(100), doc="User-defined version name for display") release_note = Column(Text, doc="Release notes / publish remarks") - source_version_no = Column( - Integer, doc="Source version number. If this version is a rollback, record the source version") - source_type = Column(String( - 30), doc="Source type: NORMAL (normal publish) / ROLLBACK (rollback and republish)") - status = Column(String(30), default="RELEASED", - doc="Version status: RELEASED / DISABLED / ARCHIVED") - is_a2a = Column(Boolean, default=False, - doc="Whether this version is published as an A2A Server agent") - - -class AgentRepository(TableBase): - """ - Agent repository (marketplace) table. Frozen snapshot of a published agent tree for sharing. - """ - __tablename__ = "ag_agent_repository_t" - __table_args__ = {"schema": SCHEMA} - - agent_repository_id = Column(BigInteger, Sequence("ag_agent_repository_t_agent_repository_id_seq", schema=SCHEMA), - primary_key=True, nullable=False, doc="Agent repository listing ID, unique primary key") - publisher_tenant_id = Column(String(100), nullable=False, doc="Publisher tenant ID") - publisher_user_id = Column(String(100), nullable=False, doc="Publisher user ID") - agent_id = Column(Integer, nullable=False, - doc="Root agent ID from ag_tenant_agent_t; upsert key") - source_version_no = Column(Integer, nullable=False, - doc="Published version number frozen at share time") - name = Column(String(100), nullable=False, - doc="Root agent programmatic name for display and search") - display_name = Column(String(100), doc="Root agent display name") - description = Column(Text, doc="Root agent description") - author = Column(String(100), doc="Agent author") - category_id = Column(Integer, doc="Optional marketplace category ID") - tags = Column(ARRAY(Text), doc="Marketplace tags") - tool_count = Column(Integer, - doc="Total tool count across all agents in the bundle (display only)") - version_label = Column(String(100), - doc="Repository entry version label for display (e.g. v1.0)") - agent_info_json = Column(JSONB, nullable=False, - doc="Frozen ExportAndImportDataFormat snapshot with optional skills") - status = Column(String(30), default="NOT_SHARED", - doc="Listing status: NOT_SHARED (未共享) / PENDING_REVIEW (待审核) / REJECTED (审核驳回) / SHARED (已共享)") + source_version_no = Column(Integer, doc="Source version number. If this version is a rollback, record the source version") + source_type = Column(String(30), doc="Source type: NORMAL (normal publish) / ROLLBACK (rollback and republish)") + status = Column(String(30), default="RELEASED", doc="Version status: RELEASED / DISABLED / ARCHIVED") + is_a2a = Column(Boolean, default=False, doc="Whether this version is published as an A2A Server agent") class UserTokenInfo(TableBase): @@ -747,8 +675,7 @@ class UserTokenInfo(TableBase): token_id = Column(Integer, Sequence("user_token_info_t_token_id_seq", schema=SCHEMA), primary_key=True, nullable=False, doc="Token ID, unique primary key") access_key = Column(String(100), nullable=False, doc="Access Key (AK)") - user_id = Column(String(100), nullable=False, - doc="User ID who owns this token") + user_id = Column(String(100), nullable=False, doc="User ID who owns this token") class UserTokenUsageLog(TableBase): @@ -760,21 +687,16 @@ class UserTokenUsageLog(TableBase): token_usage_id = Column(Integer, Sequence("user_token_usage_log_t_token_usage_id_seq", schema=SCHEMA), primary_key=True, nullable=False, doc="Token usage log ID, unique primary key") - token_id = Column(Integer, nullable=False, - doc="Foreign key to user_token_info_t.token_id") - call_function_name = Column( - String(100), doc="API function name being called") - related_id = Column( - Integer, doc="Related resource ID (e.g., conversation_id)") - meta_data = Column( - JSONB, doc="Additional metadata for this usage log entry, stored as JSON") + token_id = Column(Integer, nullable=False, doc="Foreign key to user_token_info_t.token_id") + call_function_name = Column(String(100), doc="API function name being called") + related_id = Column(Integer, doc="Related resource ID (e.g., conversation_id)") + meta_data = Column(JSONB, doc="Additional metadata for this usage log entry, stored as JSON") class UserOAuthAccount(TableBase): __tablename__ = "user_oauth_account_t" __table_args__ = ( - UniqueConstraint("provider", "provider_user_id", - name="uq_oauth_provider_user"), + UniqueConstraint("provider", "provider_user_id", name="uq_oauth_provider_user"), {"schema": SCHEMA}, ) @@ -792,38 +714,11 @@ class UserOAuthAccount(TableBase): provider_user_id = Column( String(200), nullable=False, doc="User ID from the OAuth provider" ) - provider_email = Column( - String(255), doc="Email address from the OAuth provider") - provider_username = Column( - String(200), doc="Display name from the OAuth provider") + provider_email = Column(String(255), doc="Email address from the OAuth provider") + provider_username = Column(String(200), doc="Display name from the OAuth provider") tenant_id = Column(String(100), doc="Tenant ID at time of linking") -class UserCasSession(TableBase): - __tablename__ = "user_cas_session_t" - __table_args__ = ( - Index("ix_user_cas_session_session_id", "session_id"), - Index("ix_user_cas_session_user_id", "user_id"), - Index("ix_user_cas_session_cas_user_id", "cas_user_id"), - {"schema": SCHEMA}, - ) - - cas_session_id = Column( - Integer, - Sequence("user_cas_session_t_cas_session_id_seq", schema=SCHEMA), - primary_key=True, - nullable=False, - doc="CAS session record ID", - ) - session_id = Column(String(100), nullable=False, unique=True, doc="JWT session ID") - user_id = Column(String(100), nullable=False, doc="Supabase user UUID") - cas_user_id = Column(String(200), nullable=False, doc="User ID from CAS") - cas_session_index = Column(String(500), doc="CAS SessionIndex or service ticket") - status = Column(String(30), nullable=False, default="active", doc="active/revoked") - expires_at = Column(TIMESTAMP(timezone=False), nullable=False, doc="Session expiration time") - revoked_at = Column(TIMESTAMP(timezone=False), doc="Revocation time") - - class SkillInfo(TableBase): """ Skill information table - stores skill metadata and content. @@ -833,17 +728,13 @@ class SkillInfo(TableBase): skill_id = Column(Integer, Sequence("ag_skill_info_t_skill_id_seq", schema=SCHEMA), primary_key=True, nullable=False, autoincrement=True, doc="Skill ID") - skill_name = Column(String(100), nullable=False, - unique=True, doc="Unique skill name") - tenant_id = Column(String(100), nullable=True, - doc="Tenant ID for multi-tenancy. NULL for pre-existing skills.") + skill_name = Column(String(100), nullable=False, unique=True, doc="Unique skill name") + tenant_id = Column(String(100), nullable=True, doc="Tenant ID for multi-tenancy. NULL for pre-existing skills.") skill_description = Column(String(1000), doc="Skill description") skill_tags = Column(JSON, doc="Skill tags as JSON array") skill_content = Column(Text, doc="Skill content in markdown format") - config_schemas = Column( - JSON, doc="Parameter metadata from config/schema.yaml") - config_values = Column( - JSON, doc="Runtime parameter values from config/config.yaml") + config_schemas = Column(JSON, doc="Parameter metadata from config/schema.yaml") + config_values = Column(JSON, doc="Runtime parameter values from config/config.yaml") source = Column(String(30), nullable=False, default="official", doc="Skill source: official, custom, etc.") @@ -857,10 +748,8 @@ class SkillToolRelation(TableBase): rel_id = Column(Integer, Sequence("ag_skill_tools_rel_t_rel_id_seq", schema=SCHEMA), primary_key=True, nullable=False, autoincrement=True, doc="Relation ID") - skill_id = Column(Integer, nullable=False, - doc="Foreign key to ag_skill_info_t.skill_id") - tool_id = Column(Integer, nullable=False, - doc="Foreign key to ag_tool_info_t.tool_id") + skill_id = Column(Integer, nullable=False, doc="Foreign key to ag_skill_info_t.skill_id") + tool_id = Column(Integer, nullable=False, doc="Foreign key to ag_tool_info_t.tool_id") class SkillInstance(TableBase): @@ -879,19 +768,14 @@ class SkillInstance(TableBase): nullable=False, doc="Skill instance ID" ) - skill_id = Column(Integer, nullable=False, - doc="Foreign key to ag_skill_info_t.skill_id") + skill_id = Column(Integer, nullable=False, doc="Foreign key to ag_skill_info_t.skill_id") agent_id = Column(Integer, nullable=False, doc="Agent ID") user_id = Column(String(100), doc="User ID") tenant_id = Column(String(100), doc="Tenant ID") - enabled = Column(Boolean, default=True, - doc="Whether this skill is enabled for the agent") - version_no = Column(Integer, default=0, primary_key=True, nullable=False, - doc="Version number. 0 = draft/editing state, >=1 = published snapshot") - config_values = Column( - JSON, doc="Per-agent runtime parameter values (mirrors ag_tool_instance_t.params)") - config_schemas = Column( - JSON, doc="Per-agent parameter schema overrides from config/schema.yaml") + enabled = Column(Boolean, default=True, doc="Whether this skill is enabled for the agent") + version_no = Column(Integer, default=0, primary_key=True, nullable=False, doc="Version number. 0 = draft/editing state, >=1 = published snapshot") + config_values = Column(JSON, doc="Per-agent runtime parameter values (mirrors ag_tool_instance_t.params)") + config_schemas = Column(JSON, doc="Per-agent parameter schema overrides from config/schema.yaml") class OuterApiService(TableBase): @@ -904,16 +788,13 @@ class OuterApiService(TableBase): id = Column(BigInteger, Sequence("ag_outer_api_services_id_seq", schema=SCHEMA), primary_key=True, nullable=False, doc="Service ID, unique primary key") - mcp_service_name = Column(String(100), nullable=False, - doc="MCP service name (unique identifier per tenant)") + mcp_service_name = Column(String(100), nullable=False, doc="MCP service name (unique identifier per tenant)") description = Column(Text, doc="Service description from OpenAPI info") openapi_json = Column(JSONB, doc="Complete OpenAPI JSON specification") server_url = Column(String(500), doc="Base URL of the REST API server") headers_template = Column(JSONB, doc="Default headers template as JSON") - tenant_id = Column(String(100), nullable=False, - doc="Tenant ID for multi-tenancy") - is_available = Column(Boolean, default=True, - doc="Whether the service is available") + tenant_id = Column(String(100), nullable=False, doc="Tenant ID for multi-tenancy") + is_available = Column(Boolean, default=True, doc="Whether the service is available") # Alias for backward compatibility @@ -928,37 +809,27 @@ class A2ANacosConfig(TableBase): __tablename__ = "ag_a2a_nacos_config_t" __table_args__ = {"schema": SCHEMA} - id = Column(BigInteger, primary_key=True, - autoincrement=True, doc=_PRIMARY_KEY_DOC) - config_id = Column(String(64), unique=True, nullable=False, - doc="Unique config identifier for API reference") + id = Column(BigInteger, primary_key=True, autoincrement=True, doc=_PRIMARY_KEY_DOC) + config_id = Column(String(64), unique=True, nullable=False, doc="Unique config identifier for API reference") # Nacos connection - nacos_addr = Column(String(512), nullable=False, - doc="Nacos server address, e.g., http://nacos-server:8848") - nacos_username = Column( - String(100), doc="Nacos username for authentication") - nacos_password = Column( - String(256), doc="Nacos password, encrypted at rest") + nacos_addr = Column(String(512), nullable=False, doc="Nacos server address, e.g., http://nacos-server:8848") + nacos_username = Column(String(100), doc="Nacos username for authentication") + nacos_password = Column(String(256), doc="Nacos password, encrypted at rest") # Discovery scope - namespace_id = Column(String(100), default="public", - doc="Nacos namespace for service discovery") + namespace_id = Column(String(100), default="public", doc="Nacos namespace for service discovery") # Metadata - name = Column(String(100), nullable=False, - doc="Display name for this Nacos config") + name = Column(String(100), nullable=False, doc="Display name for this Nacos config") description = Column(Text, doc="Description of this Nacos configuration") # Tenant isolation - tenant_id = Column(String(100), nullable=False, - doc="Tenant ID for multi-tenancy") + tenant_id = Column(String(100), nullable=False, doc="Tenant ID for multi-tenancy") # Status - is_active = Column(Boolean, default=True, - doc="Whether this Nacos config is active") - last_scan_at = Column(TIMESTAMP(timezone=False), - doc="Last time a scan was performed using this config") + is_active = Column(Boolean, default=True, doc="Whether this Nacos config is active") + last_scan_at = Column(TIMESTAMP(timezone=False), doc="Last time a scan was performed using this config") class A2AExternalAgent(TableBase): @@ -969,49 +840,39 @@ class A2AExternalAgent(TableBase): __tablename__ = "ag_a2a_external_agent_t" __table_args__ = {"schema": SCHEMA} - id = Column(BigInteger, primary_key=True, - autoincrement=True, doc=_PRIMARY_KEY_DOC) + id = Column(BigInteger, primary_key=True, autoincrement=True, doc=_PRIMARY_KEY_DOC) # Agent metadata (cached from Agent Card) - name = Column(String(255), nullable=False, - doc="Agent name from Agent Card") + name = Column(String(255), nullable=False, doc="Agent name from Agent Card") description = Column(Text, doc="Agent description from Agent Card") - version = Column( - String(50), doc="Agent version from Agent Card, e.g., 1.2.0") + version = Column(String(50), doc="Agent version from Agent Card, e.g., 1.2.0") # Primary interface (extracted from supportedInterfaces for quick access) # In A2A 1.0, this should store the http-json-rpc URL - agent_url = Column(String(512), nullable=False, - doc="Primary A2A endpoint URL (http-json-rpc by default)") + agent_url = Column(String(512), nullable=False, doc="Primary A2A endpoint URL (http-json-rpc by default)") # Protocol type for calling this agent: JSONRPC, HTTP+JSON, GRPC - protocol_type = Column(String(20), default=PROTOCOL_JSONRPC, - doc="Protocol type for calling this agent") + protocol_type = Column(String(20), default=PROTOCOL_JSONRPC, doc="Protocol type for calling this agent") # Capabilities - streaming = Column(Boolean, default=False, - doc="Whether this agent supports SSE streaming") + streaming = Column(Boolean, default=False, doc="Whether this agent supports SSE streaming") # All supported interfaces (full JSON array from Agent Card) # Format: [{protocolBinding, url, protocolVersion}, ...] supported_interfaces = Column(JSON, doc="All supported interfaces array") # Source information - source_type = Column(String(20), nullable=False, - doc="Discovery source: url or nacos") + source_type = Column(String(20), nullable=False, doc="Discovery source: url or nacos") # For URL mode source_url = Column(String(512), doc="Direct URL to agent card") # For Nacos mode - nacos_config_id = Column( - String(64), doc="Reference to Nacos config used for discovery") - nacos_agent_name = Column( - String(255), doc="Original name used for Nacos query") + nacos_config_id = Column(String(64), doc="Reference to Nacos config used for discovery") + nacos_agent_name = Column(String(255), doc="Original name used for Nacos query") # Base URL for infrastructure health checks - base_url = Column(String( - 512), doc="Base URL for health checks (service root address), e.g., http://agent:8080") + base_url = Column(String(512), doc="Base URL for health checks (service root address), e.g., http://agent:8080") # Tenant isolation tenant_id = Column(String(100), nullable=False, doc=_TENANT_ID_DOC) @@ -1020,18 +881,13 @@ class A2AExternalAgent(TableBase): raw_card = Column(JSON, doc="Full original Agent Card JSON from discovery") # Cache management - cached_at = Column(TIMESTAMP(timezone=False), - doc="Timestamp when Agent Card was cached") - cache_expires_at = Column( - TIMESTAMP(timezone=False), doc="Timestamp when cache expires") + cached_at = Column(TIMESTAMP(timezone=False), doc="Timestamp when Agent Card was cached") + cache_expires_at = Column(TIMESTAMP(timezone=False), doc="Timestamp when cache expires") # Health check status - is_available = Column(Boolean, default=True, - doc="Whether this agent is currently reachable") - last_check_at = Column(TIMESTAMP(timezone=False), - doc="Last health check timestamp") - last_check_result = Column( - String(50), doc="Last health check result: OK, ERROR, TIMEOUT") + is_available = Column(Boolean, default=True, doc="Whether this agent is currently reachable") + last_check_at = Column(TIMESTAMP(timezone=False), doc="Last health check timestamp") + last_check_result = Column(String(50), doc="Last health check result: OK, ERROR, TIMEOUT") class A2AExternalAgentRelation(TableBase): @@ -1049,23 +905,19 @@ class A2AExternalAgentRelation(TableBase): {"schema": SCHEMA}, ) - id = Column(BigInteger, primary_key=True, - autoincrement=True, doc=_PRIMARY_KEY_DOC) + id = Column(BigInteger, primary_key=True, autoincrement=True, doc=_PRIMARY_KEY_DOC) # Local agent (parent) - local_agent_id = Column(Integer, nullable=False, - doc="Local parent agent ID") + local_agent_id = Column(Integer, nullable=False, doc="Local parent agent ID") # External A2A agent (sub-agent) - FK to ag_a2a_external_agent_t.id - external_agent_id = Column( - BigInteger, nullable=False, doc="External A2A agent ID (FK to ag_a2a_external_agent_t.id)") + external_agent_id = Column(BigInteger, nullable=False, doc="External A2A agent ID (FK to ag_a2a_external_agent_t.id)") # Tenant isolation tenant_id = Column(String(100), nullable=False, doc=_TENANT_ID_DOC) # Status - is_enabled = Column(Boolean, default=True, - doc="Whether this relation is active") + is_enabled = Column(Boolean, default=True, doc="Whether this relation is active") class A2AServerAgent(TableBase): @@ -1076,8 +928,7 @@ class A2AServerAgent(TableBase): __tablename__ = "ag_a2a_server_agent_t" __table_args__ = {"schema": SCHEMA} - id = Column(BigInteger, primary_key=True, - autoincrement=True, doc=_PRIMARY_KEY_DOC) + id = Column(BigInteger, primary_key=True, autoincrement=True, doc=_PRIMARY_KEY_DOC) # Link to local agent agent_id = Column(Integer, nullable=False, doc="Local agent ID") @@ -1087,44 +938,35 @@ class A2AServerAgent(TableBase): tenant_id = Column(String(100), nullable=False, doc=_TENANT_ID_DOC) # Generated endpoint ID - endpoint_id = Column(String(64), unique=True, - nullable=False, doc="Generated endpoint ID") + endpoint_id = Column(String(64), unique=True, nullable=False, doc="Generated endpoint ID") # Basic info (extracted from local agent, can be overridden) - name = Column(String(255), nullable=False, - doc="Agent name exposed in Agent Card") + name = Column(String(255), nullable=False, doc="Agent name exposed in Agent Card") description = Column(Text, doc="Agent description exposed in Agent Card") version = Column(String(50), doc="Agent version exposed in Agent Card") # Primary endpoint URL (http-json-rpc by default) - agent_url = Column( - String(512), doc="Primary A2A endpoint URL (http-json-rpc by default)") + agent_url = Column(String(512), doc="Primary A2A endpoint URL (http-json-rpc by default)") # Capabilities - streaming = Column(Boolean, default=False, - doc="Whether this agent supports SSE streaming") + streaming = Column(Boolean, default=False, doc="Whether this agent supports SSE streaming") # All supported interfaces (A2A 1.0 compliant) # Format: [{protocolBinding, url, protocolVersion}, ...] - supported_interfaces = Column( - JSON, doc="All supported interfaces: [{protocolBinding, url, protocolVersion}, ...]") + supported_interfaces = Column(JSON, doc="All supported interfaces: [{protocolBinding, url, protocolVersion}, ...]") # Agent Card customization (partial overrides only) - card_overrides = Column( - JSON, doc="User customizations for Agent Card (partial override)") + card_overrides = Column(JSON, doc="User customizations for Agent Card (partial override)") # A2A Server status - is_enabled = Column(Boolean, default=False, - doc="Whether A2A Server is enabled for this agent") + is_enabled = Column(Boolean, default=False, doc="Whether A2A Server is enabled for this agent") # Raw Agent Card (generated from settings, for debugging) raw_card = Column(JSON, doc="Generated Agent Card JSON (for debugging)") # Publishing timestamps - published_at = Column(TIMESTAMP(timezone=False), - doc="Timestamp when A2A Server was last enabled") - unpublished_at = Column(TIMESTAMP(timezone=False), - doc="Timestamp when A2A Server was disabled") + published_at = Column(TIMESTAMP(timezone=False), doc="Timestamp when A2A Server was last enabled") + unpublished_at = Column(TIMESTAMP(timezone=False), doc="Timestamp when A2A Server was disabled") class A2ATask(SimpleTableBase): @@ -1137,8 +979,7 @@ class A2ATask(SimpleTableBase): # Core identifiers (following A2A spec) id = Column(String(64), primary_key=True, doc="Task ID (A2A spec: taskId)") - context_id = Column( - String(64), doc="Context ID for grouping related tasks") + context_id = Column(String(64), doc="Context ID for grouping related tasks") # Endpoint and caller info endpoint_id = Column(String(64), nullable=False, doc="Endpoint ID") @@ -1149,21 +990,16 @@ class A2ATask(SimpleTableBase): raw_request = Column(JSON, doc="Original A2A request payload") # Task state (following A2A TaskState enum) - task_state = Column(String(50), nullable=False, server_default="TASK_STATE_SUBMITTED", - doc="Task state: TASK_STATE_SUBMITTED, TASK_STATE_WORKING, TASK_STATE_COMPLETED, TASK_STATE_FAILED, TASK_STATE_CANCELED, TASK_STATE_INPUT_REQUIRED, TASK_STATE_REJECTED, TASK_STATE_AUTH_REQUIRED") - state_timestamp = Column(TIMESTAMP(timezone=False), - doc="Task state last update timestamp") + task_state = Column(String(50), nullable=False, server_default="TASK_STATE_SUBMITTED", doc="Task state: TASK_STATE_SUBMITTED, TASK_STATE_WORKING, TASK_STATE_COMPLETED, TASK_STATE_FAILED, TASK_STATE_CANCELED, TASK_STATE_INPUT_REQUIRED, TASK_STATE_REJECTED, TASK_STATE_AUTH_REQUIRED") + state_timestamp = Column(TIMESTAMP(timezone=False), doc="Task state last update timestamp") # Task result result_data = Column(JSON, doc="Task final result data") # Timestamps - create_time = Column(TIMESTAMP(timezone=False), - server_default=func.now(), doc="Task creation timestamp") - update_time = Column(TIMESTAMP(timezone=False), server_default=func.now( - ), onupdate=func.now(), doc="Task last update timestamp") - completed_at = Column(TIMESTAMP(timezone=False), - doc="Task completion timestamp") + create_time = Column(TIMESTAMP(timezone=False), server_default=func.now(), doc="Task creation timestamp") + update_time = Column(TIMESTAMP(timezone=False), server_default=func.now(), onupdate=func.now(), doc="Task last update timestamp") + completed_at = Column(TIMESTAMP(timezone=False), doc="Task completion timestamp") class A2AMessage(SimpleTableBase): @@ -1175,30 +1011,23 @@ class A2AMessage(SimpleTableBase): __table_args__ = {"schema": SCHEMA} # Core identifiers (following A2A spec) - message_id = Column(String(64), primary_key=True, - doc="Message ID (A2A spec: messageId)") - task_id = Column(String(64), nullable=True, - doc="Task ID this message belongs to (nullable for standalone/simple requests)") + message_id = Column(String(64), primary_key=True, doc="Message ID (A2A spec: messageId)") + task_id = Column(String(64), nullable=True, doc="Task ID this message belongs to (nullable for standalone/simple requests)") # Message attributes - message_index = Column(Integer, nullable=False, - doc="Order of message in the conversation") - role = Column(String(20), nullable=False, - doc="Message sender role: user or agent") + message_index = Column(Integer, nullable=False, doc="Order of message in the conversation") + role = Column(String(20), nullable=False, doc="Message sender role: user or agent") # Message content (following A2A Part structure) - parts = Column(JSON, nullable=False, - doc="Message parts following A2A Part structure") + parts = Column(JSON, nullable=False, doc="Message parts following A2A Part structure") meta_data = Column(JSON, doc="Optional metadata") extensions = Column(JSON, doc="Extension URI list") # References to other tasks (optional) - reference_task_ids = Column( - JSON, doc="Referenced task IDs array for multi-turn scenarios") + reference_task_ids = Column(JSON, doc="Referenced task IDs array for multi-turn scenarios") # Timestamp - create_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Message creation timestamp") + create_time = Column(TIMESTAMP(timezone=False), server_default=func.now(), doc="Message creation timestamp") class A2AArtifact(SimpleTableBase): @@ -1210,19 +1039,15 @@ class A2AArtifact(SimpleTableBase): # Core identifiers (following A2A spec) id = Column(String(64), primary_key=True, doc="Internal primary key") - artifact_id = Column(String(64), nullable=False, - doc="Artifact ID (A2A spec: artifactId)") - task_id = Column(String(64), nullable=False, - doc="Task ID this artifact belongs to") + artifact_id = Column(String(64), nullable=False, doc="Artifact ID (A2A spec: artifactId)") + task_id = Column(String(64), nullable=False, doc="Task ID this artifact belongs to") # Artifact attributes name = Column(String(255), doc="Human-readable artifact name") description = Column(Text, doc="Artifact description") - parts = Column(JSON, nullable=False, - doc="Artifact parts following A2A Part structure") + parts = Column(JSON, nullable=False, doc="Artifact parts following A2A Part structure") meta_data = Column(JSON, doc="Artifact metadata") extensions = Column(JSON, doc="Extension URI list") # Timestamp - create_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Artifact creation timestamp") + create_time = Column(TIMESTAMP(timezone=False), server_default=func.now(), doc="Artifact creation timestamp") diff --git a/backend/database/knowledge_db.py b/backend/database/knowledge_db.py index 8fc60d6bd..9a8b1c8c1 100644 --- a/backend/database/knowledge_db.py +++ b/backend/database/knowledge_db.py @@ -34,7 +34,6 @@ def create_knowledge_record(query: Dict[str, Any]) -> Dict[str, Any]: - user_id: Optional user ID for created_by and updated_by fields - tenant_id: Optional tenant ID for created_by and updated_by fields - embedding_model_name: embedding model name for the knowledge base - - preserve_source_file: whether to preserve uploaded source documents (optional) Returns: Dict[str, Any]: Dictionary with at least 'knowledge_id' and 'index_name' @@ -58,7 +57,6 @@ def create_knowledge_record(query: Dict[str, Any]) -> Dict[str, Any]: "knowledge_name": knowledge_name, "group_ids": convert_list_to_string(group_ids) if isinstance(group_ids, list) else group_ids, "ingroup_permission": query.get("ingroup_permission"), - "preserve_source_file": query.get("preserve_source_file", True), } # For backward compatibility: if caller explicitly provides index_name, @@ -119,16 +117,11 @@ def upsert_knowledge_record(query: Dict[str, Any]) -> Dict[str, Any]: if existing_record: # Update existing record - existing_record.knowledge_name = query.get( - 'knowledge_name') or query.get('index_name') - existing_record.knowledge_describe = query.get( - 'knowledge_describe', '') - existing_record.knowledge_sources = query.get( - 'knowledge_sources', 'elasticsearch') - existing_record.embedding_model_name = query.get( - 'embedding_model_name') - existing_record.embedding_model_id = query.get( - 'embedding_model_id') + existing_record.knowledge_name = query.get('knowledge_name') or query.get('index_name') + existing_record.knowledge_describe = query.get('knowledge_describe', '') + existing_record.knowledge_sources = query.get('knowledge_sources', 'elasticsearch') + existing_record.embedding_model_name = query.get('embedding_model_name') + existing_record.embedding_model_id = query.get('embedding_model_id') existing_record.updated_by = query.get('user_id') existing_record.update_time = func.current_timestamp() @@ -190,7 +183,7 @@ def update_knowledge_record(query: Dict[str, Any]) -> bool: # Update group IDs if query.get("group_ids") is not None: record.group_ids = query["group_ids"] - + # Update timestamp and user if query.get("user_id"): record.updated_by = query["user_id"] @@ -258,17 +251,15 @@ def get_knowledge_record(query: Optional[Dict[str, Any]] = None) -> Dict[str, An # Support both index_name and knowledge_name queries if 'index_name' in query: - db_query = db_query.filter( - KnowledgeRecord.index_name == query['index_name']) + db_query = db_query.filter(KnowledgeRecord.index_name == query['index_name']) elif 'knowledge_name' in query: - db_query = db_query.filter( - KnowledgeRecord.knowledge_name == query['knowledge_name']) + db_query = db_query.filter(KnowledgeRecord.knowledge_name == query['knowledge_name']) # Add tenant_id filter only if it is provided in the query if 'tenant_id' in query and query['tenant_id'] is not None: db_query = db_query.filter( KnowledgeRecord.tenant_id == query['tenant_id']) - + result = db_query.first() if result: diff --git a/backend/database/user_tenant_db.py b/backend/database/user_tenant_db.py index b147eac49..f1294f8a7 100644 --- a/backend/database/user_tenant_db.py +++ b/backend/database/user_tenant_db.py @@ -75,37 +75,6 @@ def insert_user_tenant(user_id: str, tenant_id: str, user_role: str = "USER", us session.add(user_tenant) -def upsert_user_tenant(user_id: str, tenant_id: str, user_role: str = "USER", user_email: str = None) -> Dict[str, Any]: - """ - Create or update the active user-tenant relationship for an external identity login. - """ - with get_db_session() as session: - result = session.query(UserTenant).filter( - UserTenant.user_id == user_id, - UserTenant.delete_flag == "N" - ).first() - - if result: - result.tenant_id = tenant_id - result.user_role = user_role - if user_email is not None: - result.user_email = user_email - result.updated_by = user_id - else: - result = UserTenant( - user_id=user_id, - tenant_id=tenant_id, - user_role=user_role, - user_email=user_email, - created_by=user_id, - updated_by=user_id - ) - session.add(result) - - session.flush() - return as_dict(result) - - def get_users_by_tenant_id(tenant_id: str, page: Optional[int] = 1, page_size: Optional[int] = 20, sort_by: str = "created_at", sort_order: str = "desc") -> Dict[str, Any]: """ diff --git a/backend/mcp_service.py b/backend/mcp_service.py index 4629d42ad..0d8ab4c1b 100644 --- a/backend/mcp_service.py +++ b/backend/mcp_service.py @@ -70,7 +70,7 @@ async def run(self, arguments: Dict[str, Any]) -> Any: nexent_mcp = FastMCP(name="nexent_mcp") -nexent_mcp.mount(local_mcp_service, local_mcp_service.name) +nexent_mcp.mount(local_mcp_service.name, local_mcp_service) _openapi_mcp_services: Dict[str, FastMCP] = {} @@ -188,8 +188,7 @@ def _sanitize_function_name(name: str) -> str: def register_openapi_service( service_name: str, openapi_json: Dict[str, Any], - server_url: str, - headers_template: Dict[str, str], + server_url: str ) -> bool: """ Register an OpenAPI service using FastMCP.from_openapi(). @@ -223,7 +222,7 @@ def register_openapi_service( openapi_spec["servers"] = [{"url": server_url}] # Create HTTP client for the underlying REST API - client = httpx.AsyncClient(base_url=server_url, timeout=120.0, headers=headers_template) + client = httpx.AsyncClient(base_url=server_url, timeout=30.0) # Create FastMCP instance from OpenAPI spec mcp_server = FastMCP.from_openapi( @@ -240,7 +239,7 @@ def register_openapi_service( _openapi_mcp_services[service_name] = mcp_server # Mount to the main MCP server - nexent_mcp.mount(mcp_server, service_name) + nexent_mcp.mount(service_name, mcp_server) logger.info(f"Registered OpenAPI service: {service_name}") return True @@ -321,14 +320,13 @@ def refresh_openapi_services_by_tenant(tenant_id: str) -> Dict[str, Any]: service_name = service.get("mcp_service_name") openapi_json = service.get("openapi_json") server_url = service.get("server_url") - headers_template = service.get("headers_template") if not openapi_json: logger.warning(f"Service '{service_name}' has no OpenAPI JSON, skipping") skipped_count += 1 continue - if register_openapi_service(service_name, openapi_json, server_url, headers_template): + if register_openapi_service(service_name, openapi_json, server_url): registered_count += 1 else: skipped_count += 1 @@ -396,7 +394,6 @@ def refresh_single_openapi_service(service_name: str, tenant_id: str) -> Dict[st # Re-register with fresh data openapi_json = service_data.get("openapi_json") server_url = service_data.get("server_url") - headers_template = service_data.get("headers_template") if not openapi_json: logger.warning(f"Service '{service_name}' has no OpenAPI JSON") @@ -406,7 +403,7 @@ def refresh_single_openapi_service(service_name: str, tenant_id: str) -> Dict[st "error": "No OpenAPI JSON found" } - success = register_openapi_service(service_name, openapi_json, server_url, headers_template) + success = register_openapi_service(service_name, openapi_json, server_url) return { "status": "refreshed" if success else "error", "service_name": service_name, diff --git a/backend/prompts/managed_system_prompt_template_en.yaml b/backend/prompts/managed_system_prompt_template_en.yaml index 62e16e946..5c2893c39 100644 --- a/backend/prompts/managed_system_prompt_template_en.yaml +++ b/backend/prompts/managed_system_prompt_template_en.yaml @@ -1,6 +1,6 @@ system_prompt: |- ### Basic Information - You are {{APP_NAME}}, {{APP_DESCRIPTION}} + You are {{APP_NAME}}, {{APP_DESCRIPTION}}, it is {{time|default('current time')}} now {%- if memory_list and memory_list|length > 0 %} ### Contextual Memory @@ -66,11 +66,6 @@ system_prompt: |- - Note that executed code is not visible to users. If users need to see the code, use 'code' for displaying code. - **IMPORTANT**: After code execution, the system will return content with "Observation:" marker (this is the real execution result). Please continue your next thinking based on these real results. **Do NOT fabricate observation results before code execution.** - 3. Self-verification: - - After critical events (tool calls, retrieval results, code execution, and final-answer preparation), the system may run explicit verification. - - If verification reports errors, insufficient evidence, incomplete parameters, or unreliable results, you must repair the issue, gather more evidence, call tools again, or clearly state what cannot be completed. - - The final answer is shown to the user only after verification passes. If the system returns Verification feedback, treat it as a real observation and continue revising. - After thinking, when you believe you can answer the user's question, you can generate a final answer directly to the user without generating code and stop the loop. When generating the final answer, you need to follow these specifications: @@ -183,13 +178,3 @@ final_answer: Original task: {{task}} Please provide a clear and concise summary of the work completed so far. - - -verification: - pre_messages: |- - You are a strict verifier for a ReAct agent. Judge reliability only from the task, candidate answer, tool outputs, and observations. Do not output hidden chain-of-thought. - You must output JSON only. - - post_messages: |- - Verify whether the candidate answer covers the user's intent, is grounded in observations, handles tool errors, uses trustworthy citations, and is formatted for users. - Output fields: passed, score, status, failed_criteria, checks, revision_instruction, user_visible_note. diff --git a/backend/prompts/managed_system_prompt_template_zh.yaml b/backend/prompts/managed_system_prompt_template_zh.yaml index da3d53469..291e336fb 100644 --- a/backend/prompts/managed_system_prompt_template_zh.yaml +++ b/backend/prompts/managed_system_prompt_template_zh.yaml @@ -2,7 +2,7 @@ system_prompt: |- ### 基本信息 - 你是{{APP_NAME}},{{APP_DESCRIPTION}},用户ID为{{user_id}} + 你是{{APP_NAME}},{{APP_DESCRIPTION}},现在是{{time|default('当前时间')}},用户ID为{{user_id}} {%- if memory_list and memory_list|length > 0 %} ### 上下文记忆 @@ -130,11 +130,6 @@ system_prompt: |- - 注意运行的代码不会被用户看到,所以如果用户需要看到代码,你需要使用'代码'表达展示代码。 - **重要**:代码执行后,系统会返回 "Observation:" 标记的内容(这是真实的执行结果)。请基于这些真实结果继续下一步思考,**不要在代码执行前自行编造观察结果**。 - 3. 自验证: - - 关键事件(工具调用、检索结果、代码执行、准备最终回答)后,系统会进行显式自验证。 - - 如果自验证提示存在错误、证据不足、参数不完整或结果不可靠,必须优先修正、补充证据、重新调用工具,或清晰说明无法完成的部分。 - - 最终回答只有在自验证通过后才会展示给用户;如果系统返回 Verification feedback,请把它视为真实观察结果继续修正,不要忽略。 - 在思考结束后,当你认为可以回答用户问题,那么可以不生成代码,直接生成最终回答给到用户并停止循环。 生成最终回答时,你需要遵循以下规范: @@ -276,13 +271,3 @@ final_answer: 原始任务:{{task}} 请对迄今为止完成的工作进行清晰、简洁的总结。 - - -verification: - pre_messages: |- - 你是 ReAct 智能体的严格验证器。请仅根据任务、候选答案、工具输出和观察结果判断答案是否可靠,不要输出隐藏思维链。 - 你必须只输出 JSON。 - - post_messages: |- - 请验证候选答案是否覆盖用户意图、是否有观察结果支撑、是否处理了工具错误、引用是否可信、格式是否适合展示。 - 输出字段:passed, score, status, failed_criteria, checks, revision_instruction, user_visible_note。 diff --git a/backend/prompts/manager_system_prompt_template_en.yaml b/backend/prompts/manager_system_prompt_template_en.yaml index d44ed9a71..8ce58db29 100644 --- a/backend/prompts/manager_system_prompt_template_en.yaml +++ b/backend/prompts/manager_system_prompt_template_en.yaml @@ -1,6 +1,6 @@ system_prompt: |- ### Basic Information - You are {{APP_NAME}}, {{APP_DESCRIPTION}} + You are {{APP_NAME}}, {{APP_DESCRIPTION}}, it is {{time|default('current time')}} now {%- if memory_list and memory_list|length > 0 %} ### Contextual Memory @@ -67,11 +67,6 @@ system_prompt: |- - Note that executed code is not visible to users. If users need to see the code, use 'code' for displaying code. - **IMPORTANT**: After code execution, the system will return content with "Observation:" marker (this is the real execution result). Please continue your next thinking based on these real results. **Do NOT fabricate observation results before code execution.** - 3. Self-verification: - - After critical events (tool calls, retrieval results, code execution, agent handoffs, and final-answer preparation), the system may run explicit verification. - - If verification reports errors, insufficient evidence, incomplete parameters, or unreliable results, you must repair the issue, gather more evidence, call tools again, or clearly state what cannot be completed. - - The final answer is shown to the user only after verification passes. If the system returns Verification feedback, treat it as a real observation and continue revising. - After thinking, when you believe you can answer the user's question, you can generate a final answer directly to the user without generating code and stop the loop. When generating the final answer, you need to follow these specifications: @@ -227,13 +222,3 @@ final_answer: Original task: {{task}} Please provide a clear and concise summary of the work completed so far. - - -verification: - pre_messages: |- - You are a strict verifier for a ReAct agent. Judge reliability only from the task, candidate answer, tool outputs, and observations. Do not output hidden chain-of-thought. - You must output JSON only. - - post_messages: |- - Verify whether the candidate answer covers the user's intent, is grounded in observations, handles tool errors, uses trustworthy citations, and is formatted for users. - Output fields: passed, score, status, failed_criteria, checks, revision_instruction, user_visible_note. diff --git a/backend/prompts/manager_system_prompt_template_zh.yaml b/backend/prompts/manager_system_prompt_template_zh.yaml index a49ced82d..fc4eb7c0c 100644 --- a/backend/prompts/manager_system_prompt_template_zh.yaml +++ b/backend/prompts/manager_system_prompt_template_zh.yaml @@ -1,6 +1,6 @@ system_prompt: |- ### 基本信息 - 你是{{APP_NAME}},{{APP_DESCRIPTION}},用户ID为{{user_id}} + 你是{{APP_NAME}},{{APP_DESCRIPTION}},现在是{{time|default('当前时间')}},用户ID为{{user_id}} {%- if memory_list and memory_list|length > 0 %} ### 上下文记忆 @@ -130,11 +130,6 @@ system_prompt: |- - 注意运行的代码不会被用户看到,所以如果用户需要看到代码,你需要使用'代码'表达展示代码。 - **重要**:代码执行后,系统会返回 "Observation:" 标记的内容(这是真实的执行结果)。请基于这些真实结果继续下一步思考,**不要在代码执行前自行编造观察结果**。 - 3. 自验证: - - 关键事件(工具调用、检索结果、代码执行、助手返回、准备最终回答)后,系统会进行显式自验证。 - - 如果自验证提示存在错误、证据不足、参数不完整或结果不可靠,必须优先修正、补充证据、重新调用工具,或清晰说明无法完成的部分。 - - 最终回答只有在自验证通过后才会展示给用户;如果系统返回 Verification feedback,请把它视为真实观察结果继续修正,不要忽略。 - 在思考结束后,当你认为可以回答用户问题,那么可以不生成代码,直接生成最终回答给到用户并停止循环。 生成最终回答时,你需要遵循以下规范: @@ -304,13 +299,3 @@ final_answer: 原始任务:{{task}} 请对迄今为止完成的工作进行清晰、简洁的总结。 - - -verification: - pre_messages: |- - 你是 ReAct 智能体的严格验证器。请仅根据任务、候选答案、工具输出和观察结果判断答案是否可靠,不要输出隐藏思维链。 - 你必须只输出 JSON。 - - post_messages: |- - 请验证候选答案是否覆盖用户意图、是否有观察结果支撑、是否处理了工具错误、引用是否可信、格式是否适合展示。 - 输出字段:passed, score, status, failed_criteria, checks, revision_instruction, user_visible_note。 diff --git a/backend/prompts/utils/greeting_generate_en.yaml b/backend/prompts/utils/greeting_generate_en.yaml deleted file mode 100644 index 31ea75632..000000000 --- a/backend/prompts/utils/greeting_generate_en.yaml +++ /dev/null @@ -1,54 +0,0 @@ -GREETING_SYSTEM_PROMPT: |- - ### You are an expert in generating agent greetings and example questions. You help users create engaging greetings and practical example questions for starting conversations with agents. - You are building an Agent application. The input includes: agent name, duty description, business description, and existing examples. - Generate a concise greeting and 3-5 example questions that help users quickly start a conversation with the agent. - The greeting should reflect the agent's positioning and capabilities. - - ### Requirements: - 1. The greeting should be concise and friendly, 1-2 sentences, introducing the agent's identity and core capabilities. Don't make it too long or too formal. - 2. Example questions should be specific and practical, representing questions users might actually ask, showcasing the agent's core features. - 3. If existing examples contain user query scenarios, prioritize extracting short user questions from them, keeping semantics consistent but simplified to natural conversational form. - 4. Provide 3-5 example questions, each with a clear use case. - 5. You MUST output strictly in JSON format, do not output any other content or formatting. - - ### Output format: - ```json - { - "greeting_message": "greeting content", - "example_questions": ["example question 1", "example question 2", "example question 3"] - } - ``` - - ### Examples: - Example 1 (Travel Planning Assistant, existing examples contain "Help me plan a trip from Shanghai to Beijing" etc.): - ```json - { - "greeting_message": "Hello! I'm your travel planning assistant, I can help you plan trips, recommend attractions, and arrange travel routes.", - "example_questions": ["Help me plan a 3-day trip from Shanghai to Beijing", "Recommend some family-friendly attractions", "What's fun to do in Hangzhou tomorrow?"] - } - ``` - - Example 2 (Data Analysis Assistant): - ```json - { - "greeting_message": "Hello! I'm a data analysis assistant, I can help you process and analyze data, provide visual reports and insights.", - "example_questions": ["Help me analyze trends in this sales data", "Generate a quarterly performance comparison report", "Which products have the highest profit margins?"] - } - ``` - -USER_PROMPT: |- - ### Agent Name: - {{display_name}} - - ### Agent Duty Description: - {{duty_description}} - - ### Business Description: - {{business_description}} - - {% if few_shots %} - ### Existing Examples (extract user query scenarios from these as example questions): - {{few_shots}} - {% endif %} - - Please generate the greeting and example questions based on the above information. Output strictly in JSON format. \ No newline at end of file diff --git a/backend/prompts/utils/greeting_generate_zh.yaml b/backend/prompts/utils/greeting_generate_zh.yaml deleted file mode 100644 index 34b8d85d3..000000000 --- a/backend/prompts/utils/greeting_generate_zh.yaml +++ /dev/null @@ -1,53 +0,0 @@ -GREETING_SYSTEM_PROMPT: |- - ### 你是【智能体开场白和示例问题生成专家】,用于帮助用户创建高效、吸引人的智能体开场白和示例问题。 - 现在正在构建一个Agent应用,用户的输入包含:智能体名称、职责描述、业务描述、已有示例。 - 请根据智能体的定位和职责,生成一个简短的开场白和3~5个示例问题,帮助用户快速开始与智能体的对话。 - - ### 要求: - 1.开场白要简洁友好,1-2句话即可,介绍智能体的身份和核心能力,不要过长或过于正式。 - 2.示例问题要具体、实用,是用户真实可能提出的问题,体现智能体的核心功能。 - 3.如果已有示例中包含用户的提问场景,请优先从中提炼简短的用户问题作为示例问题,保持语义一致但简化为自然对话形式。 - 4.示例问题数量为3~5个,每个问题要有明确的使用场景。 - 5.必须严格按照JSON格式输出,不要输出任何其他内容或格式。 - - ### 输出格式: - ```json - { - "greeting_message": "开场白内容", - "example_questions": ["示例问题1", "示例问题2", "示例问题3"] - } - ``` - - ### 参考示例: - 示例1(旅行规划助手,已有示例包含"帮我规划明天从上海出发去北京的行程"等场景): - ```json - { - "greeting_message": "你好!我是你的旅行规划助手,可以帮你规划行程、推荐景点和安排出行路线。", - "example_questions": ["帮我规划一个从上海到北京的三日旅行", "推荐一些适合家庭出游的景点", "明天去杭州有什么好玩的地方?"] - } - ``` - - 示例2(数据分析助手): - ```json - { - "greeting_message": "你好!我是数据分析助手,可以帮你处理和分析各种数据,提供可视化报告和洞察。", - "example_questions": ["帮我分析这组销售数据的趋势", "生成一份季度业绩对比报告", "哪些产品的利润率最高?"] - } - ``` - -USER_PROMPT: |- - ### 智能体名称: - {{display_name}} - - ### 智能体职责描述: - {{duty_description}} - - ### 业务描述: - {{business_description}} - - {% if few_shots %} - ### 已有示例(请从中提炼用户提问场景作为示例问题): - {{few_shots}} - {% endif %} - - 请根据以上信息生成开场白和示例问题。严格按JSON格式输出。 \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b8f51dd4c..dff0e8693 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "backend" version = "0.1.0" -requires-python = ">=3.11,<3.12" +requires-python = "==3.10.*" dependencies = [ "aiofiles>=0.8.0", "uvicorn>=0.34.0", @@ -11,7 +11,7 @@ dependencies = [ "aiohttp>=3.8.0", "authlib>=1.3.0", "cryptography>=42.0.0", - "psycopg2-binary>=2.9.9", + "psycopg2-binary==2.9.10", "PyJWT>=2.8.0", "sqlalchemy~=2.0.37", "greenlet<3.5.0", @@ -21,14 +21,10 @@ dependencies = [ "jsonref>=1.1.0", "ruamel-yaml==0.19.1", "redis>=5.0.0", - "fastmcp>=2.14.2,<3.0", + "fastmcp==2.12.0", "langchain>=0.3.26", "scikit-learn>=1.0.0", "numpy>=1.24.0", - "defusedxml>=0.7.1", - "openjiuwen>=0.1.0", - "pydantic-settings>=2.0.0", - "python-docx>=1.1.0", ] [project.optional-dependencies] @@ -38,7 +34,7 @@ data-process = [ "flower>=2.0.1", "nest_asyncio>=1.5.6", "unstructured[csv,docx,pdf,pptx,xlsx,md]==0.18.14", - "huggingface_hub>=0.30.0,<1.0" + "huggingface_hub>=0.19.0,<0.21.0" ] test = [ "pytest", diff --git a/backend/services/agent_repository_service.py b/backend/services/agent_repository_service.py deleted file mode 100644 index 87649bcd1..000000000 --- a/backend/services/agent_repository_service.py +++ /dev/null @@ -1,306 +0,0 @@ -import logging -from typing import Any, Dict, Optional - -from consts.const import ASSET_OWNER_TENANT_ID -from consts.model import AgentRepositorySnapshot -from database.agent_db import search_agent_info_by_agent_id -from database.agent_version_db import search_version_by_version_no -from database.agent_repository_db import ( - STATUS_PENDING_REVIEW, - VALID_REPOSITORY_STATUSES, - get_agent_repository_by_agent_id, - get_agent_repository_by_id, - insert_agent_repository_record, - list_agent_repository_summaries, - update_agent_repository_by_id, - update_agent_repository_status_by_id, -) -from services.agent_service import ( - collect_skill_zip_entries, - export_agent_dict_for_repository_impl, - import_agent_impl, - import_agent_with_skills_impl, -) - -logger = logging.getLogger("agent_repository_service") - -_UPDATE_SNAPSHOT_FIELDS = ( - "display_name", - "description", - "author", - "category_id", - "tags", - "tool_count", - "version_label", - "source_version_no", - "agent_info_json", - "status", -) - - -def _to_summary_item(record: Dict[str, Any]) -> Dict[str, Any]: - """Map a DB record to a lightweight marketplace summary item.""" - return { - "agent_repository_id": record.get("agent_repository_id"), - "author": record.get("author"), - "name": record.get("name"), - "display_name": record.get("display_name"), - "description": record.get("description"), - "status": record.get("status"), - } - - -def list_agent_repository_listings_impl( - *, - status: Optional[str] = None, -) -> Dict[str, Any]: - """List all repository listings with optional status filter.""" - if status is not None and status not in VALID_REPOSITORY_STATUSES: - raise ValueError( - f"Invalid status '{status}'; must be one of: " - f"{', '.join(sorted(VALID_REPOSITORY_STATUSES))}" - ) - records = list_agent_repository_summaries(status=status) - return {"items": [_to_summary_item(record) for record in records]} - - -def update_agent_repository_status_impl( - *, - agent_repository_id: int, - status: str, - user_id: str, -) -> Dict[str, Any]: - """Update a repository listing status by primary key.""" - if status not in VALID_REPOSITORY_STATUSES: - raise ValueError( - f"Invalid status '{status}'; must be one of: " - f"{', '.join(sorted(VALID_REPOSITORY_STATUSES))}" - ) - - record = get_agent_repository_by_id(agent_repository_id) - if not record: - raise ValueError("Repository listing not found") - - rows_affected = update_agent_repository_status_by_id( - repository_id=agent_repository_id, - status=status, - user_id=user_id, - ) - if rows_affected == 0: - raise ValueError("Repository listing not found") - - updated = get_agent_repository_by_id(agent_repository_id) - if not updated: - raise ValueError("Failed to load repository listing after update") - return _to_summary_item(updated) - - -def _to_list_item(record: Dict[str, Any]) -> Dict[str, Any]: - """Map a DB record to a marketplace list item (without heavy JSON blobs).""" - return { - "id": record.get("agent_repository_id"), - "agent_repository_id": record.get("agent_repository_id"), - "agent_id": record.get("agent_id"), - "name": record.get("name"), - "display_name": record.get("display_name"), - "description": record.get("description"), - "author": record.get("author"), - "category_id": record.get("category_id"), - "tags": record.get("tags") or [], - "tool_count": record.get("tool_count"), - "version_label": record.get("version_label"), - "status": record.get("status"), - "source_version_no": record.get("source_version_no"), - "publisher_tenant_id": record.get("publisher_tenant_id"), - "created_at": record.get("create_time"), - "updated_at": record.get("update_time"), - } - - -def _to_detail_item( - record: Dict[str, Any], - *, - include_bundles: bool = True, - is_updated: Optional[bool] = None, -) -> Dict[str, Any]: - """Map a DB record to a marketplace detail payload.""" - detail = _to_list_item(record) - if include_bundles: - detail["agent_info_json"] = record.get("agent_info_json") - if is_updated is not None: - detail["is_updated"] = is_updated - return detail - - -def _validate_create_payload(repository_data: Dict[str, Any]) -> None: - """Validate required fields before inserting a repository listing.""" - required_fields = ( - "agent_id", - "source_version_no", - "name", - "agent_info_json", - ) - missing = [ - field for field in required_fields - if field not in repository_data or repository_data[field] is None - ] - if missing: - raise ValueError(f"Missing required repository fields: {', '.join(missing)}") - if not repository_data.get("name"): - raise ValueError("name must be a non-empty string") - - agent_info_json = repository_data.get("agent_info_json") - if not isinstance(agent_info_json, dict): - raise ValueError("agent_info_json must be a JSON object") - for key in ("agent_id", "agent_info", "mcp_info"): - if key not in agent_info_json: - raise ValueError(f"agent_info_json must contain '{key}'") - - -def _validate_agent_info_json_shareable(agent_info_json: dict) -> None: - """Reject marketplace share when any agent in the tree belongs to ASSET_OWNER tenant.""" - agent_info_map = agent_info_json.get("agent_info") - if not isinstance(agent_info_map, dict): - return - for entry in agent_info_map.values(): - if not isinstance(entry, dict): - continue - if entry.get("tenant_id") == ASSET_OWNER_TENANT_ID: - raise ValueError("租户管理员智能体无法共享") - - -async def _build_agent_info_json( - agent_id: int, - tenant_id: str, - user_id: str, - version_no: int, -) -> dict: - """Build marketplace snapshot JSON via the agent export pipeline.""" - export_dict = await export_agent_dict_for_repository_impl( - agent_id=agent_id, - tenant_id=tenant_id, - user_id=user_id, - version_no=version_no, - ) - skills = collect_skill_zip_entries( - agent_id=agent_id, - tenant_id=tenant_id, - version_no=version_no, - ) - snapshot = AgentRepositorySnapshot( - **export_dict, - skills=skills or None, - ) - return snapshot.model_dump() - - -async def _build_repository_data_from_agent( - agent_id: int, - tenant_id: str, - user_id: str, - version_no: int, -) -> Dict[str, Any]: - """Build a repository upsert payload from a published agent version snapshot.""" - agent_info = search_agent_info_by_agent_id(agent_id, tenant_id, version_no) - agent_info_json = await _build_agent_info_json( - agent_id=agent_id, - tenant_id=tenant_id, - user_id=user_id, - version_no=version_no, - ) - _validate_agent_info_json_shareable(agent_info_json) - - version_meta = search_version_by_version_no(agent_id, tenant_id, version_no) - version_label = ( - version_meta.get("version_name") - if version_meta and version_meta.get("version_name") - else f"v{version_no}" - ) - - return { - "agent_id": agent_id, - "source_version_no": version_no, - "name": agent_info["name"], - "display_name": agent_info.get("display_name"), - "description": agent_info.get("description"), - "author": agent_info.get("author"), - "version_label": version_label, - "agent_info_json": agent_info_json, - "status": STATUS_PENDING_REVIEW, - } - - -async def create_agent_repository_listing_impl( - agent_id: int, - tenant_id: str, - user_id: str, - version_no: int, -) -> Dict[str, Any]: - """Create or update a repository listing from a published agent version. - - Loads agent metadata and builds agent_info_json via the export pipeline, - then inserts or updates the marketplace table. - - When a listing for the same agent_id already exists, snapshot fields are - updated via update_agent_repository_by_id. - """ - if version_no < 0: - raise ValueError("version_no must be >= 0") - - repository_data = await _build_repository_data_from_agent( - agent_id, tenant_id, user_id, version_no - ) - _validate_create_payload(repository_data) - - existing = get_agent_repository_by_agent_id(agent_id) - if not existing: - repository_id = insert_agent_repository_record( - repository_data=repository_data, - publisher_tenant_id=tenant_id, - publisher_user_id=user_id, - ) - is_updated = False - else: - repository_id = int(existing["agent_repository_id"]) - updates = { - key: repository_data[key] - for key in _UPDATE_SNAPSHOT_FIELDS - if key in repository_data - } - affected = update_agent_repository_by_id( - repository_id=repository_id, - publisher_tenant_id=tenant_id, - user_id=user_id, - updates=updates, - ) - if affected == 0: - raise ValueError("Failed to update repository listing") - is_updated = True - - record = get_agent_repository_by_id(repository_id) - if not record: - raise ValueError("Failed to load repository listing after write") - return _to_detail_item(record, is_updated=is_updated) - - -async def import_agent_from_repository_impl( - agent_repository_id: int, - authorization: str, -) -> Dict[int, int]: - """Import an agent tree from a marketplace repository listing into the current tenant.""" - record = get_agent_repository_by_id(agent_repository_id) - if not record: - raise ValueError("Repository listing not found") - - agent_info_json = record.get("agent_info_json") - if not isinstance(agent_info_json, dict): - raise ValueError("Repository listing has no agent snapshot") - - snapshot = AgentRepositorySnapshot.model_validate(agent_info_json) - if snapshot.skills: - return await import_agent_with_skills_impl( - snapshot, - snapshot.skills, - authorization, - ) - return await import_agent_impl(snapshot, authorization) diff --git a/backend/services/agent_service.py b/backend/services/agent_service.py index 643d1995e..5a340b1d6 100644 --- a/backend/services/agent_service.py +++ b/backend/services/agent_service.py @@ -22,8 +22,7 @@ from utils.prompt_template_utils import normalize_prompt_generate_template_content from consts.const import MEMORY_SEARCH_START_MSG, MEMORY_SEARCH_DONE_MSG, MEMORY_SEARCH_FAIL_MSG, TOOL_TYPE_MAPPING, \ LANGUAGE, MESSAGE_ROLE, MODEL_CONFIG_MAPPING, CAN_EDIT_ALL_USER_ROLES, PERMISSION_EDIT, PERMISSION_READ, PERMISSION_PRIVATE -from consts.exceptions import AppException, MemoryPreparationException, SkillDuplicateError -from consts.error_code import ErrorCode +from consts.exceptions import MemoryPreparationException, SkillDuplicateError from consts.agent_unavailable_reasons import AgentUnavailableReason from consts.model import ( AgentInfoRequest, @@ -46,9 +45,7 @@ delete_related_agent, insert_related_agent, query_all_agent_info_by_tenant_id, - query_sub_agent_relations, query_sub_agents_id_list, - resolve_sub_agent_version_no, search_agent_id_by_agent_name, search_agent_info_by_agent_id, search_blank_sub_agent_by_main_agent_id, @@ -70,10 +67,8 @@ search_tools_for_sub_agent ) from database import skill_db -from database.attachment_db import upload_fileobj from services.skill_service import SkillService -from services.file_management_service import is_allowed_skill_upload_path -from database.agent_version_db import query_version_list, query_current_version_no +from database.agent_version_db import query_version_list from database.group_db import query_group_ids_by_user from database.user_tenant_db import get_user_tenant_by_user_id from database.a2a_agent_db import get_server_agent_ids, query_external_sub_agents @@ -83,7 +78,7 @@ get_prompt_template_summary, ) from utils.str_utils import convert_list_to_string, convert_string_to_list -from services.conversation_management_service import save_conversation_assistant, save_conversation_user, save_skill_files_to_conversation +from services.conversation_management_service import save_conversation_assistant, save_conversation_user from services.memory_config_service import build_memory_context from utils.auth_utils import get_current_user_info, get_user_language from utils.config_utils import tenant_config_manager @@ -102,139 +97,9 @@ SAFE_AGENT_STREAM_ERROR_MESSAGE = "Agent execution failed. Please try again later." -def _extract_json_objects_from_text(text: str) -> list[dict]: - """Extract all JSON objects embedded in a text blob.""" - if not text: - return [] - - decoder = json.JSONDecoder() - results: list[dict] = [] - index = 0 - - while index < len(text): - start_index = text.find("{", index) - if start_index < 0: - break - - try: - payload, end_index = decoder.raw_decode(text, start_index) - except json.JSONDecodeError: - index = start_index + 1 - continue - - if isinstance(payload, dict): - results.append(payload) - index = max(end_index, start_index + 1) - - return results - - -def _extract_skill_file_upload_payloads(content: str) -> list[dict]: - """Extract JSON payloads containing absolute_path from streamed tool output.""" - payloads: list[dict] = [] - for payload in _extract_json_objects_from_text(content): - if payload.get("absolute_path"): - payloads.append(payload) - return payloads - - -def _transform_skill_files_to_standard_format(upload_results: list[dict]) -> list[dict]: - """ - Transform skill file upload results to match the frontend attachment format. - - Skill upload format: - {file_name, absolute_path, object_name, preview_url, url, presigned_url, mime_type, file_size, status} - Frontend format: - {object_name, name, type, size, url, presigned_url, description} - """ - frontend_files = [] - for result in upload_results: - frontend_files.append({ - "object_name": result.get("object_name", ""), - "name": result.get("file_name", result.get("name", "")), - "type": "file", - "size": result.get("file_size", result.get("size", 0)), - "url": result.get("url", ""), - "presigned_url": result.get("presigned_url", result.get("preview_url", "")), - "description": "", - }) - return frontend_files - - -async def _process_skill_file_uploads( - content: str, - user_id: str, - tenant_id: str, -) -> list[dict]: - """Upload generated skill files to storage and return upload metadata.""" - - upload_results: list[dict] = [] - for payload in _extract_skill_file_upload_payloads(content): - absolute_path = str(payload.get("absolute_path") or "").strip() - file_name = str( - payload.get("file_name") - or payload.get("file_path") - or os.path.basename(absolute_path) - ) - mime_type = str(payload.get("mime_type") or payload.get("content_type") or "application/octet-stream") - if not absolute_path: - continue - - if not is_allowed_skill_upload_path(absolute_path): - logger.warning( - "[skill-file] rejected unsafe path absolute_path=%s", - absolute_path, - ) - continue - - if not file_name: - file_name = os.path.basename(absolute_path) - - if not os.path.exists(absolute_path): - continue - - try: - file_size = os.path.getsize(absolute_path) - actual_prefix = f"skill-files/{user_id}" if user_id else "skill-files" - with open(absolute_path, "rb") as file_obj: - upload_result = upload_fileobj( - file_obj=file_obj, - file_name=file_name, - prefix=actual_prefix, - generate_presigned_url=True, - file_size=file_size, - ) - - if upload_result.get("success"): - upload_results.append( - { - "status": "success", - "file_name": file_name, - "absolute_path": absolute_path, - "object_name": upload_result.get("object_name"), - "preview_url": upload_result.get("presigned_url") or upload_result.get("url"), - "url": upload_result.get("url"), - "presigned_url": upload_result.get("presigned_url"), - "mime_type": mime_type, - "file_size": upload_result.get("file_size", file_size), - } - ) - else: - error_message = upload_result.get("error") or "Upload failed" - logger.warning( - "[skill-file] upload failed file_name=%s absolute_path=%s error=%s", - file_name, - absolute_path, - error_message, - ) - except Exception as exc: - logger.exception( - "[skill-file] failed to upload file file_name=%s absolute_path=%s", - file_name, - absolute_path, - ) - - return upload_results +# ------------------------------------------------------------- +# Internal helper functions +# ------------------------------------------------------------- def _safe_agent_stream_error_chunk() -> str: @@ -782,53 +647,23 @@ async def _stream_agent_chunks( agent_run_info, memory_ctx, ): - """Yield SSE chunks from agent_run while persisting messages and cleanup.""" + """Yield SSE chunks from agent_run while persisting messages & cleanup. + + This utility centralizes the common streaming logic used by both + generate_stream_with_memory and generate_stream_no_memory so that the code + is easier to maintain and less error-prone. + """ local_messages = [] captured_final_answer = None - captured_skill_files: dict[str, dict] = {} - skill_file_uploads: list[dict] = [] try: async for chunk in agent_run(agent_run_info): local_messages.append(chunk) + # Try to capture the final answer as it streams by in order to start memory addition try: data = json.loads(chunk) - chunk_type = data.get("type") - if chunk_type == "final_answer": + if data.get("type") == "final_answer": captured_final_answer = data.get("content") - - should_parse_skill_file = chunk_type in {"execution_logs", "parse"} or data.get("role") == "tool-response" - if should_parse_skill_file: - extracted_payload_count = 0 - content_value = data.get("content") - if isinstance(content_value, list): - content_items = content_value - elif content_value: - content_items = [{"type": "text", "text": str(content_value)}] - else: - content_items = [] - - for item in content_items: - if isinstance(item, dict) and item.get("type") == "text": - text_value = item.get("text") - if text_value: - extracted_payloads = _extract_json_objects_from_text(text_value) - for payload in extracted_payloads: - absolute_path = str(payload.get("absolute_path") or "").strip() - if not absolute_path: - continue - if absolute_path in captured_skill_files: - continue - if not os.path.exists(absolute_path): - continue - captured_skill_files[absolute_path] = payload - extracted_payload_count += 1 - if extracted_payload_count: - logger.info( - "[skill-file] captured payloads count=%s current_total=%s", - extracted_payload_count, - len(captured_skill_files), - ) except Exception: pass yield f"data: {chunk}\n\n" @@ -836,6 +671,7 @@ async def _stream_agent_chunks( logger.error("Agent run error: %r", run_exc, exc_info=True) yield _safe_agent_stream_error_chunk() finally: + # Persist assistant messages for non-debug runs if not agent_request.is_debug: save_messages( agent_request, @@ -844,54 +680,11 @@ async def _stream_agent_chunks( tenant_id=tenant_id, user_id=user_id, ) + # Always unregister the run to release resources agent_run_manager.unregister_agent_run( agent_request.conversation_id, user_id) - try: - skill_file_content_local = "\n".join( - json.dumps(payload, ensure_ascii=False) - for payload in captured_skill_files.values() - ) - if skill_file_content_local: - skill_file_uploads = await _process_skill_file_uploads( - content=skill_file_content_local, - user_id=user_id, - tenant_id=tenant_id, - ) - logger.info( - "[skill-file] upload finished conversation=%s result_count=%s results=%s", - agent_request.conversation_id, - len(skill_file_uploads), skill_file_uploads - ) - if skill_file_uploads: - # Keep original format for real-time SSE display - skill_files_payload = json.dumps( - {"skill_file_uploads": skill_file_uploads}, - ensure_ascii=False, - ) - try: - yield f"data: {json.dumps({'type': 'skill_files', 'content': skill_files_payload}, ensure_ascii=False)}\n\n" - except RuntimeError: - # Stream is closing (e.g., client disconnect). Avoid raising during generator teardown. - pass - # Persist skill file uploads to the conversation history so they - # appear in subsequent GET /conversation/{id} calls. - # Transform to frontend attachment format (object_name, name, type, size, etc.) - try: - frontend_files = _transform_skill_files_to_standard_format(skill_file_uploads) - save_skill_files_to_conversation( - conversation_id=agent_request.conversation_id, - skill_file_uploads=frontend_files, - user_id=user_id, - ) - except Exception: - logger.exception( - "[skill-file] failed to persist skill file uploads to conversation=%s", - agent_request.conversation_id, - ) - except Exception: - logger.exception("Failed to process skill file uploads") - + # Schedule memory addition in background to avoid blocking SSE termination async def _add_memory_background(): try: # Skip if memory recording is disabled @@ -986,13 +779,14 @@ async def get_agent_info_impl(agent_id: int, tenant_id: str, version_no: int = 0 user_role = str(user_tenant_record.get("user_role") or "").upper() can_edit_all = user_role in CAN_EDIT_ALL_USER_ROLES - # Permission logic (same as agent list, including ASSET_OWNER read-only override) - agent_info["permission"] = resolve_agent_list_permission( - user_role=user_role, - agent=agent_info, - user_id=user_id, - can_edit_all=can_edit_all, - ) + # Permission logic (same as agent list): + # - If creator or can_edit_all: PERMISSION_EDIT + # - Otherwise: use ingroup_permission, default to PERMISSION_READ if None + if can_edit_all or str(agent_info.get("created_by")) == str(user_id): + agent_info["permission"] = PERMISSION_EDIT + else: + ingroup_permission = agent_info.get("ingroup_permission") + agent_info["permission"] = ingroup_permission if ingroup_permission is not None else PERMISSION_READ except Exception as e: logger.warning(f"Failed to calculate agent permission: {str(e)}") @@ -1068,12 +862,6 @@ async def get_agent_info_impl(agent_id: int, tenant_id: str, version_no: int = 0 agent_info["is_available"] = is_available agent_info["unavailable_reasons"] = unavailable_reasons - # Set current_version_no from draft record (version_no=0) - # This ensures the returned data always has the current published version info - if version_no > 0: - draft_version_no = query_current_version_no(agent_id, tenant_id) - agent_info["current_version_no"] = draft_version_no - return agent_info @@ -1118,10 +906,6 @@ async def get_creating_sub_agent_info_impl(authorization: str = Header(None)): async def update_agent_info_impl(request: AgentInfoRequest, authorization: str = Header(None)): user_id, tenant_id, _ = get_current_user_info(authorization) - - if request.example_questions is not None and len(request.example_questions) > 6: - raise AppException(ErrorCode.COMMON_PARAMETER_INVALID, "example_questions cannot exceed 6 items") - prompt_template_id, prompt_template_name = get_prompt_template_summary( template_id=request.prompt_template_id, tenant_id=tenant_id, @@ -1148,12 +932,9 @@ async def update_agent_info_impl(request: AgentInfoRequest, authorization: str = "prompt_template_name": prompt_template_name, "max_steps": request.max_steps, "provide_run_summary": request.provide_run_summary, - "verification_config": request.verification_config, "duty_prompt": request.duty_prompt, "constraint_prompt": request.constraint_prompt, "few_shots_prompt": request.few_shots_prompt, - "greeting_message": request.greeting_message, - "example_questions": request.example_questions, "enabled": request.enabled if request.enabled is not None else True, "group_ids": convert_list_to_string(request.group_ids) if request.group_ids else user_group_ids, "ingroup_permission": request.ingroup_permission @@ -1421,216 +1202,76 @@ async def clear_agent_memory(agent_id: int, tenant_id: str, user_id: str): # Silently fail to maintain agent deletion process -async def _export_agent_dict_core( - root_agent_id: int, - tenant_id: str, - user_id: str, - version_no: int = 0, -) -> dict: - """Build ExportAndImportDataFormat dict for an agent tree at the given version.""" +async def export_agent_impl(agent_id: int, authorization: str = Header(None)) -> str: + """ + Export the configuration information of the specified agent and all its sub-agents. + + Args: + agent_id (int): The ID of the agent to export. + authorization (str): User authentication information, obtained from the Header. + + Returns: + str: A formatted JSON string containing the configuration information of the agent and all its sub-agents. + + Data Structure Example: + model.py ExportAndImportDataFormat + + Note: + This function recursively finds all managed sub-agents and exports the detailed configuration of each agent (including tools, prompts, etc.) as a dictionary, and finally returns it as a formatted JSON string for frontend download and backup. + """ + + user_id, tenant_id, _ = get_current_user_info(authorization) + export_agent_dict = {} - search_list: deque = deque([(root_agent_id, version_no)]) - visited: set = set() + search_list = deque([agent_id]) + agent_id_set = set() mcp_info_set = set() - while search_list: - current_agent_id, current_version_no = search_list.popleft() - visit_key = (current_agent_id, current_version_no) - if visit_key in visited: + while len(search_list): + left_ele = search_list.popleft() + if left_ele in agent_id_set: continue - visited.add(visit_key) - agent_info = await export_agent_by_agent_id( - agent_id=current_agent_id, - tenant_id=tenant_id, - user_id=user_id, - version_no=current_version_no, - ) + agent_id_set.add(left_ele) + agent_info = await export_agent_by_agent_id(agent_id=left_ele, tenant_id=tenant_id, user_id=user_id) + # collect mcp name for tool in agent_info.tools: if tool.source == "mcp" and tool.usage: mcp_info_set.add(tool.usage) - relations = query_sub_agent_relations( - main_agent_id=current_agent_id, - tenant_id=tenant_id, - version_no=current_version_no, - ) - for rel in relations: - child_id = rel["selected_agent_id"] - child_version = resolve_sub_agent_version_no( - child_id, - rel.get("selected_agent_version_no"), - tenant_id, - ) - search_list.append((child_id, child_version)) - + search_list.extend(agent_info.managed_agents) export_agent_dict[str(agent_info.agent_id)] = agent_info + # convert mcp info to MCPInfo list mcp_info_list = [] for mcp_server_name in mcp_info_set: + # get mcp url by mcp_server_name and tenant_id mcp_url = get_mcp_server_by_name_and_tenant(mcp_server_name, tenant_id) mcp_info_list.append( MCPInfo(mcp_server_name=mcp_server_name, mcp_url=mcp_url)) export_data = ExportAndImportDataFormat( - agent_id=root_agent_id, - agent_info=export_agent_dict, - mcp_info=mcp_info_list, - ) - return export_data.model_dump() + agent_id=agent_id, agent_info=export_agent_dict, mcp_info=mcp_info_list) + return json.dumps(export_data.model_dump()) -async def export_agent_dict_impl( - agent_id: int, - authorization: str = Header(None), - version_no: int = 0, -) -> dict: +async def export_agent_by_agent_id(agent_id: int, tenant_id: str, user_id: str) -> ExportAndImportAgentInfo: """ - Export the configuration information of the specified agent and all its sub-agents. - - Args: - agent_id (int): The ID of the agent to export. - authorization (str): User authentication information, obtained from the Header. - version_no (int): Version to export. Default 0 = draft. - - Returns: - dict: ExportAndImportDataFormat as a plain dict (via model_dump). + Export a single agent's information based on agent_id """ - user_id, tenant_id, _ = get_current_user_info(authorization) - return await _export_agent_dict_core( - root_agent_id=agent_id, - tenant_id=tenant_id, - user_id=user_id, - version_no=version_no, - ) - - -async def export_agent_dict_for_repository_impl( - agent_id: int, - tenant_id: str, - user_id: str, - version_no: int, -) -> dict: - """Export agent tree for marketplace repository storage (no HTTP auth header).""" - return await _export_agent_dict_core( - root_agent_id=agent_id, - tenant_id=tenant_id, - user_id=user_id, - version_no=version_no, - ) - - -async def export_agent_impl( - agent_id: int, - authorization: str = Header(None), - version_no: int = 0, -) -> str: - """Serialize export_agent_dict_impl output to a JSON string for download or ZIP embedding.""" - agent_dict = await export_agent_dict_impl( - agent_id, authorization, version_no=version_no - ) - return json.dumps(agent_dict) - - -def _collect_skill_names_from_tree( - agent_id: int, - tenant_id: str, - version_no: int, - visited: Optional[set] = None, -) -> List[str]: - """Collect unique skill names from an agent tree at the given version.""" - if visited is None: - visited = set() - - skill_names: List[str] = [] - seen_names: set = set() - - def _walk(current_agent_id: int, current_version_no: int) -> None: - visit_key = (current_agent_id, current_version_no) - if visit_key in visited: - return - visited.add(visit_key) - - skill_instances = skill_db.query_skill_instances_by_agent_id( - agent_id=current_agent_id, - tenant_id=tenant_id, - version_no=current_version_no, - ) - for inst in skill_instances: - skill_id = inst.get("skill_id") - skill = skill_db.get_skill_by_id(skill_id, tenant_id) - if skill: - name = skill.get("name") - if name and name not in seen_names: - seen_names.add(name) - skill_names.append(name) - - relations = query_sub_agent_relations( - main_agent_id=current_agent_id, - tenant_id=tenant_id, - version_no=current_version_no, - ) - for rel in relations: - child_id = rel["selected_agent_id"] - child_version = resolve_sub_agent_version_no( - child_id, - rel.get("selected_agent_version_no"), - tenant_id, - ) - _walk(child_id, child_version) - - _walk(agent_id, version_no) - return skill_names - - -def collect_skill_zip_entries( - agent_id: int, - tenant_id: str, - version_no: int = 0, -) -> List[SkillZipEntry]: - """Export skill ZIP payloads for all skills in an agent tree.""" - skill_names = _collect_skill_names_from_tree(agent_id, tenant_id, version_no) - if not skill_names: - return [] - - skill_service = SkillService(tenant_id=tenant_id) - exported = skill_service.export_skills_by_names(skill_names, tenant_id) - return [ - SkillZipEntry( - skill_name=entry["skill_name"], - skill_zip_base64=entry["skill_zip_base64"], - ) - for entry in exported - ] - - -async def export_agent_by_agent_id( - agent_id: int, - tenant_id: str, - user_id: str, - version_no: int = 0, -) -> ExportAndImportAgentInfo: - """Export a single agent's information based on agent_id and version_no.""" agent_info = search_agent_info_by_agent_id( - agent_id=agent_id, tenant_id=tenant_id, version_no=version_no - ) + agent_id=agent_id, tenant_id=tenant_id) agent_relation_in_db = query_sub_agents_id_list( - main_agent_id=agent_id, tenant_id=tenant_id, version_no=version_no - ) - tool_list = await create_tool_config_list( - agent_id=agent_id, - tenant_id=tenant_id, - user_id=user_id, - version_no=version_no, - ) + main_agent_id=agent_id, tenant_id=tenant_id) + tool_list = await create_tool_config_list(agent_id=agent_id, tenant_id=tenant_id, user_id=user_id) # Collect skill names from skill instances skill_names: List[str] = [] try: skill_instances = skill_db.query_skill_instances_by_agent_id( - agent_id=agent_id, tenant_id=tenant_id, version_no=version_no + agent_id=agent_id, tenant_id=tenant_id, version_no=0 ) for inst in skill_instances: skill_id = inst.get("skill_id") @@ -1666,7 +1307,6 @@ async def export_agent_by_agent_id( "display_name") if business_logic_model_info is not None else None agent_info = ExportAndImportAgentInfo(agent_id=agent_id, - tenant_id=agent_info["tenant_id"], name=agent_info["name"], display_name=agent_info["display_name"], description=agent_info["description"], @@ -1674,7 +1314,6 @@ async def export_agent_by_agent_id( author=agent_info.get("author"), max_steps=agent_info["max_steps"], provide_run_summary=agent_info["provide_run_summary"], - verification_config=agent_info.get("verification_config"), duty_prompt=agent_info.get( "duty_prompt"), constraint_prompt=agent_info.get( @@ -1829,7 +1468,6 @@ async def import_agent_by_agent_id( "prompt_template_name": import_agent_info.prompt_template_name or SYSTEM_PROMPT_TEMPLATE_NAME, "max_steps": import_agent_info.max_steps, "provide_run_summary": import_agent_info.provide_run_summary, - "verification_config": getattr(import_agent_info, "verification_config", None), "duty_prompt": import_agent_info.duty_prompt, "constraint_prompt": import_agent_info.constraint_prompt, "few_shots_prompt": import_agent_info.few_shots_prompt, @@ -2197,7 +1835,6 @@ async def prepare_agent_run( is_debug=agent_request.is_debug, override_version_no=agent_request.version_no, override_model_id=agent_request.model_id, - tool_params=agent_request.tool_params, ) # Mount conversation-level reusable ContextManager if enabled @@ -2643,45 +2280,52 @@ def get_sub_agents_recursive(parent_agent_id: int, depth: int = 0, max_depth: in raise ValueError(f"Failed to get agent call relationship: {str(e)}") -async def export_agent_with_skills_impl( - agent_id: int, - authorization: str, - version_no: int = 0, -) -> dict: - """Export an agent, returning a ZIP if it has skill instances, otherwise a plain dict. +async def export_agent_with_skills_impl(agent_id: int, authorization: str) -> dict: + """Export an agent, returning a ZIP if it has skill instances, otherwise plain JSON. The response is either: - A dict with {"_zip": True, "data": bytes, "filename": str} when the agent has skills - - ExportAndImportDataFormat as a plain dict when the agent has no skills + - A plain dict (JSON string) when the agent has no skills """ + from services.skill_service import SkillService + user_id, tenant_id, _ = get_current_user_info(authorization) - skill_zip_entries = collect_skill_zip_entries( - agent_id=agent_id, tenant_id=tenant_id, version_no=version_no + skill_instances = skill_db.query_skill_instances_by_agent_id( + agent_id=agent_id, tenant_id=tenant_id, version_no=0 ) - if not skill_zip_entries: - return await export_agent_dict_impl( - agent_id, authorization, version_no=version_no - ) + if not skill_instances: + return await export_agent_impl(agent_id, authorization) - agent_json_str = await export_agent_impl( - agent_id, authorization, version_no=version_no - ) + skill_names = [] + for inst in skill_instances: + skill_id = inst.get("skill_id") + skill = skill_db.get_skill_by_id(skill_id, tenant_id) + if skill: + skill_names.append(skill.get("name")) + + if not skill_names: + return await export_agent_impl(agent_id, authorization) + + agent_json_str = await export_agent_impl(agent_id, authorization) + + skill_service = SkillService(tenant_id=tenant_id) + skill_zip_entries = skill_service.export_skills_by_names( + skill_names, tenant_id) zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: zf.writestr("agent.json", agent_json_str) for entry in skill_zip_entries: - skill_zip_bytes = base64.b64decode(entry.skill_zip_base64) - zf.writestr(f"skills/{entry.skill_name}.zip", skill_zip_bytes) + skill_zip_bytes = base64.b64decode(entry["skill_zip_base64"]) + zf.writestr(f"skills/{entry['skill_name']}.zip", skill_zip_bytes) zip_buffer.seek(0) zip_data = zip_buffer.read() agent_info = search_agent_info_by_agent_id( - agent_id=agent_id, tenant_id=tenant_id, version_no=version_no - ) + agent_id=agent_id, tenant_id=tenant_id) agent_name = agent_info.get( "name", "anonymous") if agent_info else "anonymous" diff --git a/backend/services/agent_version_service.py b/backend/services/agent_version_service.py index 8ed6e14d4..d7096727b 100644 --- a/backend/services/agent_version_service.py +++ b/backend/services/agent_version_service.py @@ -49,17 +49,6 @@ def _remove_audit_fields_for_insert(data: dict) -> None: data.pop('delete_flag', None) -def _build_sub_agent_relations(relations: List[dict]) -> List[dict]: - """Map relation snapshots to sub-agent relation payloads for API responses.""" - return [ - { - 'agent_id': r['selected_agent_id'], - 'version_no': r.get('selected_agent_version_no'), - } - for r in relations - ] - - def publish_version_impl( agent_id: int, tenant_id: str, @@ -103,18 +92,11 @@ def publish_version_impl( _remove_audit_fields_for_insert(tool_snapshot) insert_tool_snapshot(tool_snapshot) - # Insert relation snapshots with pinned child agent versions + # Insert relation snapshots for rel in relations_draft: - child_id = rel['selected_agent_id'] - child_version = query_current_version_no(child_id, tenant_id) - if child_version is None: - raise ValueError( - f"Sub-agent {child_id} has no published version; publish the sub-agent first." - ) rel_snapshot = rel.copy() rel_snapshot.pop('version_no', None) rel_snapshot['version_no'] = new_version_no - rel_snapshot['selected_agent_version_no'] = child_version _remove_audit_fields_for_insert(rel_snapshot) insert_relation_snapshot(rel_snapshot) @@ -289,7 +271,6 @@ def get_version_detail_impl( # Extract sub_agent_id_list from relations result['sub_agent_id_list'] = [r['selected_agent_id'] for r in relations_snapshot] - result['sub_agent_relations'] = _build_sub_agent_relations(relations_snapshot) # Get skill instances for this version (from ag_skill_instance_t with version_no) from database import skill_db as skill_db_module @@ -729,7 +710,6 @@ def _get_version_detail_or_draft( # Add tools (only enabled tools) result['tools'] = [t for t in tools_draft if t.get('enabled', True)] result['sub_agent_id_list'] = [r['selected_agent_id'] for r in relations_draft] - result['sub_agent_relations'] = _build_sub_agent_relations(relations_draft) # Get draft skill instances (version_no=0) skills_draft = skill_db_module.query_skill_instances_by_agent_id( @@ -803,11 +783,12 @@ async def list_published_agents_impl( CAN_EDIT_ALL_USER_ROLES, get_user_tenant_by_user_id, query_group_ids_by_user, + PERMISSION_EDIT, + PERMISSION_READ, get_model_by_model_id, check_agent_availability, _apply_duplicate_name_availability_rules, ) - from services.asset_owner_visibility import resolve_agent_list_permission from database.agent_version_db import query_agent_snapshot # Get user role for permission check @@ -877,10 +858,9 @@ async def list_published_agents_impl( # Extract sub_agent_id_list from relations agent_info['sub_agent_id_list'] = [r['selected_agent_id'] for r in relations_snapshot] - agent_info['sub_agent_relations'] = _build_sub_agent_relations(relations_snapshot) - # Add current version info - agent_info['current_version_no'] = current_version_no + # Add published version info + agent_info['published_version_no'] = current_version_no # Check agent availability using the shared function _, unavailable_reasons = check_agent_availability( @@ -913,12 +893,7 @@ async def list_published_agents_impl( model_cache[model_id] = get_model_by_model_id(model_id, tenant_id) model_info = model_cache.get(model_id) - permission = resolve_agent_list_permission( - user_role=user_role, - agent=agent, - user_id=user_id, - can_edit_all=can_edit_all, - ) + permission = PERMISSION_EDIT if can_edit_all or str(agent.get("created_by")) == str(user_id) else PERMISSION_READ simple_agent_list.append({ "agent_id": agent.get("agent_id"), @@ -934,9 +909,7 @@ async def list_published_agents_impl( "is_new": agent.get("is_new", False), "group_ids": agent.get("group_ids", []), "permission": permission, - "current_version_no": agent.get("current_version_no"), - "greeting_message": agent.get("greeting_message"), - "example_questions": agent.get("example_questions"), + "published_version_no": agent.get("published_version_no"), }) return simple_agent_list diff --git a/backend/services/cas_service.py b/backend/services/cas_service.py deleted file mode 100644 index 7db3fce1a..000000000 --- a/backend/services/cas_service.py +++ /dev/null @@ -1,424 +0,0 @@ -import json -import logging -import os -import secrets -import ssl -import urllib.parse -import urllib.request -from xml.etree.ElementTree import Element -from dataclasses import dataclass -from datetime import datetime, timedelta -from typing import Any, Dict, Optional - -import defusedxml.ElementTree as ET -from defusedxml.common import DefusedXmlException - -from consts.const import ( - CAS_CA_BUNDLE, - CAS_CALLBACK_BASE_URL, - CAS_EMAIL_ATTRIBUTE, - CAS_ENABLED, - CAS_LOGIN_MODE, - CAS_LOGOUT_URL, - CAS_RENEW_BEFORE_SECONDS, - CAS_RENEW_TIMEOUT_SECONDS, - CAS_ROLE_ATTRIBUTE, - CAS_ROLE_MAP_JSON, - CAS_SERVER_URL, - CAS_SESSION_MAX_AGE_SECONDS, - CAS_SSL_VERIFY, - CAS_SYNTHETIC_EMAIL_DOMAIN, - CAS_TENANT_ATTRIBUTE, - CAS_USER_ATTRIBUTE, - CAS_VALIDATE_PATH, - DEFAULT_TENANT_ID, - LOCAL_SESSION_MAX_AGE_SECONDS, -) -from database.cas_session_db import ( - create_cas_session, - revoke_cas_session_by_index, - revoke_cas_sessions_by_user_id, -) -from database.oauth_account_db import get_oauth_account_by_provider -from database.user_tenant_db import get_user_tenant_by_user_id, upsert_user_tenant -from services.oauth_service import ( - create_or_update_oauth_account, - find_supabase_user_id_by_email, -) -from services.skill_service import init_skill_list_for_tenant -from services.tool_configuration_service import init_tool_list_for_tenant -from utils.auth_utils import calculate_expires_at, generate_session_jwt, get_supabase_admin_client - -logger = logging.getLogger(__name__) - -CAS_PROVIDER = "cas" -VALID_ROLES = {"SU", "ADMIN", "DEV", "USER"} - - -class CasAuthenticationError(Exception): - pass - - -@dataclass -class CasPrincipal: - cas_user_id: str - email: str - username: str - role: str - tenant_id: str - session_index: str - expires_at: datetime - - -def get_cas_config() -> Dict[str, Any]: - mode = CAS_LOGIN_MODE if CAS_LOGIN_MODE in {"button", "force", "disabled"} else "disabled" - enabled = CAS_ENABLED and bool(CAS_SERVER_URL) - if not enabled: - mode = "disabled" - return { - "enabled": enabled, - "login_mode": mode, - "renew_before_seconds": CAS_RENEW_BEFORE_SECONDS, - "renew_timeout_seconds": CAS_RENEW_TIMEOUT_SECONDS, - "display_name": "CAS", - } - - -def build_login_url(redirect: str = "/") -> str: - _ensure_enabled() - service_url = _build_callback_url("/api/user/cas/callback", {"redirect": _normalize_redirect(redirect)}) - return f"{CAS_SERVER_URL}/login?service={service_url}" - - -def build_renew_url() -> str: - _ensure_enabled() - service_url = _build_callback_url("/api/user/cas/renew_callback", {}) - return f"{CAS_SERVER_URL}/login?service={service_url}&gateway=true" - - -def build_logout_url() -> str: - _ensure_enabled() - configured_logout_url = CAS_LOGOUT_URL.strip() - if not configured_logout_url: - return "" - - parsed_config = urllib.parse.urlsplit(configured_logout_url) - if parsed_config.scheme and parsed_config.netloc: - logout_url = configured_logout_url - else: - logout_url = f"{CAS_SERVER_URL}/{configured_logout_url.lstrip('/')}" - - parsed = urllib.parse.urlsplit(logout_url) - if parsed.query: - return logout_url - - query = f"service={CAS_CALLBACK_BASE_URL}" - return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path, query, parsed.fragment)) - - -async def login_with_ticket(ticket: str, redirect: str = "/") -> Dict[str, Any]: - redirect = _normalize_redirect(redirect) - service_url = _build_callback_url("/api/user/cas/callback", {"redirect": redirect}) - principal = validate_service_ticket(ticket, service_url) - return await _create_project_session(principal, redirect=redirect) - - -async def renew_with_ticket(ticket: str) -> Dict[str, Any]: - service_url = _build_callback_url("/api/user/cas/renew_callback", {}) - principal = validate_service_ticket(ticket, service_url) - return await _create_project_session(principal, redirect="/", renew=True) - - -def validate_service_ticket(ticket: str, service_url: str) -> CasPrincipal: - _ensure_enabled() - if not ticket: - raise CasAuthenticationError("CAS ticket is missing") - - validate_path = CAS_VALIDATE_PATH if CAS_VALIDATE_PATH.startswith("/") else f"/{CAS_VALIDATE_PATH}" - validate_url = f"{CAS_SERVER_URL}{validate_path}" - xml_text = _http_get_text(f"{validate_url}?service={service_url}&ticket={ticket}") - logger.info("CAS serviceValidate response: %s", xml_text) - return parse_service_validate_response(xml_text, fallback_session_index=ticket) - - -def parse_service_validate_response(xml_text: str, fallback_session_index: str = "") -> CasPrincipal: - try: - root = ET.fromstring(xml_text) - except (ET.ParseError, DefusedXmlException) as exc: - raise CasAuthenticationError("Invalid CAS validation response") from exc - - failure = _find_first(root, "authenticationFailure") - if failure is not None: - raise CasAuthenticationError((failure.text or "CAS authentication failed").strip()) - - success = _find_first(root, "authenticationSuccess") - if success is None: - raise CasAuthenticationError("CAS authentication failed") - - user = _get_child_text(success, "user") - attrs_node = _find_first(success, "attributes") - attrs = _extract_attributes(attrs_node) if attrs_node is not None else {} - - cas_user_id = _attribute_or_default(attrs, CAS_USER_ATTRIBUTE, user) or user - if not cas_user_id: - raise CasAuthenticationError("CAS user id is missing") - - email = _attribute_or_default(attrs, CAS_EMAIL_ATTRIBUTE, "") - username = attrs.get("displayName") or attrs.get("name") or cas_user_id - role = _map_role(_attribute_or_default(attrs, CAS_ROLE_ATTRIBUTE, "USER")) - tenant_id = _attribute_or_default(attrs, CAS_TENANT_ATTRIBUTE, DEFAULT_TENANT_ID) or DEFAULT_TENANT_ID - session_index = attrs.get("SessionIndex") or attrs.get("sessionIndex") or fallback_session_index - expires_at = _resolve_expires_at(attrs) - - if not email: - safe_user = "".join(c if c.isalnum() or c in ("-", "_", ".") else "_" for c in cas_user_id) - email = f"{safe_user}@{CAS_SYNTHETIC_EMAIL_DOMAIN}" - - return CasPrincipal( - cas_user_id=str(cas_user_id), - email=str(email).lower(), - username=str(username), - role=role, - tenant_id=str(tenant_id), - session_index=str(session_index or ""), - expires_at=expires_at, - ) - - -def parse_logout_request(logout_request: str) -> Dict[str, str]: - if not logout_request: - return {"cas_user_id": "", "session_index": ""} - try: - root = ET.fromstring(logout_request) - except (ET.ParseError, DefusedXmlException): - logger.warning("Invalid CAS logoutRequest XML") - return {"cas_user_id": "", "session_index": ""} - - session_index = _get_child_text(root, "SessionIndex") - cas_user_id = ( - _get_child_text(root, "NameID") - or _get_child_text(root, "nameID") - or _get_child_text(root, "user") - or _get_child_text(root, "casUserId") - ) - return {"cas_user_id": cas_user_id or "", "session_index": session_index or ""} - - -def revoke_from_logout_request(logout_request: str) -> Dict[str, Any]: - parsed = parse_logout_request(logout_request) - revoked = 0 - if parsed["cas_user_id"]: - revoked = revoke_cas_sessions_by_user_id(parsed["cas_user_id"]) - logger.info( - "CAS SLO revoke by cas_user_id: cas_user_id=%s revoked=%s", - parsed["cas_user_id"], - revoked, - ) - if revoked == 0 and parsed["session_index"]: - revoked = revoke_cas_session_by_index(parsed["session_index"]) - logger.info( - "CAS SLO revoke by session_index: session_index=%s revoked=%s", - parsed["session_index"], - revoked, - ) - if revoked == 0: - logger.warning("CAS SLO did not revoke any session: %s", parsed) - return {"revoked": revoked, **parsed} - - -async def _create_project_session(principal: CasPrincipal, redirect: str = "/", renew: bool = False) -> Dict[str, Any]: - user_id = _resolve_project_user(principal) - existing_tenant = get_user_tenant_by_user_id(user_id) - user_tenant = upsert_user_tenant( - user_id=user_id, - tenant_id=principal.tenant_id, - user_role=principal.role, - user_email=principal.email, - ) - if not existing_tenant: - await init_tool_list_for_tenant(principal.tenant_id, user_id) - await init_skill_list_for_tenant(principal.tenant_id, user_id) - - now = datetime.now() - max_local_expiry = now + timedelta(seconds=LOCAL_SESSION_MAX_AGE_SECONDS) - expires_at_dt = min(principal.expires_at, max_local_expiry) - expires_in_seconds = max(1, int((expires_at_dt - now).total_seconds())) - - session_id = secrets.token_urlsafe(32) - create_cas_session( - session_id=session_id, - user_id=user_id, - cas_user_id=principal.cas_user_id, - cas_session_index=principal.session_index, - expires_at=expires_at_dt, - ) - - jwt_token = generate_session_jwt(user_id, expires_in=expires_in_seconds, session_id=session_id) - - return { - "user": { - "id": str(user_id), - "email": principal.email, - "role": user_tenant.get("user_role", principal.role), - }, - "session": { - "access_token": jwt_token, - "refresh_token": "", - "expires_at": calculate_expires_at(jwt_token), - "expires_in_seconds": expires_in_seconds, - }, - "redirect_url": redirect, - "renew": renew, - } - - -def _resolve_project_user(principal: CasPrincipal) -> str: - existing = get_oauth_account_by_provider(CAS_PROVIDER, principal.cas_user_id) - if existing: - create_or_update_oauth_account( - user_id=existing["user_id"], - provider=CAS_PROVIDER, - provider_user_id=principal.cas_user_id, - email=principal.email, - username=principal.username, - tenant_id=principal.tenant_id, - ) - return existing["user_id"] - - admin_client = get_supabase_admin_client() - if not admin_client: - raise RuntimeError("Supabase admin client not available") - - user_id = find_supabase_user_id_by_email(admin_client, principal.email) - if not user_id: - create_resp = admin_client.auth.admin.create_user( - { - "email": principal.email, - "password": secrets.token_urlsafe(32), - "email_confirm": True, - "user_metadata": { - "full_name": principal.username, - "provider": CAS_PROVIDER, - "cas_user_id": principal.cas_user_id, - }, - } - ) - user_id = create_resp.user.id - - create_or_update_oauth_account( - user_id=user_id, - provider=CAS_PROVIDER, - provider_user_id=principal.cas_user_id, - email=principal.email, - username=principal.username, - tenant_id=principal.tenant_id, - ) - return user_id - - -def _ensure_enabled() -> None: - if not CAS_ENABLED or not CAS_SERVER_URL: - raise CasAuthenticationError("CAS is not configured") - - -def _build_callback_url(path: str, params: Dict[str, str]) -> str: - if not CAS_CALLBACK_BASE_URL: - raise CasAuthenticationError("CAS callback base URL is not configured") - query = _build_callback_query(params) - suffix = f"?{query}" if query else "" - return f"{CAS_CALLBACK_BASE_URL}{path}{suffix}" - - -def _build_callback_query(params: Dict[str, str]) -> str: - return "&".join(f"{key}={value}" for key, value in params.items()) - - -def _normalize_redirect(redirect: str) -> str: - if not redirect or not redirect.startswith("/") or redirect.startswith("//"): - return "/" - return redirect - - -def _build_ssl_context() -> ssl.SSLContext: - if CAS_CA_BUNDLE and os.path.isfile(CAS_CA_BUNDLE): - return ssl.create_default_context(cafile=CAS_CA_BUNDLE) - if not CAS_SSL_VERIFY: - ctx = ssl.create_default_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - return ctx - return ssl.create_default_context() - - -def _http_get_text(url: str) -> str: - req = urllib.request.Request(url, headers={"Accept": "application/xml,text/xml,*/*"}) - with urllib.request.urlopen(req, timeout=15, context=_build_ssl_context()) as resp: - return resp.read().decode("utf-8") - - -def _local_name(tag: str) -> str: - return tag.rsplit("}", 1)[-1] - - -def _find_first(node: Element, name: str) -> Optional[Element]: - for child in node.iter(): - if _local_name(child.tag) == name: - return child - return None - - -def _get_child_text(node: Element, name: str) -> str: - found = _find_first(node, name) - return (found.text or "").strip() if found is not None else "" - - -def _extract_attributes(attrs_node: Element) -> Dict[str, str]: - attrs: Dict[str, str] = {} - for child in list(attrs_node): - value = (child.text or "").strip() - if value: - attrs[_local_name(child.tag)] = value - return attrs - - -def _attribute_or_default(attrs: Dict[str, str], key: str, default: str) -> str: - if key and key in attrs: - return attrs[key] - return default - - -def _map_role(raw_role: str) -> str: - role = (raw_role or "USER").upper() - try: - role_map = json.loads(CAS_ROLE_MAP_JSON) if CAS_ROLE_MAP_JSON else {} - role = str(role_map.get(raw_role, role_map.get(role, role))).upper() - except Exception: - logger.warning("Invalid CAS_ROLE_MAP_JSON; falling back to raw role") - return role if role in VALID_ROLES else "USER" - - -def _resolve_expires_at(attrs: Dict[str, str]) -> datetime: - for key in ("expiresAt", "expirationDate", "validUntil", "notOnOrAfter"): - value = attrs.get(key) - if not value: - continue - parsed = _parse_datetime(value) - if parsed: - return parsed - return datetime.now() + timedelta(seconds=CAS_SESSION_MAX_AGE_SECONDS) - - -def _parse_datetime(value: str) -> Optional[datetime]: - try: - if value.isdigit(): - timestamp = int(value) - if timestamp > 10_000_000_000: - timestamp = timestamp / 1000 - return datetime.fromtimestamp(timestamp) - normalized = value.replace("Z", "+00:00") - parsed = datetime.fromisoformat(normalized) - if parsed.tzinfo: - parsed = parsed.astimezone().replace(tzinfo=None) - return parsed - except Exception: - return None diff --git a/backend/services/conversation_management_service.py b/backend/services/conversation_management_service.py index 0b7345461..302ec63a8 100644 --- a/backend/services/conversation_management_service.py +++ b/backend/services/conversation_management_service.py @@ -8,7 +8,6 @@ from consts.const import LANGUAGE, MODEL_CONFIG_MAPPING, MESSAGE_ROLE, DEFAULT_EN_TITLE, DEFAULT_ZH_TITLE from consts.model import AgentRequest, ConversationResponse, MessageRequest, MessageUnit -from consts.exceptions import ConversationNotFoundError from database.conversation_db import ( create_conversation, create_conversation_message, @@ -19,14 +18,12 @@ get_conversation, get_conversation_history, get_conversation_list, - get_latest_assistant_message_id, get_message_id_by_index, get_source_images_by_conversation, get_source_images_by_message, get_source_searches_by_conversation, get_source_searches_by_message, rename_conversation, - update_message_minio_files, update_message_opinion ) from nexent.core.utils.observer import MessageObserver, ProcessType @@ -227,7 +224,7 @@ def save_conversation_assistant(request: AgentRequest, messages: List[str], user message_list.append(message) conversation_req = MessageRequest(conversation_id=request.conversation_id, message_idx=user_role_count * 2 + 1, - role=MESSAGE_ROLE["ASSISTANT"], message=message_list, minio_files=None) + role=MESSAGE_ROLE["ASSISTANT"], message=message_list, minio_files=request.minio_files) save_message(conversation_req, user_id=user_id, tenant_id=tenant_id) @@ -299,9 +296,7 @@ def update_conversation_title(conversation_id: int, title: str, user_id: str = N """ success = rename_conversation(conversation_id, title, user_id) if not success: - raise ConversationNotFoundError( - f"Conversation {conversation_id} does not exist or has been deleted" - ) + raise Exception(f"Conversation {conversation_id} does not exist or has been deleted") return success @@ -514,10 +509,6 @@ def get_conversation_history_service(conversation_id: int, user_id: str) -> List 'opinion_flag': msg['opinion_flag'] } - # Add minio_files field (if any, e.g., skill-generated attachments) - if 'minio_files' in msg and msg['minio_files']: - message_item['minio_files'] = msg['minio_files'] - # Add image content (if any) if message_id in image_by_message: message_item['picture'] = image_by_message[message_id] @@ -710,52 +701,3 @@ async def get_message_id_by_index_impl(conversation_id: int, message_index: int) if message_id is None: raise Exception("Message not found.") return message_id - - -def save_skill_files_to_conversation( - conversation_id: int, - skill_file_uploads: List[Dict[str, Any]], - user_id: str, -) -> bool: - """ - Append skill file upload records to the latest assistant message in a conversation. - - This persists generated documents (e.g., DOCX, XLSX created by skills) to the - conversation history so they appear in subsequent GET /conversation/{id} calls. - - Args: - conversation_id: Target conversation ID - skill_file_uploads: List of upload metadata dicts (e.g., from upload_fileobj) - user_id: User ID for ownership validation - - Returns: - bool: True if files were saved, False if no assistant message was found - """ - if not skill_file_uploads: - return False - - try: - message_id = get_latest_assistant_message_id(conversation_id, user_id) - if message_id is None: - logging.warning( - "[skill-file] no assistant message found for conversation=%s, " - "cannot persist skill file uploads", - conversation_id, - ) - return False - - success = update_message_minio_files(message_id, skill_file_uploads) - if success: - logging.info( - "[skill-file] persisted %d file(s) to message_id=%s conversation=%s", - len(skill_file_uploads), - message_id, - conversation_id, - ) - return success - except Exception as exc: - logging.exception( - "[skill-file] failed to persist skill file uploads for conversation=%s", - conversation_id, - ) - return False diff --git a/backend/services/data_process_service.py b/backend/services/data_process_service.py index a7529127c..ae3d35dcd 100644 --- a/backend/services/data_process_service.py +++ b/backend/services/data_process_service.py @@ -15,7 +15,7 @@ import redis import torch from PIL import Image -from celery import states +from celery import states, chain from transformers import CLIPProcessor, CLIPModel from nexent.data_process.core import DataProcessCore @@ -25,7 +25,7 @@ from database.attachment_db import delete_file, file_exists, get_file_size_from_minio, get_file_stream, upload_file from utils.file_management_utils import convert_office_to_pdf from data_process.app import app as celery_app -from data_process.tasks import submit_process_forward_chain +from data_process.tasks import process, forward from data_process.utils import get_task_info, get_all_task_ids_from_redis # Limit concurrent LibreOffice processes to avoid resource exhaustion @@ -54,8 +54,7 @@ def __init__(self): self._inspector = None self._inspector_last_time = 0 - # 5 minutes - inspector is expensive to create (ping all workers) - self._inspector_ttl = 300 + self._inspector_ttl = 300 # 5 minutes - inspector is expensive to create (ping all workers) self._inspector_lock = None self._inspector_lock = threading.Lock() @@ -153,8 +152,7 @@ async def get_all_tasks(self, filter: bool = True) -> List[Dict[str, Any]]: def _normalize_runtime_meta(task: Dict[str, Any]) -> Dict[str, Any]: task_name_full = task.get('name', '') or '' - task_name = task_name_full.split( - '.')[-1] if task_name_full else '' + task_name = task_name_full.split('.')[-1] if task_name_full else '' kwargs = task.get('kwargs') or {} if isinstance(kwargs, str): try: @@ -180,43 +178,35 @@ def _normalize_runtime_meta(task: Dict[str, Any]) -> Dict[str, Any]: def get_active(): t = time.time() # Create fresh inspector with short timeout for each call - short_inspector = celery_app.control.inspect( - timeout=short_timeout) + short_inspector = celery_app.control.inspect(timeout=short_timeout) result = short_inspector.active() elapsed = time.time() - t - logger.info( - f"[get_all_tasks] inspector.active() took {elapsed:.3f}s") + logger.info(f"[get_all_tasks] inspector.active() took {elapsed:.3f}s") return result if result else {} def get_reserved(): t = time.time() - short_inspector = celery_app.control.inspect( - timeout=short_timeout) + short_inspector = celery_app.control.inspect(timeout=short_timeout) result = short_inspector.reserved() elapsed = time.time() - t - logger.info( - f"[get_all_tasks] inspector.reserved() took {elapsed:.3f}s") + logger.info(f"[get_all_tasks] inspector.reserved() took {elapsed:.3f}s") return result if result else {} with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: future_active = executor.submit(get_active) future_reserved = executor.submit(get_reserved) - active_tasks_dict = future_active.result( - timeout=short_timeout + 0.5) - reserved_tasks_dict = future_reserved.result( - timeout=short_timeout + 0.5) + active_tasks_dict = future_active.result(timeout=short_timeout + 0.5) + reserved_tasks_dict = future_reserved.result(timeout=short_timeout + 0.5) celery_duration = time.time() - celery_start if celery_duration > 0.5: - logger.warning( - f"[get_all_tasks] Inspector took {celery_duration:.3f}s (expected <0.5s)") + logger.warning(f"[get_all_tasks] Inspector took {celery_duration:.3f}s (expected <0.5s)") if active_tasks_dict: for worker, tasks in active_tasks_dict.items(): for task in tasks: task_id = task.get('id') if task_id: task_ids.add(task_id) - runtime_task_meta[task_id] = _normalize_runtime_meta( - task) + runtime_task_meta[task_id] = _normalize_runtime_meta(task) if reserved_tasks_dict: for worker, tasks in reserved_tasks_dict.items(): for task in tasks: @@ -224,8 +214,7 @@ def get_reserved(): if task_id: task_ids.add(task_id) # Keep active metadata if already present - runtime_task_meta.setdefault( - task_id, _normalize_runtime_meta(task)) + runtime_task_meta.setdefault(task_id, _normalize_runtime_meta(task)) # Get task IDs from Redis backend (covers completed/failed tasks within expiry) try: @@ -252,14 +241,11 @@ def get_reserved(): if not task_info.get('task_name') and runtime_meta.get('task_name'): task_info['task_name'] = runtime_meta.get('task_name') if not task_info.get('index_name') and runtime_meta.get('index_name'): - task_info['index_name'] = runtime_meta.get( - 'index_name') + task_info['index_name'] = runtime_meta.get('index_name') if not task_info.get('path_or_url') and runtime_meta.get('path_or_url'): - task_info['path_or_url'] = runtime_meta.get( - 'path_or_url') + task_info['path_or_url'] = runtime_meta.get('path_or_url') if not task_info.get('original_filename') and runtime_meta.get('original_filename'): - task_info['original_filename'] = runtime_meta.get( - 'original_filename') + task_info['original_filename'] = runtime_meta.get('original_filename') if filter and not (task_info.get('index_name') and task_info.get('task_name')): # Keep user-visible queued tasks even before worker updates task meta. @@ -552,23 +538,30 @@ async def create_batch_tasks_impl(self, authorization: Optional[str], request: B f"Missing required field 'index_name' in source config: {source_config}") continue - chain_id = submit_process_forward_chain( - source=source, - source_type=source_type, - chunking_strategy=chunking_strategy, - index_name=index_name, - original_filename=original_filename, - authorization=authorization, - embedding_model_id=embedding_model_id, - tenant_id=tenant_id, + # Create and submit a chain: process -> forward + task_chain = chain( + process.s( + source=source, + source_type=source_type, + chunking_strategy=chunking_strategy, + index_name=index_name, + original_filename=original_filename, + embedding_model_id=embedding_model_id, + tenant_id=tenant_id + ).set(queue='process_q'), + forward.s( + index_name=index_name, + source=source, + source_type=source_type, + original_filename=original_filename, + authorization=authorization + ).set(queue='forward_q') ) - if not chain_id: - logger.error( - f"Failed to enqueue process-forward chain for source: {source}") - continue - task_ids.append(chain_id) - logger.debug(f"Created task {chain_id} for source: {source}") + task_result = task_chain.apply_async() + + task_ids.append(task_result.id) + logger.debug(f"Created task {task_result.id} for source: {source}") logger.info( f"Created {len(task_ids)} individual tasks for batch processing") return task_ids @@ -600,7 +593,7 @@ async def process_uploaded_text_file(self, file_content: bytes, filename: str, c f"Processing uploaded file: {filename} using SDK DataProcessCore") data_processor = DataProcessCore() - chunks, _ = data_processor.file_process( + chunks = data_processor.file_process( file_data=file_content, filename=filename, chunking_strategy=chunking_strategy @@ -649,8 +642,7 @@ async def convert_office_to_pdf_impl(self, object_name: str, pdf_object_name: st # Step 1: Download original Office file from MinIO original_stream = get_file_stream(object_name) if original_stream is None: - raise OfficeConversionException( - f"Source file not found in storage: {object_name}") + raise OfficeConversionException(f"Source file not found in storage: {object_name}") original_filename = os.path.basename(object_name) input_path = os.path.join(temp_dir, original_filename) @@ -662,12 +654,10 @@ async def convert_office_to_pdf_impl(self, object_name: str, pdf_object_name: st try: pdf_path = await convert_office_to_pdf(input_path, temp_dir, timeout=30) except Exception as exc: - raise OfficeConversionException( - f"LibreOffice conversion failed: {exc}") from exc + raise OfficeConversionException(f"LibreOffice conversion failed: {exc}") from exc # Step 3: Upload converted PDF to MinIO - result = upload_file(file_path=pdf_path, - object_name=pdf_object_name) + result = upload_file(file_path=pdf_path, object_name=pdf_object_name) if not result.get('success'): raise OfficeConversionException( f"Failed to upload PDF to MinIO: {result.get('error', 'Unknown error')}" @@ -676,16 +666,14 @@ async def convert_office_to_pdf_impl(self, object_name: str, pdf_object_name: st # Step 4: Validate the uploaded PDF (header check + minimum size) remote_size = get_file_size_from_minio(pdf_object_name) if remote_size <= 0: - raise OfficeConversionException( - "PDF validation failed: cannot read remote file size") + raise OfficeConversionException("PDF validation failed: cannot read remote file size") if remote_size < 100: raise OfficeConversionException( f"PDF validation failed: file too small ({remote_size} bytes)" ) remote_stream = get_file_stream(pdf_object_name) if remote_stream is None: - raise OfficeConversionException( - "PDF validation failed: cannot read uploaded file") + raise OfficeConversionException("PDF validation failed: cannot read uploaded file") try: header = remote_stream.read(5) finally: @@ -694,8 +682,7 @@ async def convert_office_to_pdf_impl(self, object_name: str, pdf_object_name: st except Exception: pass if not header.startswith(b'%PDF-'): - raise OfficeConversionException( - "PDF validation failed: invalid PDF header") + raise OfficeConversionException("PDF validation failed: invalid PDF header") except OfficeConversionException: # Clean up any partially-uploaded remote PDF so a future retry starts clean @@ -703,16 +690,14 @@ async def convert_office_to_pdf_impl(self, object_name: str, pdf_object_name: st delete_file(pdf_object_name) raise except Exception as exc: - raise OfficeConversionException( - f"Unexpected error during conversion: {exc}") from exc + raise OfficeConversionException(f"Unexpected error during conversion: {exc}") from exc finally: # Step 5: Clean up local temporary directory if temp_dir and os.path.exists(temp_dir): try: shutil.rmtree(temp_dir) except Exception as cleanup_err: - logger.warning( - f"Failed to cleanup temp dir '{temp_dir}': {cleanup_err}") + logger.warning(f"Failed to cleanup temp dir '{temp_dir}': {cleanup_err}") def convert_celery_states_to_custom(self, process_celery_state: Optional[str], forward_celery_state: Optional[str]) -> str: """Map Celery task states to a custom frontend state string. diff --git a/backend/services/file_management_service.py b/backend/services/file_management_service.py index 585669c0c..b2850403d 100644 --- a/backend/services/file_management_service.py +++ b/backend/services/file_management_service.py @@ -52,27 +52,6 @@ logger = logging.getLogger("file_management_service") -ALLOWED_SKILL_UPLOAD_ROOT = Path("/mnt/nexent").resolve() - - -def is_allowed_skill_upload_path(file_path: str) -> bool: - """Return True when a local file path is under the allowed skill upload root.""" - if not file_path: - return False - - try: - candidate_path = Path(file_path).resolve() - except Exception: - return False - - try: - candidate_path.relative_to(ALLOWED_SKILL_UPLOAD_ROOT) - return True - except ValueError: - return False - - - def resolve_minio_upload_folder( folder: Optional[str], @@ -104,11 +83,6 @@ def resolve_minio_upload_folder( if folder == "knowledge_base": return "knowledge_base" - if folder == "skill-files": - if user_id: - return f"skill-files/{user_id}" - return "skill-files" - if user_id: return f"attachments/{user_id}" @@ -127,6 +101,7 @@ def check_file_access( - knowledge_base/*: All authenticated users can access - attachments/{user_id}/*: Only the owner (user_id) can access - images_in_attachments/*: All authenticated users can access + - preview/*: Accessible if the original file is accessible Args: object_name: File object name in storage @@ -150,10 +125,6 @@ def check_file_access( # Keep them readable for authenticated users to avoid broken image citations. return True - if object_name.startswith("skill-files/"): - # Generated documents are private to the uploader and must stay user-scoped. - return object_name.startswith(f"skill-files/{user_id}/") - # Check if file is in user's attachments folder # Pattern: attachments/{user_id}/* if object_name.startswith(f"attachments/{user_id}/"): @@ -386,20 +357,14 @@ async def upload_to_minio( # Convert file content to BytesIO object file_obj = BytesIO(file_content) - # Store original filename before upload - original_filename = f.filename or "" - # Upload file result = upload_fileobj( file_obj=file_obj, - file_name=original_filename, + file_name=f.filename or "", prefix=actual_folder, file_size=len(file_content) ) - # Preserve original filename in result (upload_fileobj uses it for object name generation) - result["original_file_name"] = original_filename - # Reset file pointer for potential re-reading await f.seek(0) results.append(result) @@ -411,7 +376,6 @@ async def upload_to_minio( results.append({ "success": False, "file_name": f.filename, - "original_file_name": f.filename, "error": "An error occurred while processing the file." }) return results diff --git a/backend/services/northbound_service.py b/backend/services/northbound_service.py index c5493a551..a6eaed77d 100644 --- a/backend/services/northbound_service.py +++ b/backend/services/northbound_service.py @@ -1,40 +1,31 @@ import asyncio import hashlib -import json import logging import time from dataclasses import dataclass -from os.path import basename -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Optional -from fastapi import HTTPException, UploadFile from fastapi.responses import StreamingResponse - -from consts.const import ASSET_OWNER_TENANT_ID from consts.exceptions import ( LimitExceededError, UnauthorizedError, - ConversationNotFoundError, ) -from consts.model import AgentRequest, ToolParamsRequest -from database.conversation_db import get_conversation_messages, get_source_searches_by_message +from consts.model import AgentRequest +from database.conversation_db import get_conversation_messages from database.token_db import log_token_usage, get_latest_usage_metadata from services.agent_service import ( run_agent_stream, stop_agent_tasks, + list_all_agent_info_impl, get_agent_id_by_name ) -from services.agent_version_service import list_published_agents_impl from services.conversation_management_service import ( save_conversation_user, get_conversation_list_service, create_new_conversation, update_conversation_title as update_conversation_title_service, ) -from services.file_management_service import upload_to_minio, resolve_minio_upload_folder, validate_urls_access -from database.attachment_db import get_file_url, get_file_size_from_minio -from nexent.multi_modal.utils import parse_s3_url logger = logging.getLogger("northbound_service") @@ -48,188 +39,6 @@ class NorthboundContext: token_id: int = 0 -def _build_northbound_file_descriptor( - upload_result: Dict[str, Any], - original_file_name: str = "", - file_type: Optional[str] = None, - file_size: Optional[int] = None, -) -> Dict[str, Any]: - """Normalize upload metadata for northbound API consumers.""" - object_name = str(upload_result.get("object_name") or "").strip() - # Use original filename if provided, otherwise fall back to upload result or object name - if original_file_name: - file_name = original_file_name - else: - file_name = str(upload_result.get("file_name") or basename(object_name) or "") - # Frontend-compatible field order - descriptor = { - "object_name": object_name, - "name": file_name, - "type": file_type or "file", - # Use provided file_size, or from upload_result, or 0 as fallback - "size": file_size if file_size is not None else upload_result.get("file_size", 0), - # Use relative URL format matching frontend: /nexent/{object_name} - "url": f"/nexent/{object_name}", - "description": "", - } - presigned_url = upload_result.get("presigned_url") - if presigned_url: - descriptor["presigned_url"] = presigned_url - return descriptor - - -async def upload_files_for_northbound( - ctx: NorthboundContext, - files: List[UploadFile], - folder: str = "attachments", -) -> Dict[str, Any]: - """Upload files for northbound callers and return reusable storage references.""" - if not files: - raise ValueError("No files in the request") - - actual_folder = resolve_minio_upload_folder(folder, ctx.user_id, ctx.tenant_id) - results = await upload_to_minio(files=files, folder=actual_folder) - normalized_files = [] - for result, upload_file in zip(results, files): - if result.get("success") and result.get("object_name"): - content_type = result.get("content_type", "") - file_type = "image" if content_type.startswith("image/") else "file" - # Extract original filename - use upload result first, then fallback to UploadFile - # The upload result contains the original filename passed to upload_fileobj - original_file_name = result.get("original_file_name") or upload_file.filename or "" - file_size = result.get("file_size", 0) - # If file_size is 0 but we have the UploadFile, try to get size from headers - if file_size == 0 and hasattr(upload_file, 'size') and upload_file.size: - file_size = upload_file.size - descriptor = _build_northbound_file_descriptor( - result, - original_file_name=original_file_name, - file_type=file_type, - file_size=file_size, - ) - normalized_files.append(descriptor) - - if not normalized_files: - raise ValueError("No valid files uploaded") - - success_count = sum(1 for result in results if result.get("success", False)) - failed_count = sum(1 for result in results if not result.get("success", False)) - - return { - "message": f"Processed {len(results)} files", - "requestId": ctx.request_id, - "summary": { - "total": len(results), - "uploaded": success_count, - "failed": failed_count, - }, - "files": normalized_files, - } - - -def _normalize_northbound_attachments( - attachments: Optional[List[Any]], - user_id: str, - tenant_id: str, -) -> Optional[List[Dict[str, Any]]]: - """Convert northbound attachment references into internal minio_files objects. - - Supports two formats: - 1. List of S3 URL strings (backward compatible): ["s3://nexent/...", "/nexent/...", "attachments/..."] - 2. List of attachment objects (full metadata): [{"object_name": "...", "name": "...", ...}] - """ - from database.attachment_db import _build_mcp_presigned_url - - if attachments is None: - return None - if not isinstance(attachments, list): - raise ValueError("attachments must be an array") - - normalized_files: List[Dict[str, Any]] = [] - for attachment in attachments: - # Handle dict format (full attachment object) - if isinstance(attachment, dict): - # Use the attachment dict directly, just ensure required fields - normalized_file = { - "object_name": attachment.get("object_name", ""), - "name": attachment.get("name", basename(attachment.get("object_name", ""))), - "type": attachment.get("type", "file"), - "size": attachment.get("size", 0), - "url": attachment.get("url", ""), - "description": attachment.get("description", ""), - } - # Add presigned_url if available, or generate one if we have object_name - if "presigned_url" in attachment: - normalized_file["presigned_url"] = attachment["presigned_url"] - elif normalized_file.get("object_name"): - try: - presigned_result = get_file_url(object_name=normalized_file["object_name"], expires=86400) - if presigned_result.get("success") and presigned_result.get("url"): - normalized_file["presigned_url"] = _build_mcp_presigned_url(presigned_result["url"]) - except Exception: - pass - normalized_files.append(normalized_file) - continue - - # Handle string format (S3 URL) - if not isinstance(attachment, str) or not attachment.strip(): - raise ValueError("attachments must contain non-empty S3 URLs or object paths") - - attachment_url = attachment.strip() - - # Support multiple URL formats: - # 1. s3://nexent/attachments/xxx.md - # 2. /nexent/attachments/xxx.md - # 3. attachments/xxx.md (relative path) - if attachment_url.startswith("s3://"): - try: - _, object_name = parse_s3_url(attachment_url) - except ValueError as exc: - raise ValueError(f"Invalid S3 URL format: {attachment_url}") from exc - validate_url = attachment_url - elif attachment_url.startswith("/nexent/"): - object_name = attachment_url[len("/nexent/"):] - validate_url = f"s3://nexent/{object_name}" - elif attachment_url.startswith("attachments/") or attachment_url.startswith("nexent/"): - object_name = attachment_url if attachment_url.startswith("nexent/") else attachment_url - validate_url = f"s3://nexent/{object_name}" - else: - raise ValueError(f"Invalid attachment format: {attachment_url}. Expected s3:// URL, /nexent/ path, or attachments/ path") - - try: - validate_urls_access([validate_url], user_id, tenant_id) - presigned_result = get_file_url(object_name=object_name, expires=86400) - except PermissionError as exc: - detail = str(exc) - if "Invalid S3 URL format" in detail: - raise ValueError(detail) from exc - raise PermissionError(detail) from exc - - # Get file size from MinIO - try: - file_size = get_file_size_from_minio(object_name) - except Exception: - file_size = 0 - - # Build frontend-compatible minio_files format - file_name = basename(object_name.rstrip("/")) - normalized_file = { - "object_name": object_name, - "name": file_name, - "type": "file", - "size": file_size, - # Use relative URL format matching frontend: /nexent/{object_name} - "url": f"/nexent/{object_name}", - "description": "", - } - # Use MCP proxy URL for presigned_url (same as frontend format) - if presigned_result.get("success") and presigned_result.get("url"): - normalized_file["presigned_url"] = _build_mcp_presigned_url(presigned_result["url"]) - normalized_files.append(normalized_file) - - return normalized_files - - # ----------------------------- # In-memory idempotency and rate limit placeholders # ----------------------------- @@ -302,12 +111,6 @@ def _build_idempotency_key(*parts: Any) -> str: return ":".join(processed) -def _build_title_update_idempotency_key(tenant_id: str, conversation_id: int, title: str) -> str: - """Build an ASCII-safe idempotency key for title updates.""" - title_hash = hashlib.sha256(title.encode("utf-8")).hexdigest() - return _build_idempotency_key(tenant_id, str(conversation_id), title_hash) - - # ----------------------------- # Agent resolver # ----------------------------- @@ -323,9 +126,7 @@ async def start_streaming_chat( conversation_id: Optional[int], agent_name: str, query: str, - attachments: Optional[List[Any]] = None, meta_data: Optional[Dict[str, Any]] = None, - tool_params: Optional[ToolParamsRequest] = None, idempotency_key: Optional[str] = None ) -> StreamingResponse: try: @@ -344,11 +145,6 @@ async def start_streaming_chat( # Get history according to internal_conversation_id history_resp = await get_conversation_history_internal(ctx, internal_conversation_id) agent_id = await get_agent_id_by_name(agent_name=agent_name, tenant_id=ctx.tenant_id) - normalized_attachments = _normalize_northbound_attachments( - attachments=attachments, - user_id=ctx.user_id, - tenant_id=ctx.tenant_id, - ) # Idempotency: only prevent concurrent duplicate starts composed_key = idempotency_key or _build_idempotency_key(ctx.tenant_id, str(conversation_id), agent_id, query) await idempotency_start(composed_key) @@ -357,9 +153,8 @@ async def start_streaming_chat( agent_id=agent_id, query=query, history=(history_resp.get("data", {})).get("history", []), - minio_files=normalized_attachments, + minio_files=None, is_debug=False, - tool_params=tool_params, ) # Synchronously persist the user message before starting the stream to avoid race conditions @@ -462,58 +257,15 @@ async def list_conversations(ctx: NorthboundContext) -> Dict[str, Any]: return {"message": "success", "data": conversations, "requestId": ctx.request_id} -def _format_search_record(record: Dict[str, Any]) -> Dict[str, Any]: - """Format a search source record for API response.""" - search_item = { - "title": record.get("source_title", ""), - "text": record.get("source_content", ""), - "source_type": record.get("source_type", ""), - "url": record.get("source_location", ""), - "filename": record.get("source_title", "") if record.get("source_type") == "file" else None, - "published_date": None, - "score": float(record["score_overall"]) if record.get("score_overall") is not None else None, - "tool_sign": record.get("tool_sign", ""), - "cite_index": record.get("cite_index") - } - - if record.get("published_date"): - if hasattr(record["published_date"], "strftime"): - search_item["published_date"] = record["published_date"].strftime("%Y-%m-%d") - else: - search_item["published_date"] = str(record["published_date"])[:10] - - return search_item - - async def get_conversation_history_internal(ctx: NorthboundContext, conversation_id: int) -> Dict[str, Any]: """Internal helper to get conversation history without logging.""" history = get_conversation_messages(conversation_id) + # Remove unnecessary fields result = [] for message in history: - # Parse minio_files from database (stored as JSON string) - minio_files = [] - raw_minio_files = message.get("minio_files") - if raw_minio_files: - try: - minio_files = json.loads(raw_minio_files) if isinstance(raw_minio_files, str) else raw_minio_files - except (json.JSONDecodeError, TypeError): - logger.warning(f"Failed to parse minio_files for message {message.get('message_id')}") - - # Fetch search results for this message - message_id = message.get("message_id") - search_results = [] - if message_id: - try: - search_records = get_source_searches_by_message(message_id, user_id=ctx.user_id) - search_results = [_format_search_record(r) for r in search_records] - except Exception as e: - logger.warning(f"Failed to get search records for message {message_id}: {str(e)}") - result.append({ "role": message["message_role"], - "content": message["message_content"], - "minio_files": minio_files, - "search": search_results + "content": message["message_content"] }) response = { @@ -532,18 +284,7 @@ async def get_conversation_history(ctx: NorthboundContext, conversation_id: int) async def get_agent_info_list(ctx: NorthboundContext) -> Dict[str, Any]: try: - agent_info_list = await list_published_agents_impl( - tenant_id=ctx.tenant_id, - user_id=ctx.user_id, - ) - # Match the same scope as /agent/published_list: non-asset-owner tenants - # also get the asset owner's published agents merged in. - if ctx.tenant_id != ASSET_OWNER_TENANT_ID: - asset_agent_list = await list_published_agents_impl( - tenant_id=ASSET_OWNER_TENANT_ID, - user_id=ctx.user_id, - ) - agent_info_list.extend(asset_agent_list) + agent_info_list = await list_all_agent_info_impl(tenant_id=ctx.tenant_id, user_id=ctx.user_id) # Remove internal information that partner don't need for agent_info in agent_info_list: agent_info.pop("agent_id", None) @@ -557,11 +298,7 @@ async def update_conversation_title(ctx: NorthboundContext, conversation_id: int composed_key: Optional[str] = None try: # Idempotency: avoid concurrent duplicate title update for same conversation - composed_key = idempotency_key or _build_title_update_idempotency_key( - ctx.tenant_id, - conversation_id, - title, - ) + composed_key = idempotency_key or _build_idempotency_key(ctx.tenant_id, str(conversation_id), title) await idempotency_start(composed_key) update_conversation_title_service(conversation_id, title, ctx.user_id) @@ -587,8 +324,6 @@ async def update_conversation_title(ctx: NorthboundContext, conversation_id: int } except LimitExceededError as _: raise LimitExceededError("Duplicate request is still running, please wait.") - except ConversationNotFoundError: - raise except Exception as e: raise Exception(f"Failed to update conversation title for conversation_id {conversation_id}: {str(e)}") finally: diff --git a/backend/services/prompt_service.py b/backend/services/prompt_service.py index f1564cdbc..ee9704302 100644 --- a/backend/services/prompt_service.py +++ b/backend/services/prompt_service.py @@ -1,17 +1,15 @@ import json import logging import queue -import sys import threading from typing import Optional, List from jinja2 import StrictUndefined, Template -from consts.const import LANGUAGE, ENABLE_JIUWEN_SDK +from consts.const import LANGUAGE from consts.error_code import ErrorCode from consts.error_message import ErrorMessage from consts.exceptions import AppException -from consts.model import AgentInfoRequest from database.agent_db import search_agent_info_by_agent_id, query_all_agent_info_by_tenant_id, \ query_sub_agents_id_list from database.model_management_db import get_model_by_model_id @@ -24,31 +22,15 @@ _regenerate_agent_name_with_llm, _regenerate_agent_display_name_with_llm, _generate_unique_agent_name_with_suffix, - _generate_unique_display_name_with_suffix, - update_agent, + _generate_unique_display_name_with_suffix ) from services.prompt_template_service import resolve_prompt_generate_template from utils.llm_utils import call_llm_for_system_prompt from utils.prompt_template_utils import ( + get_prompt_generate_prompt_template, get_prompt_optimize_prompt_template, - get_prompt_template, ) -from dataclasses import dataclass, field -from typing import Optional as Opt - -from adapters.exception import JiuwenSDKError, NexentCapabilityError - - -def _get_jiuwen_adapter_class(): - """Import Jiuwen adapter only when optimization paths need it.""" - try: - from adapters import JiuwenSDKAdapter - except ModuleNotFoundError: - return None - return JiuwenSDKAdapter - - # Configure logging logger = logging.getLogger("prompt_service") @@ -123,16 +105,14 @@ def generate_and_save_system_prompt_impl(agent_id: int, # Get knowledge base display names for few-shot examples # Priority: frontend-provided > database query if knowledge_base_display_names: - logger.debug( - f"Using frontend-provided knowledge base display names: {knowledge_base_display_names}") + logger.debug(f"Using frontend-provided knowledge base display names: {knowledge_base_display_names}") else: knowledge_base_display_names = get_knowledge_base_display_names( tool_info_list=tool_info_list, agent_id=agent_id, tenant_id=tenant_id ) - logger.debug( - f"Using database query for knowledge base display names: {knowledge_base_display_names}") + logger.debug(f"Using database query for knowledge base display names: {knowledge_base_display_names}") # Handle sub-agent IDs if sub_agent_ids and len(sub_agent_ids) > 0: @@ -166,7 +146,7 @@ def generate_and_save_system_prompt_impl(agent_id: int, # 1. Real-time streaming push final_results = {"duty": "", "constraint": "", "few_shots": "", "agent_var_name": "", "agent_display_name": "", - "agent_description": "", "greeting_message": "", "example_questions": ""} + "agent_description": ""} # Get all existing agent names and display names for duplicate checking (only if not in create mode) all_agents = query_all_agent_info_by_tenant_id(tenant_id) @@ -212,8 +192,7 @@ def generate_and_save_system_prompt_impl(agent_id: int, exclude_agent_id=agent_id, agents_cache=all_agents ): - logger.info( - f"Agent name '{agent_name}' already exists, regenerating with LLM") + logger.info(f"Agent name '{agent_name}' already exists, regenerating with LLM") try: agent_name = _regenerate_agent_name_with_llm( original_name=agent_name, @@ -227,12 +206,10 @@ def generate_and_save_system_prompt_impl(agent_id: int, prompt_template_id=prompt_template_id, user_id=user_id, ) - logger.info( - f"Regenerated agent name: '{agent_name}'") + logger.info(f"Regenerated agent name: '{agent_name}'") final_results["agent_var_name"] = agent_name except Exception as e: - logger.error( - f"Failed to regenerate agent name with LLM: {str(e)}, using fallback") + logger.error(f"Failed to regenerate agent name with LLM: {str(e)}, using fallback") # Fallback: add suffix agent_name = _generate_unique_agent_name_with_suffix( agent_name, @@ -258,8 +235,7 @@ def generate_and_save_system_prompt_impl(agent_id: int, exclude_agent_id=agent_id, agents_cache=all_agents ): - logger.info( - f"Agent display_name '{agent_display_name}' already exists, regenerating with LLM") + logger.info(f"Agent display_name '{agent_display_name}' already exists, regenerating with LLM") try: agent_display_name = _regenerate_agent_display_name_with_llm( original_display_name=agent_display_name, @@ -273,12 +249,10 @@ def generate_and_save_system_prompt_impl(agent_id: int, prompt_template_id=prompt_template_id, user_id=user_id, ) - logger.info( - f"Regenerated agent display_name: '{agent_display_name}'") + logger.info(f"Regenerated agent display_name: '{agent_display_name}'") final_results["agent_display_name"] = agent_display_name except Exception as e: - logger.error( - f"Failed to regenerate agent display_name with LLM: {str(e)}, using fallback") + logger.error(f"Failed to regenerate agent display_name with LLM: {str(e)}, using fallback") # Fallback: add suffix agent_display_name = _generate_unique_display_name_with_suffix( agent_display_name, @@ -311,68 +285,6 @@ def generate_and_save_system_prompt_impl(agent_id: int, if not has_content: raise Exception("Failed to generate prompt content.") - # 3. Generate greeting message and example questions - try: - greeting_template = get_prompt_template('greeting_generate', language) - greeting_system_prompt = greeting_template.get("GREETING_SYSTEM_PROMPT", "") - greeting_user_prompt_template = greeting_template.get("USER_PROMPT", "") - - greeting_user_prompt = Template(greeting_user_prompt_template, undefined=StrictUndefined).render({ - "display_name": final_results.get("agent_display_name", ""), - "duty_description": final_results.get("duty", ""), - "business_description": task_description, - "few_shots": final_results.get("few_shots", ""), - }) - - greeting_result = call_llm_for_system_prompt( - model_id=model_id, - user_prompt=greeting_user_prompt, - system_prompt=greeting_system_prompt, - tenant_id=tenant_id, - ) - - parsed = None - try: - json_start = greeting_result.find("{") - json_end = greeting_result.rfind("}") + 1 - if json_start >= 0 and json_end > json_start: - parsed = json.loads(greeting_result[json_start:json_end]) - except json.JSONDecodeError: - logger.warning(f"Failed to parse greeting JSON from LLM output: {greeting_result}") - - if parsed and "greeting_message" in parsed and "example_questions" in parsed: - greeting_message = parsed["greeting_message"] - example_questions = parsed["example_questions"] - if isinstance(example_questions, list) and len(example_questions) > 6: - example_questions = example_questions[:6] - else: - greeting_message = greeting_result.strip() if greeting_result else "" - example_questions = [] - - yield { - "type": "greeting_message", - "content": greeting_message, - "is_complete": True - } - yield { - "type": "example_questions", - "content": json.dumps(example_questions, ensure_ascii=False), - "is_complete": True - } - - final_results["greeting_message"] = greeting_message - final_results["example_questions"] = json.dumps(example_questions, ensure_ascii=False) - - # Update agent with greeting (skip in create mode) - if agent_id != 0: - update_agent(agent_id, AgentInfoRequest( - agent_id=agent_id, - greeting_message=greeting_message, - example_questions=example_questions, - ), user_id) - except Exception as e: - logger.warning(f"Greeting generation failed: {str(e)}, skipping greeting") - def optimize_prompt_section_impl( agent_id: int, model_id: int, @@ -427,8 +339,7 @@ def optimize_prompt_section_impl( prompt_context = join_info_for_optimize_prompt_section( prompt_for_optimize=prompt_template, section_type=normalized_section_type, - section_title=section_title or _default_prompt_section_title( - normalized_section_type, language), + section_title=section_title or _default_prompt_section_title(normalized_section_type, language), task_description=task_description, current_content=current_content, feedback=feedback, @@ -487,8 +398,7 @@ def generate_system_prompt(sub_agent_info_list, task_description, tool_info_list # If None or >= 6, no limit (all 6 calls run concurrently) # If < 6, use semaphore to limit concurrent calls model_config = get_model_by_model_id(model_id, tenant_id) - concurrency_limit = model_config.get( - "concurrency_limit") if model_config else None + concurrency_limit = model_config.get("concurrency_limit") if model_config else None # Start all generation threads with concurrency control threads, error_holder = _start_generation_threads( @@ -533,8 +443,7 @@ def _resolve_knowledge_base_display_names( agent_id=agent_id, tenant_id=tenant_id ) - logger.debug( - f"Using database query for knowledge base display names: {resolved_names}") + logger.debug(f"Using database query for knowledge base display names: {resolved_names}") return resolved_names @@ -562,9 +471,8 @@ def _resolve_prompt_generation_sub_agents( tenant_id=tenant_id, agent_id=agent_id ) - def _start_generation_threads(content, prompt_for_generate, produce_queue, latest, stop_flags, tenant_id, model_id, - has_selected_resources=True, concurrency_limit: Optional[int] = None): + has_selected_resources = True, concurrency_limit: Optional[int] = None): """Start all prompt generation threads with optional concurrency control.""" # Shared error tracking across threads error_holder = {"error": None} @@ -580,11 +488,9 @@ def _start_generation_threads(content, prompt_for_generate, produce_queue, lates effective_limit = concurrency_limit # Use semaphore if concurrency is limited - semaphore = threading.Semaphore( - effective_limit) if effective_limit else None + semaphore = threading.Semaphore(effective_limit) if effective_limit else None if semaphore: - logger.info( - f"Using concurrency limit of {effective_limit} for prompt generation (total tasks: {total_tasks})") + logger.info(f"Using concurrency limit of {effective_limit} for prompt generation (total tasks: {total_tasks})") else: logger.info("Using unlimited concurrency for prompt generation") @@ -633,8 +539,7 @@ def run_and_flag(tag, sys_prompt): ("few_shots", prompt_for_generate["few_shots_system_prompt"]), ]) else: - logger.info( - "Skipping constraint and few_shots generation: no tools or sub-agents selected") + logger.info("Skipping constraint and few_shots generation: no tools or sub-agents selected") # Mark these sections as already complete with empty content stop_flags["constraint"] = True stop_flags["few_shots"] = True @@ -733,15 +638,13 @@ def join_info_for_generate_system_prompt(prompt_for_generate, sub_agent_info_lis # This is necessary because Jinja2 StrictUndefined raises an error for any # undefined variable, even inside an {% if %} block. if knowledge_base_display_names: - kb_names_str = ", ".join( - f'"{name}"' for name in knowledge_base_display_names) + kb_names_str = ", ".join(f'"{name}"' for name in knowledge_base_display_names) else: kb_names_str = "" template_context["knowledge_base_names"] = kb_names_str # Generate content using template - content = Template( - prompt_for_generate["user_prompt"], undefined=StrictUndefined).render(template_context) + content = Template(prompt_for_generate["user_prompt"], undefined=StrictUndefined).render(template_context) return content @@ -769,8 +672,7 @@ def join_info_for_optimize_prompt_section( ) if knowledge_base_display_names: - kb_names_str = ", ".join( - f'"{name}"' for name in knowledge_base_display_names) + kb_names_str = ", ".join(f'"{name}"' for name in knowledge_base_display_names) else: kb_names_str = "" @@ -822,8 +724,7 @@ def get_knowledge_base_display_names(tool_info_list: List[dict], agent_id: int, List of knowledge base display names if knowledge_base_search tool is configured, None otherwise """ # Check if knowledge_base_search tool is in the list - kb_tool_ids = [tool['tool_id'] for tool in tool_info_list if tool.get( - 'name') == 'knowledge_base_search'] + kb_tool_ids = [tool['tool_id'] for tool in tool_info_list if tool.get('name') == 'knowledge_base_search'] if not kb_tool_ids: logger.debug("No knowledge_base_search tool found in tool list") return None @@ -846,23 +747,19 @@ def get_knowledge_base_display_names(tool_info_list: List[dict], agent_id: int, try: all_index_names.extend(json.loads(index_names)) except json.JSONDecodeError: - logger.warning( - f"Failed to parse index_names JSON: {index_names}") + logger.warning(f"Failed to parse index_names JSON: {index_names}") except Exception as e: - logger.warning( - f"Failed to get tool instance for tool_id {kb_tool_id}: {e}") + logger.warning(f"Failed to get tool instance for tool_id {kb_tool_id}: {e}") if not all_index_names: - logger.debug( - "No index_names configured for knowledge_base_search tool") + logger.debug("No index_names configured for knowledge_base_search tool") return None # Remove duplicates while preserving order unique_index_names = list(dict.fromkeys(all_index_names)) # Convert to display names - knowledge_name_map = get_knowledge_name_map_by_index_names( - unique_index_names) + knowledge_name_map = get_knowledge_name_map_by_index_names(unique_index_names) # Return list of display names (knowledge_name) for each configured index_name display_names = [] @@ -871,8 +768,7 @@ def get_knowledge_base_display_names(tool_info_list: List[dict], agent_id: int, if display_name and display_name not in display_names: display_names.append(display_name) - logger.debug( - f"Converted index_names {unique_index_names} to display_names: {display_names}") + logger.debug(f"Converted index_names {unique_index_names} to display_names: {display_names}") return display_names if display_names else None @@ -889,299 +785,3 @@ def get_enabled_sub_agent_description_for_generate_prompt(agent_id: int, tenant_ sub_agent_info_list.append(sub_agent_info) return sub_agent_info_list - - -# ── Jiuwen SDK 集成 ─────────────────────────────────────────────────────────── - - -@dataclass -class OptimizeRequest: - """优化请求的统一数据结构""" - agent_id: int - model_id: int - task_description: str - section_type: str - section_title: str - current_content: str - feedback: str - mode: str = "general" - start_pos: Opt[int] = None - end_pos: Opt[int] = None - tool_ids: Opt[list[int]] = None - sub_agent_ids: Opt[list[int]] = None - knowledge_base_display_names: Opt[list[str]] = None - - -@dataclass -class OptimizeResult: - """优化结果的统一数据结构""" - optimized_content: str - source: str - section_type: str = "" - section_title: str = "" - original_content: str = "" - - -class PromptOptimizationService: - """提示词优化服务 — 统一入口,模式二选一""" - - def optimize_from_debug(self, agent_id: int, feedback: str, selected, history=None) -> OptimizeResult: - """基于调试对话自动优化整个 system prompt(完整模板)。 - - Args: - selected: OptimizeFromDebugSelected (pydantic model) or any object with user_question/assistant_answer. - history: Optional[List[HistoryItem]] - """ - if not (feedback or "").strip(): - raise AppException( - ErrorCode.COMMON_MISSING_REQUIRED_FIELD, - "Optimization feedback is required.", - ) - - if not self.is_jiuwen_mode_available(): - raise NexentCapabilityError( - "Auto optimize from debug requires Jiuwen SDK to be enabled." - ) - - agent_info = search_agent_info_by_agent_id( - agent_id=agent_id, tenant_id=self.tenant_id, version_no=0) - - duty = (agent_info.get("duty_prompt") or "").strip() - constraint = (agent_info.get("constraint_prompt") or "").strip() - few_shots = (agent_info.get("few_shots_prompt") or "").strip() - - original_full_prompt = "\n\n".join( - [ - "# Duty\n" + duty, - "# Constraint\n" + constraint, - "# FewShots\n" + few_shots, - ] - ).strip() - - if not original_full_prompt: - raise AppException( - ErrorCode.COMMON_MISSING_REQUIRED_FIELD, - "Agent system prompt is empty.", - ) - - user_question = getattr(selected, "user_question", None) or ( - selected.get("user_question") if isinstance(selected, dict) else "") - assistant_answer = getattr(selected, "assistant_answer", None) or ( - selected.get("assistant_answer") if isinstance(selected, dict) else "") - - bad_case_obj = type("_BadCase", (), {}) - bc = bad_case_obj() - bc.question = user_question or "" - bc.answer = assistant_answer or "" - bc.label = "" - bc.reason = feedback - - adapter_cls = _get_jiuwen_adapter_class() - if adapter_cls is None: - raise JiuwenSDKError("Jiuwen SDK adapter is unavailable") - - adapter = adapter_cls( - model_id=self.model_id, tenant_id=self.tenant_id) - - optimized_full_prompt = adapter.optimize_badcase( - prompt=original_full_prompt, - bad_cases=[bc], - language=self.language, - ) - - return OptimizeResult( - optimized_content=optimized_full_prompt, - source="jiuwen", - section_type="full_prompt", - section_title="system_prompt", - original_content=original_full_prompt, - ) - - def __init__(self, model_id: int, tenant_id: str, language: str): - self.model_id = model_id - self.tenant_id = tenant_id - self.language = language - - def is_jiuwen_mode_available(self) -> bool: - """判断 Jiuwen SDK 模式是否可用""" - if not ENABLE_JIUWEN_SDK: - return False - - return _get_jiuwen_adapter_class() is not None - - def optimize(self, request: OptimizeRequest) -> OptimizeResult: - """统一优化入口 — 优先 Jiuwen SDK,失败则降级 nexent 原生""" - if self.is_jiuwen_mode_available(): - logger.info( - f"[prompt-optimize] mode={request.mode}, using Jiuwen SDK") - try: - return self._optimize_with_jiuwen(request) - except JiuwenSDKError as e: - logger.warning(f"Jiuwen SDK 模式失败,降级到 nexent 原生: {e}") - return self._optimize_with_nexent(request) - else: - return self._optimize_with_nexent(request) - - def _optimize_with_jiuwen(self, request: OptimizeRequest) -> OptimizeResult: - """Jiuwen SDK 模式""" - logger.info( - f"[jiuwen-optimize] mode={request.mode}, start_pos={request.start_pos}, " - f"end_pos={request.end_pos}, prompt_len={len(request.current_content)}, " - f"feedback_len={len(request.feedback)}" - ) - adapter_cls = _get_jiuwen_adapter_class() - if adapter_cls is None: - raise JiuwenSDKError("Jiuwen SDK adapter is unavailable") - - adapter = adapter_cls( - model_id=self.model_id, - tenant_id=self.tenant_id, - ) - result = adapter.optimize( - prompt=request.current_content, - feedback=request.feedback, - mode=request.mode, - start_pos=request.start_pos, - end_pos=request.end_pos, - language=self.language, - ) - - # Jiuwen insert/select mode returns a fragment by design. - # We reassemble the full prompt here so frontend always receives full optimized content. - if request.mode == "insert": - if request.start_pos is None or not isinstance(request.start_pos, int): - raise JiuwenSDKError("insert mode requires start_pos") - if request.start_pos < 0 or request.start_pos > len(request.current_content): - raise JiuwenSDKError("insert mode start_pos out of bounds") - optimized_full = ( - request.current_content[: request.start_pos] - + result - + request.current_content[request.start_pos:] - ) - elif request.mode == "select": - if request.start_pos is None or request.end_pos is None: - raise JiuwenSDKError( - "select mode requires start_pos and end_pos") - if not isinstance(request.start_pos, int) or not isinstance(request.end_pos, int): - raise JiuwenSDKError( - "select mode start_pos/end_pos must be int") - if request.start_pos < 0 or request.end_pos < 0 or request.start_pos >= request.end_pos: - raise JiuwenSDKError("select mode start_pos/end_pos invalid") - if request.end_pos > len(request.current_content): - raise JiuwenSDKError("select mode end_pos out of bounds") - optimized_full = ( - request.current_content[: request.start_pos] - + result - + request.current_content[request.end_pos:] - ) - else: - optimized_full = result - - return OptimizeResult( - optimized_content=optimized_full, - source="jiuwen", - section_type=request.section_type, - section_title=request.section_title, - original_content=request.current_content, - ) - - def _optimize_with_nexent(self, request: OptimizeRequest) -> OptimizeResult: - """nexent 原生模式 — 只支持 general 模式""" - if request.mode != "general": - raise NexentCapabilityError( - f"nexent 原生模式只支持 general 模式," - f"当前请求 mode={request.mode} 不支持,请启用 Jiuwen SDK" - ) - - result = optimize_prompt_section_impl( - agent_id=request.agent_id, - model_id=self.model_id, - task_description=request.task_description, - tenant_id=self.tenant_id, - language=self.language, - section_type=request.section_type, - section_title=request.section_title, - current_content=request.current_content, - feedback=request.feedback, - tool_ids=request.tool_ids, - sub_agent_ids=request.sub_agent_ids, - knowledge_base_display_names=request.knowledge_base_display_names, - ) - return OptimizeResult( - optimized_content=result["optimized_content"], - source="nexent", - section_type=result["section_type"], - section_title=result["section_title"], - original_content=result["original_content"], - ) - - def optimize_badcase( - self, - current_content: str, - bad_cases: list, - agent_id: int, - section_type: str, - section_title: str, - tool_ids: Opt[list[int]] = None, - sub_agent_ids: Opt[list[int]] = None, - knowledge_base_display_names: Opt[list[str]] = None, - ) -> OptimizeResult: - """坏案例优化入口 — 优先 Jiuwen SDK,失败则降级""" - if self.is_jiuwen_mode_available(): - logger.info("[prompt-badcase] using Jiuwen SDK") - try: - return self._optimize_badcase_with_jiuwen( - current_content, bad_cases, section_type, section_title - ) - except JiuwenSDKError as e: - logger.warning(f"Jiuwen SDK badcase 模式失败,降级到 nexent 原生: {e}") - return self._optimize_badcase_with_nexent( - current_content, bad_cases, agent_id, section_type, section_title, - tool_ids, sub_agent_ids, knowledge_base_display_names, - ) - else: - return self._optimize_badcase_with_nexent( - current_content, bad_cases, agent_id, section_type, section_title, - tool_ids, sub_agent_ids, knowledge_base_display_names, - ) - - def _optimize_badcase_with_jiuwen( - self, current_content: str, bad_cases: list, section_type: str, section_title: str - ) -> OptimizeResult: - """Jiuwen SDK 坏案例优化""" - adapter_cls = _get_jiuwen_adapter_class() - if adapter_cls is None: - raise JiuwenSDKError("Jiuwen SDK adapter is unavailable") - - adapter = adapter_cls( - model_id=self.model_id, - tenant_id=self.tenant_id, - ) - result = adapter.optimize_badcase( - prompt=current_content, - bad_cases=bad_cases, - language=self.language, - ) - return OptimizeResult( - optimized_content=result, - source="jiuwen", - section_type=section_type, - section_title=section_title, - original_content=current_content, - ) - - def _optimize_badcase_with_nexent( - self, - current_content: str, - bad_cases: list, - agent_id: int, - section_type: str, - section_title: str, - tool_ids: Opt[list[int]] = None, - sub_agent_ids: Opt[list[int]] = None, - knowledge_base_display_names: Opt[list[str]] = None, - ) -> OptimizeResult: - """nexent 原生模式不支持坏案例优化""" - raise NexentCapabilityError( - "nexent 原生模式不支持 badcase 优化,请启用 Jiuwen SDK" - ) diff --git a/backend/services/remote_mcp_service.py b/backend/services/remote_mcp_service.py index 7e77a9c43..56a73fb4b 100644 --- a/backend/services/remote_mcp_service.py +++ b/backend/services/remote_mcp_service.py @@ -230,7 +230,7 @@ async def add_mcp_service( server_url: str, tags: list | None, authorization_token: str | None, - custom_headers: dict | None = None, + custom_headers: dict | None, container_config: dict | None, registry_json: dict | None, enabled: bool = False, diff --git a/backend/services/tool_configuration_service.py b/backend/services/tool_configuration_service.py index 3cbf5edc5..ba51567dc 100644 --- a/backend/services/tool_configuration_service.py +++ b/backend/services/tool_configuration_service.py @@ -782,8 +782,6 @@ def _validate_local_tool( 'embedding_model': embedding_model, 'rerank_model': rerank_model, 'display_name_to_index_map': display_name_to_index_map, - # Internal access control: restrict results to specific document paths (path_or_urls) - 'document_paths': instantiation_params.get('document_paths'), } tool_instance = tool_class(**params) elif tool_name in ["dify_search", "datamate_search"]: @@ -984,7 +982,6 @@ def import_openapi_service( tenant_id: str, user_id: str, service_description: str = None, - headers_template: Dict[str, Any] = None, force_update: bool = False ) -> Dict[str, Any]: """ @@ -998,7 +995,6 @@ def import_openapi_service( tenant_id: Tenant ID for multi-tenancy user_id: User ID for audit service_description: Optional service description (if not provided, reads from openapi_json.info.description) - headers_template: Optional default headers template force_update: If True, replace all existing tools for this service Returns: @@ -1019,8 +1015,7 @@ def import_openapi_service( server_url=server_url, tenant_id=tenant_id, user_id=user_id, - description=service_description, - headers_template=headers_template, + description=service_description ) logger.info(f"Imported service '{service_name}' for tenant {tenant_id}") diff --git a/backend/services/user_management_service.py b/backend/services/user_management_service.py index 0b38a76bc..a983b25d3 100644 --- a/backend/services/user_management_service.py +++ b/backend/services/user_management_service.py @@ -18,7 +18,6 @@ get_supabase_admin_client, calculate_expires_at, get_jwt_expiry_seconds, - ensure_cas_session_active_from_authorization, resolve_tenant_id_from_user_tenant_record, ) from consts.const import ( @@ -108,7 +107,6 @@ def validate_token(token: str) -> Tuple[bool, Optional[Any]]: try: user = get_current_user_from_client(client, token) if user: - ensure_cas_session_active_from_authorization(token) return True, user return False, None except Exception as e: diff --git a/backend/services/vectordatabase_service.py b/backend/services/vectordatabase_service.py index dd2f6e51a..11c5fd9bf 100644 --- a/backend/services/vectordatabase_service.py +++ b/backend/services/vectordatabase_service.py @@ -10,7 +10,6 @@ 4. Health check interface """ import asyncio -import hashlib import json import logging import os @@ -29,7 +28,7 @@ from consts.const import DATAMATE_URL, ES_API_KEY, ES_HOST, LANGUAGE, VectorDatabaseType, IS_SPEED_MODE, PERMISSION_EDIT, PERMISSION_READ, ASSET_OWNER_TENANT_ID from consts.model import ChunkCreateRequest, ChunkUpdateRequest -from database.attachment_db import delete_file, file_exists, get_file_stream +from database.attachment_db import delete_file, get_file_stream from database.knowledge_db import ( create_knowledge_record, delete_knowledge_record, @@ -354,18 +353,15 @@ def get_embedding_model( tenant_id: Tenant ID model_name: Optional display name of the embedding model to use. If provided, will find the model by display_name in the tenant's model list. - model_type: Optional model type filter. When model_name is omitted, queries tenant - model records by this type; when model_type is also omitted, prefers - embedding models, then multi_embedding models. Returns: Tuple of (embedding model instance or None, model_id or None) """ if model_name: try: - model_type = _normalize_model_type(model_type) - if model_type: - model = get_model_by_display_name(model_name, tenant_id, model_type) + normalized_model_type = _normalize_model_type(model_type) + if normalized_model_type: + model = get_model_by_display_name(model_name, tenant_id, normalized_model_type) else: model = get_model_by_display_name(model_name, tenant_id) @@ -376,25 +372,8 @@ def get_embedding_model( return _create_embedding_model(model), model.get("model_id") except Exception as e: logger.warning(f"Failed to get embedding model by name {model_name}: {e}") - else: - try: - if model_type: - records = get_model_records({"model_type": model_type}, tenant_id) - else: - records = get_model_records({"model_type": "embedding"}, tenant_id) - if not records: - records = get_model_records({"model_type": "multi_embedding"}, tenant_id) - - if records: - model = records[0] - if model.get("model_type") in ["embedding", "multi_embedding"]: - return _create_embedding_model(model), model.get("model_id") - logger.warning( - f"Resolved model is not an embedding model: {model.get('model_type')}" - ) - except Exception as e: - logger.warning(f"Failed to get default embedding model for tenant {tenant_id}: {e}") + # No default fallback - return None, None when no model is specified or found return None, None @@ -657,7 +636,6 @@ def create_knowledge_base( group_ids: Optional[List[int]] = None, embedding_model_name: Optional[str] = None, is_multimodal: Optional[bool] = None, - preserve_source_file: Optional[bool] = None, ): """ Create a new knowledge base with a user-facing name and an internal Elasticsearch index name. @@ -677,8 +655,6 @@ def create_knowledge_base( group_ids: List of group IDs (optional) embedding_model_name: Specific embedding model name to use (optional). If provided, will use this model instead of tenant default. - preserve_source_file: Whether to preserve uploaded source documents after - vectorization (optional; defaults to True when omitted). For backward compatibility, legacy callers can still use create_index() directly with an explicit index_name. @@ -718,8 +694,6 @@ def create_knowledge_base( knowledge_data["ingroup_permission"] = ingroup_permission if group_ids is not None: knowledge_data["group_ids"] = group_ids - if preserve_source_file is not None: - knowledge_data["preserve_source_file"] = preserve_source_file record_info = create_knowledge_record(knowledge_data) index_name = record_info["index_name"] @@ -1117,7 +1091,6 @@ def list_indices( # Auto-summary settings "summary_frequency": record.get("summary_frequency"), "last_summary_time": record.get("last_summary_time"), - "preserve_source_file": record.get("preserve_source_file", True), "stats": index_stats, }) @@ -1515,11 +1488,6 @@ async def list_files( # chunk_count is already set from ES aggregation (doc_count) file_data['chunk_count'] = file_data.get('chunk_count', 0) - for file_data in files: - file_data["source_available"] = ( - ElasticSearchService._compute_source_available(file_data) - ) - total_duration = time.time() - total_start_time logger.info(f"[list_files:complete] index={index_name}, total_files={len(files)}, " f"total_duration={total_duration:.3f}s") @@ -1530,100 +1498,6 @@ async def list_files( raise Exception( f"Error getting file list for index {index_name}: {str(e)}") - DOCUMENT_DELETE_SCOPES = ("source_only", "full") - - @staticmethod - def _preview_pdf_cache_object_name(object_name: str) -> str: - """Object key for Office-to-PDF preview cache (matches file_management_service).""" - name_without_ext = ( - object_name.rsplit(".", 1)[0] if "." in object_name else object_name - ) - hash_suffix = hashlib.md5(object_name.encode()).hexdigest()[:8] - return f"preview/converted/{name_without_ext}_{hash_suffix}.pdf" - - @staticmethod - def _compute_source_available(file_data: Dict[str, Any]) -> bool: - path_or_url = file_data.get("path_or_url") or "" - status = file_data.get("status", "") - if status != "COMPLETED": - return True - if path_or_url.startswith("knowledge_base/"): - return file_exists(path_or_url) - return True - - @staticmethod - def delete_source_file(path_or_url: str) -> Dict[str, Any]: - """Remove MinIO source (and preview cache); does not touch Elasticsearch.""" - minio_result = delete_file(path_or_url) - deleted_minio = bool(minio_result.get("success")) - - if path_or_url.startswith("knowledge_base/"): - preview_key = ElasticSearchService._preview_pdf_cache_object_name( - path_or_url - ) - try: - if file_exists(preview_key): - delete_file(preview_key) - except Exception as exc: - logger.warning( - "Failed to delete preview cache for '%s': %s", - path_or_url, - exc, - ) - - return {"deleted_minio": deleted_minio} - - @staticmethod - async def _assert_source_only_deletable( - index_name: str, path_or_url: str - ) -> None: - celery_task_files = await get_all_files_status(index_name) - status_info = celery_task_files.get(path_or_url) - if not status_info or not isinstance(status_info, dict): - return - state = status_info.get("state") or "" - if state and state != "COMPLETED": - raise ValueError( - f"Cannot delete source file while document is in state '{state}'. " - "Wait until processing completes or use scope=full to remove the document." - ) - - @staticmethod - async def delete_document_by_scope( - index_name: str, - path_or_url: str, - scope: str, - vdb_core: VectorDatabaseCore, - ) -> Dict[str, Any]: - if scope not in ElasticSearchService.DOCUMENT_DELETE_SCOPES: - raise ValueError( - f"Invalid scope '{scope}'. " - f"Must be one of: {ElasticSearchService.DOCUMENT_DELETE_SCOPES}" - ) - - if scope == "source_only": - await ElasticSearchService._assert_source_only_deletable( - index_name, path_or_url - ) - minio_part = ElasticSearchService.delete_source_file(path_or_url) - return { - "status": "success", - "scope": scope, - "deleted_es_count": 0, - "deleted_minio": minio_part.get("deleted_minio", False), - "source_available": False, - "message": ( - "Source file deleted; index chunks and vectors preserved." - ), - } - - result = ElasticSearchService.delete_documents( - index_name, path_or_url, vdb_core - ) - result["scope"] = scope - result["source_available"] = False - return result - @staticmethod def delete_documents( index_name: str = Path(..., description="Name of the index"), diff --git a/backend/utils/auth_utils.py b/backend/utils/auth_utils.py index a7194f050..04e81e6e3 100644 --- a/backend/utils/auth_utils.py +++ b/backend/utils/auth_utils.py @@ -326,13 +326,16 @@ def calculate_expires_at(token: Optional[str] = None) -> int: return int((datetime.now() + timedelta(seconds=expiry_seconds)).timestamp()) -def _decode_jwt_token(authorization: str) -> dict: +def _extract_user_id_from_jwt_token(authorization: str) -> Optional[str]: """ Extract user ID from JWT token after verifying signature and expiration. Args: authorization: Authorization header value + Returns: + Optional[str]: User ID, return None if parsing fails + Raises: UnauthorizedError: If token is invalid, expired, or signature verification fails """ @@ -352,12 +355,17 @@ def _decode_jwt_token(authorization: str) -> dict: # Decode and verify JWT (signature + expiration) # verify_aud=False: allow tokens with aud claim (e.g. test JWT, Supabase) without strict audience check - return jwt.decode( + decoded = jwt.decode( token, SUPABASE_JWT_SECRET, algorithms=["HS256"], options={"verify_exp": True, "verify_aud": False}, ) + + # Extract user ID from JWT claims + user_id = decoded.get("sub") + + return user_id except jwt.ExpiredSignatureError: logging.warning("Token expired") raise UnauthorizedError("Token has expired") @@ -370,47 +378,10 @@ def _decode_jwt_token(authorization: str) -> dict: except UnauthorizedError: raise except Exception as e: - logging.error(f"Failed to decode token: {str(e)}") + logging.error(f"Failed to extract user ID from token: {str(e)}") raise UnauthorizedError("Invalid or expired authentication token") -def _extract_user_id_from_jwt_token(authorization: str) -> Optional[str]: - """ - Extract user ID from JWT token after verifying signature and expiration. - """ - decoded = _decode_jwt_token(authorization) - return decoded.get("sub") - - -def extract_session_id_from_authorization(authorization: Optional[str]) -> Optional[str]: - """Extract the sid claim without enforcing token validity, for idempotent logout.""" - if not authorization: - return None - try: - token = ( - authorization.replace("Bearer ", "") - if authorization.startswith("Bearer ") - else authorization - ) - decoded = jwt.decode(token, options={"verify_signature": False}) - sid = decoded.get("sid") - return str(sid) if sid else None - except Exception: - return None - - -def ensure_cas_session_active_from_authorization(authorization: Optional[str]) -> None: - """Reject CAS-issued JWTs whose server-side session is expired or revoked.""" - session_id = extract_session_id_from_authorization(authorization) - if not session_id: - return - - from database.cas_session_db import is_cas_session_active - - if not is_cas_session_active(str(session_id)): - raise UnauthorizedError("CAS session has expired or been revoked") - - def get_current_user_id(authorization: Optional[str] = None) -> tuple[str, str]: """ Get current user ID and tenant ID from authorization token @@ -434,13 +405,10 @@ def get_current_user_id(authorization: Optional[str] = None) -> tuple[str, str]: raise UnauthorizedError("No authorization header provided") try: - decoded = _decode_jwt_token(authorization) - user_id = decoded.get("sub") + user_id = _extract_user_id_from_jwt_token(authorization) if not user_id: raise UnauthorizedError("Invalid or expired authentication token") - ensure_cas_session_active_from_authorization(authorization) - user_tenant_record = get_user_tenant_by_user_id(user_id) if user_tenant_record and user_tenant_record.get("tenant_id"): tenant_id = user_tenant_record["tenant_id"] @@ -453,8 +421,6 @@ def get_current_user_id(authorization: Optional[str] = None) -> tuple[str, str]: return user_id, tenant_id - except UnauthorizedError: - raise except Exception as e: logging.error(f"Failed to get user ID and tenant ID: {str(e)}") raise UnauthorizedError("Invalid or expired authentication token") @@ -506,7 +472,7 @@ def generate_test_jwt(user_id: str, expires_in: int = 3600) -> str: return jwt.encode(payload, MOCK_JWT_SECRET_KEY, algorithm="HS256") -def generate_session_jwt(user_id: str, expires_in: int = 3600, session_id: str = None) -> str: +def generate_session_jwt(user_id: str, expires_in: int = 3600) -> str: """Generate a signed JWT compatible with the existing auth verification flow.""" now = int(time.time()) payload = { @@ -517,8 +483,6 @@ def generate_session_jwt(user_id: str, expires_in: int = 3600, session_id: str = "exp": now + expires_in, "iss": SUPABASE_URL, } - if session_id: - payload["sid"] = session_id return jwt.encode(payload, SUPABASE_JWT_SECRET, algorithm="HS256") diff --git a/backend/utils/context_utils.py b/backend/utils/context_utils.py index 0c3af8915..740bf66df 100644 --- a/backend/utils/context_utils.py +++ b/backend/utils/context_utils.py @@ -8,6 +8,7 @@ allowing ContextManager to assemble them in the correct order. """ +from datetime import datetime from typing import TYPE_CHECKING, Any, Dict, List, Optional if TYPE_CHECKING: @@ -507,12 +508,13 @@ def _format_agent_fallback( return "- 当前没有可用的助手" if language == "zh" else "- No agents are currently available" -def _format_app_context(app_name: str, app_description: str, user_id: str) -> str: +def _format_app_context(app_name: str, app_description: str, user_id: str, time_str: str) -> str: """Format application context for system prompt injection.""" lines = [ f"Application: {app_name}", f"Description: {app_description}", f"Current user: {user_id}", + f"Current time: {time_str}", ] return "\n".join(lines) @@ -526,6 +528,7 @@ def _format_app_context(app_name: str, app_description: str, user_id: str) -> st def build_skeleton_header_component( app_name: str, app_description: str, + time_str: str, user_id: str, language: str = "zh", priority: int = 100, @@ -533,17 +536,14 @@ def build_skeleton_header_component( """Build SystemPromptComponent for the header section. Section: "### 基本信息" / "### Basic Information" - Content: Agent identity, app name/description, user_id. - Note: Current time is intentionally excluded from the system prompt so the - static system prefix can hit the LLM KV/prompt cache across requests. The - current time is injected on the user-message side instead (see CoreAgent.run). + Content: Agent identity, app name/description, time, user_id """ from nexent.core.agents.agent_model import SystemPromptComponent if language == "zh": - content = f"### 基本信息\n你是{app_name},{app_description},用户ID为{user_id}" + content = f"### 基本信息\n你是{app_name},{app_description},现在是{time_str},用户ID为{user_id}" else: - content = f"### Basic Information\nYou are {app_name}, {app_description}" + content = f"### Basic Information\nYou are {app_name}, {app_description}, it is {time_str} now" return SystemPromptComponent( content=content, @@ -611,11 +611,6 @@ def build_skeleton_execution_flow_component( lines.append(" - 注意运行的代码不会被用户看到,所以如果用户需要看到代码,你需要使用'代码'表达展示代码。") lines.append(" - **重要**:代码执行后,系统会返回 \"Observation:\" 标记的内容(这是真实的执行结果)。请基于这些真实结果继续下一步思考,**不要在代码执行前自行编造观察结果**。") lines.append("") - lines.append("3. 自验证:") - lines.append(" - 关键事件(工具调用、检索结果、代码执行、助手返回、准备最终回答)后,系统会进行显式自验证。") - lines.append(" - 如果自验证提示存在错误、证据不足、参数不完整或结果不可靠,必须优先修正、补充证据、重新调用工具,或清晰说明无法完成的部分。") - lines.append(" - 最终回答只有在自验证通过后才会展示给用户;如果系统返回 Verification feedback,请把它视为真实观察结果继续修正,不要忽略。") - lines.append("") lines.append("在思考结束后,当你认为可以回答用户问题,那么可以不生成代码,直接生成最终回答给到用户并停止循环。") lines.append("") lines.append("生成最终回答时,你需要遵循以下规范:") @@ -657,11 +652,6 @@ def build_skeleton_execution_flow_component( lines.append(" - Note that executed code is not visible to users. If users need to see the code, use 'code' for displaying code.") lines.append(" - **IMPORTANT**: After code execution, the system will return content with \"Observation:\" marker (this is the real execution result). Please continue your next thinking based on these real results. **Do NOT fabricate observation results before code execution.**") lines.append("") - lines.append("3. Self-verification:") - lines.append(" - After critical events (tool calls, retrieval results, code execution, agent handoffs, and final-answer preparation), the system may run explicit verification.") - lines.append(" - If verification reports errors, insufficient evidence, incomplete parameters, or unreliable results, you must repair the issue, gather more evidence, call tools again, or clearly state what cannot be completed.") - lines.append(" - The final answer is shown to the user only after verification passes. If the system returns Verification feedback, treat it as a real observation and continue revising.") - lines.append("") lines.append("After thinking, when you believe you can answer the user's question, you can generate a final answer directly to the user without generating code and stop the loop.") lines.append("") lines.append("When generating the final answer, you need to follow these specifications:") @@ -1122,6 +1112,7 @@ def build_context_components( few_shots: Optional[str] = None, app_name: Optional[str] = None, app_description: Optional[str] = None, + time_str: Optional[str] = None, user_id: Optional[str] = None, language: str = "zh", is_manager: bool = True, @@ -1176,6 +1167,7 @@ def build_context_components( few_shots: Example templates text app_name: Application name app_description: Application description + time_str: Current time string user_id: Current user ID language: Language code ('zh' or 'en') is_manager: Whether this is a manager agent @@ -1196,11 +1188,12 @@ def build_context_components( components: List = [] # 1. Header - if app_name and app_description and user_id: + if app_name and app_description and time_str and user_id: components.append( build_skeleton_header_component( app_name=app_name, app_description=app_description, + time_str=time_str, user_id=user_id, language=language, ) @@ -1335,4 +1328,5 @@ def build_app_context_string( Returns: Formatted app context string """ - return _format_app_context(app_name, app_description, user_id) + time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + return _format_app_context(app_name, app_description, user_id, time_str) \ No newline at end of file diff --git a/backend/utils/http_client_utils.py b/backend/utils/http_client_utils.py index 262c0a593..1c1d14af6 100644 --- a/backend/utils/http_client_utils.py +++ b/backend/utils/http_client_utils.py @@ -8,7 +8,6 @@ def create_httpx_client( headers: dict[str, str] | None = None, timeout: httpx.Timeout | None = None, auth: httpx.Auth | None = None, - **kwargs, ) -> AsyncClient: return AsyncClient( headers=headers, @@ -16,5 +15,4 @@ def create_httpx_client( auth=auth, trust_env=False, verify=False, - **kwargs, ) diff --git a/backend/utils/memory_utils.py b/backend/utils/memory_utils.py index e3ba01d6d..ada7019a1 100644 --- a/backend/utils/memory_utils.py +++ b/backend/utils/memory_utils.py @@ -1,5 +1,4 @@ import logging -import re from typing import Dict, Any from urllib.parse import urlparse @@ -10,11 +9,6 @@ logger = logging.getLogger("memory_utils") -def _sanitize_index_component(value: str) -> str: - """Convert arbitrary text into an Elasticsearch-safe index component.""" - return re.sub(r"[^a-z0-9_.-]", "_", value.lower()) - - def build_memory_config(tenant_id: str) -> Dict[str, Any]: """Return a fully-validated configuration dictionary for *mem0* ``Memory``. """ @@ -36,8 +30,9 @@ def build_memory_config(tenant_id: str) -> Dict[str, Any]: es_host = f"{parsed.scheme}://{parsed.hostname}" es_port = parsed.port # Normalize repo/name to avoid problematic characters in index names - safe_repo = _sanitize_index_component(embed_raw["model_repo"]) if embed_raw["model_repo"] else "" - safe_name = _sanitize_index_component(embed_raw["model_name"]) + safe_repo = embed_raw["model_repo"].lower().replace( + "/", "_") if embed_raw["model_repo"] else "" + safe_name = embed_raw["model_name"].lower().replace("/", "_") index_name = ( f"mem0_{safe_repo}_{safe_name}_{embed_raw['max_tokens']}" if embed_raw["model_repo"] @@ -78,4 +73,4 @@ def build_memory_config(tenant_id: str) -> Dict[str, Any]: }, "telemetry": {"enabled": False}, } - return memory_config + return memory_config \ No newline at end of file diff --git a/backend/utils/prompt_template_utils.py b/backend/utils/prompt_template_utils.py index 299d3bf94..8822e5fd4 100644 --- a/backend/utils/prompt_template_utils.py +++ b/backend/utils/prompt_template_utils.py @@ -99,10 +99,6 @@ def get_prompt_template(template_type: str, language: str = LANGUAGE["ZH"], **kw LANGUAGE["ZH"]: 'backend/prompts/utils/generate_title_zh.yaml', LANGUAGE["EN"]: 'backend/prompts/utils/generate_title_en.yaml' }, - 'greeting_generate': { - LANGUAGE["ZH"]: 'backend/prompts/utils/greeting_generate_zh.yaml', - LANGUAGE["EN"]: 'backend/prompts/utils/greeting_generate_en.yaml' - }, 'document_summary': { LANGUAGE["ZH"]: 'backend/prompts/document_summary_agent_zh.yaml', LANGUAGE["EN"]: 'backend/prompts/document_summary_agent_en.yaml' diff --git a/doc/docs/en/quick-start/installation.md b/doc/docs/en/quick-start/installation.md index 7b6a9cb76..0b1544819 100644 --- a/doc/docs/en/quick-start/installation.md +++ b/doc/docs/en/quick-start/installation.md @@ -273,114 +273,6 @@ Provider enablement rules: For local Docker, a GitHub callback example is `http://localhost:3000/api/user/oauth/callback?provider=github`. In production, use a public HTTPS domain such as `https://nexent.example.com/api/user/oauth/callback?provider=github` and register the exact same URL in the OAuth provider console. -### CAS Login Configuration - -CAS SSO does not require the `supabase` component. Set `CAS_CALLBACK_BASE_URL` to the browser-accessible Nexent Web URL without a trailing `/`. `CAS_SERVER_URL` is the CAS Server root URL and should also not include a trailing `/`. - -For Docker, configure CAS in `docker/.env`: - -```bash -CAS_ENABLED=true -CAS_SERVER_URL=http://localhost:8080/cas -CAS_VALIDATE_PATH=/p3/serviceValidate -CAS_CALLBACK_BASE_URL=http://localhost:3000 - -# disabled: disable the CAS login entry and automatic redirects -# button: show CAS as an optional login button -# force: redirect unauthenticated Nexent users to CAS automatically -CAS_LOGIN_MODE=force - -# Empty means use ; set userName to read -CAS_USER_ATTRIBUTE= -CAS_EMAIL_ATTRIBUTE=email -CAS_ROLE_ATTRIBUTE=role -CAS_TENANT_ATTRIBUTE=tenant_id -CAS_ROLE_MAP_JSON={"cas-admin":"ADMIN","cas-user":"USER"} -CAS_SESSION_MAX_AGE_SECONDS=3600 -LOCAL_SESSION_MAX_AGE_SECONDS=3600 -CAS_RENEW_BEFORE_SECONDS=300 -CAS_RENEW_TIMEOUT_SECONDS=10 -CAS_SYNTHETIC_EMAIL_DOMAIN=cas.local - -# Empty means Nexent logout will not call the CAS Server logout endpoint. -# /logout is resolved against CAS_SERVER_URL. -CAS_LOGOUT_URL=/logout -CAS_SSL_VERIFY=true -CAS_CA_BUNDLE= -``` - -Common CAS URLs: - -| Purpose | URL | -|---------|-----| -| Nexent login entry | `{CAS_CALLBACK_BASE_URL}/api/user/cas/login?redirect=/` | -| CAS service callback | `{CAS_CALLBACK_BASE_URL}/api/user/cas/callback` | -| CAS silent renewal callback | `{CAS_CALLBACK_BASE_URL}/api/user/cas/renew_callback` | -| CAS single logout callback | `POST {CAS_CALLBACK_BASE_URL}/api/user/cas/logout_callback` | - -For Apereo CAS JSON Service Registry, create a service registration file such as `Nexent-10001.json` in the service registry directory configured by your CAS deployment. The `id` must be globally unique. This is a local Docker example: - -```json -{ - "@class": "org.apereo.cas.services.RegexRegisteredService", - "serviceId": "http://localhost:3000.*", - "name": "Nexent CAS Client", - "id": 10001, - "description": "Nexent CAS SSO client", - "evaluationOrder": 1, - "logoutType": "BACK_CHANNEL", - "logoutUrl": "http://localhost:3000/api/user/cas/logout_callback" -} -``` - -In production, keep `CAS_SSL_VERIFY=true`; for self-signed certificates, prefer `CAS_CA_BUNDLE` and only use `CAS_SSL_VERIFY=false` for local testing. - -#### CAS Integration with ModelEngine - -When integrating with ModelEngine through the CAS protocol, deploy Nexent with the following configuration: - -```bash -CAS_ENABLED=true -CAS_SERVER_URL=https://:5443/SSOSvr -CAS_VALIDATE_PATH=/p3/serviceValidate -CAS_CALLBACK_BASE_URL=http://:3000 -CAS_LOGIN_MODE=force -CAS_USER_ATTRIBUTE=userName -CAS_EMAIL_ATTRIBUTE=email -CAS_ROLE_ATTRIBUTE=userType -CAS_TENANT_ATTRIBUTE=tenant_id -CAS_ROLE_MAP_JSON={"1":"ADMIN","3":"DEV"} -CAS_SESSION_MAX_AGE_SECONDS=3600 -LOCAL_SESSION_MAX_AGE_SECONDS=3600 -CAS_RENEW_BEFORE_SECONDS=300 -CAS_RENEW_TIMEOUT_SECONDS=10 -CAS_SYNTHETIC_EMAIL_DOMAIN=cas.local -CAS_LOGOUT_URL=/logout?service=http://:3000 -CAS_SSL_VERIFY=false -CAS_CA_BUNDLE= -``` - -You also need to add a CAS client service registration file in the OMS container. Use the following steps as a reference: - -```bash -# Create the registration file, paste the JSON content into it, and save it. -vim Nexent-10000001.json -{ - "@class": "org.apereo.cas.services.CasRegisteredService", - "serviceId": "http://:3000.*", - "name": "Nexent CAS Client", - "id": 1000001, - "description": "Nexent CAS SSO client", - "evaluationOrder": 1, - "logoutType": "BACK_CHANNEL", - "logoutUrl": "http://:3000/api/user/cas/logout_callback" -} - -# Run the following command to copy the registration file into the container. -kubectl cp Nexent-10000001.json model-engine/$(kubectl get pods -n model-engine -l app=oms --no-headers | awk '{print $1}'):/opt/huawei/fce/apps/platform/webapps/SSOSvr/WEB-INF/classes/services/Nexent-10000001.json -kubectl exec -i -n model-engine $(kubectl get pods -n model-engine -l app=oms --no-headers | awk '{print $1}') -- chown tomcat:fusioncube /opt/huawei/fce/apps/platform/webapps/SSOSvr/WEB-INF/classes/services/Nexent-10000001.json -``` - ### Northbound Interface Configuration (NORTHBOUND_EXTERNAL_URL) If you need to use any of the following features, configure the `NORTHBOUND_EXTERNAL_URL` environment variable: diff --git a/doc/docs/en/quick-start/kubernetes-installation.md b/doc/docs/en/quick-start/kubernetes-installation.md index a10873c7c..8253c411f 100644 --- a/doc/docs/en/quick-start/kubernetes-installation.md +++ b/doc/docs/en/quick-start/kubernetes-installation.md @@ -291,122 +291,6 @@ Provider callback URLs: For local NodePort, a GitHub callback example is `http://localhost:30000/api/user/oauth/callback?provider=github`. In production, use a public HTTPS domain and register the exact same URL in the OAuth provider console. -### CAS Login Configuration - -CAS SSO does not require the `supabase` component. Set `nexent-common.config.cas.callbackBaseUrl` to the browser-accessible Nexent Web URL without a trailing `/`. `nexent-common.config.cas.serverUrl` is the CAS Server root URL and should also not include a trailing `/`. - -Kubernetes writes CAS settings into backend environment variables through `nexent-common` `config.cas.*` values: - -```bash -helm upgrade --install nexent nexent \ - --namespace nexent --create-namespace \ - --set nexent-common.config.cas.enabled=true \ - --set nexent-common.config.cas.serverUrl=https://cas.example.com/cas \ - --set nexent-common.config.cas.callbackBaseUrl=https://nexent.example.com \ - --set nexent-common.config.cas.loginMode=force \ - --set nexent-common.config.cas.logoutUrl=/logout -``` - -Configurable CAS values: - -| Value | Environment variable | Description | -|-------|----------------------|-------------| -| `nexent-common.config.cas.enabled` | `CAS_ENABLED` | Enables CAS | -| `nexent-common.config.cas.serverUrl` | `CAS_SERVER_URL` | CAS Server root URL | -| `nexent-common.config.cas.validatePath` | `CAS_VALIDATE_PATH` | serviceValidate path, default `/p3/serviceValidate` | -| `nexent-common.config.cas.callbackBaseUrl` | `CAS_CALLBACK_BASE_URL` | Web entry URL; CAS callback paths are appended automatically | -| `nexent-common.config.cas.loginMode` | `CAS_LOGIN_MODE` | `disabled`, `button`, or `force` | -| `nexent-common.config.cas.userAttribute` | `CAS_USER_ATTRIBUTE` | User identifier attribute. Empty means use `` | -| `nexent-common.config.cas.emailAttribute` | `CAS_EMAIL_ATTRIBUTE` | Email attribute | -| `nexent-common.config.cas.roleAttribute` | `CAS_ROLE_ATTRIBUTE` | Role attribute | -| `nexent-common.config.cas.tenantAttribute` | `CAS_TENANT_ATTRIBUTE` | Tenant attribute | -| `nexent-common.config.cas.roleMapJson` | `CAS_ROLE_MAP_JSON` | JSON mapping from CAS roles to Nexent roles | -| `nexent-common.config.cas.sessionMaxAgeSeconds` | `CAS_SESSION_MAX_AGE_SECONDS` | Maximum local CAS session lifetime | -| `nexent-common.config.cas.localSessionMaxAgeSeconds` | `LOCAL_SESSION_MAX_AGE_SECONDS` | Nexent local session lifetime | -| `nexent-common.config.cas.renewBeforeSeconds` | `CAS_RENEW_BEFORE_SECONDS` | Trigger silent renewal within this many seconds before expiry | -| `nexent-common.config.cas.renewTimeoutSeconds` | `CAS_RENEW_TIMEOUT_SECONDS` | Silent renewal timeout | -| `nexent-common.config.cas.syntheticEmailDomain` | `CAS_SYNTHETIC_EMAIL_DOMAIN` | Domain used when CAS does not return an email | -| `nexent-common.config.cas.logoutUrl` | `CAS_LOGOUT_URL` | CAS logout URL. Empty means Nexent logout will not call the CAS Server logout endpoint | -| `nexent-common.config.cas.sslVerify` | `CAS_SSL_VERIFY` | Whether to verify CAS Server TLS certificates | -| `nexent-common.config.cas.caBundle` | `CAS_CA_BUNDLE` | Custom CA bundle path | - -Common CAS URLs: - -| Purpose | URL | -|---------|-----| -| Nexent login entry | `{CAS_CALLBACK_BASE_URL}/api/user/cas/login?redirect=/` | -| CAS service callback | `{CAS_CALLBACK_BASE_URL}/api/user/cas/callback` | -| CAS silent renewal callback | `{CAS_CALLBACK_BASE_URL}/api/user/cas/renew_callback` | -| CAS single logout callback | `POST {CAS_CALLBACK_BASE_URL}/api/user/cas/logout_callback` | - -For Apereo CAS JSON Service Registry, create a service registration file such as `Nexent-10001.json` in the service registry directory configured by your CAS deployment. The `id` must be globally unique. This is a local NodePort example: - -```json -{ - "@class": "org.apereo.cas.services.RegexRegisteredService", - "serviceId": "http://localhost:30000.*", - "name": "Nexent CAS Client", - "id": 10001, - "description": "Nexent CAS SSO client", - "evaluationOrder": 1, - "logoutType": "BACK_CHANNEL", - "logoutUrl": "http://localhost:30000/api/user/cas/logout_callback" -} -``` - -In production, keep `CAS_SSL_VERIFY=true`; for self-signed certificates, prefer `CAS_CA_BUNDLE` and only use `CAS_SSL_VERIFY=false` for local testing. - -#### CAS Integration with ModelEngine - -When integrating with ModelEngine through the CAS protocol, use a values file to configure Nexent. This avoids complex command-line escaping for `CAS_ROLE_MAP_JSON`. - -Create `cas-modelengine-values.yaml`: - -```yaml -nexent-common: - config: - cas: - enabled: true - serverUrl: "https://:5443/SSOSvr" - validatePath: "/p3/serviceValidate" - callbackBaseUrl: "http://:30000" - loginMode: "force" - userAttribute: "userName" - emailAttribute: "email" - roleAttribute: "userType" - tenantAttribute: "tenant_id" - roleMapJson: '{"1":"ADMIN","3":"DEV"}' - sessionMaxAgeSeconds: 3600 - localSessionMaxAgeSeconds: 3600 - renewBeforeSeconds: 300 - renewTimeoutSeconds: 10 - syntheticEmailDomain: "cas.local" - logoutUrl: "/logout?service=http://:30000" - sslVerify: false - caBundle: "" -``` - -You also need to add a CAS client service registration file in the OMS container. Use the following steps as a reference: - -```bash -# Create the registration file, paste the JSON content into it, and save it. -vim Nexent-10000001.json -{ - "@class": "org.apereo.cas.services.CasRegisteredService", - "serviceId": "http://:30000.*", - "name": "Nexent CAS Client", - "id": 1000001, - "description": "Nexent CAS SSO client", - "evaluationOrder": 1, - "logoutType": "BACK_CHANNEL", - "logoutUrl": "http://:30000/api/user/cas/logout_callback" -} - -# Run the following command to copy the registration file into the container. -kubectl cp Nexent-10000001.json model-engine/$(kubectl get pods -n model-engine -l app=oms --no-headers | awk '{print $1}'):/opt/huawei/fce/apps/platform/webapps/SSOSvr/WEB-INF/classes/services/Nexent-10000001.json -kubectl exec -i -n model-engine $(kubectl get pods -n model-engine -l app=oms --no-headers | awk '{print $1}') -- chown tomcat:fusioncube /opt/huawei/fce/apps/platform/webapps/SSOSvr/WEB-INF/classes/services/Nexent-10000001.json -``` - ## 🔍 Troubleshooting ### Check Pod Status diff --git a/doc/docs/en/user-guide/agent-development.md b/doc/docs/en/user-guide/agent-development.md index 8e6b47d4f..7637cd620 100644 --- a/doc/docs/en/user-guide/agent-development.md +++ b/doc/docs/en/user-guide/agent-development.md @@ -111,18 +111,6 @@ In the External A2A Agent list, you can view and manage all discovered external > - Batch integrate all agents from the same service registry through Nacos discovery > - Configure protocols to meet the requirements of different agent service providers -###### Integrate [DataAgent](https://gitcode.com/datagallery/dataagent) A2A Agent via URL - -1. Refer to the [DataAgent documentation](https://gitcode.com/datagallery/dataagent#%F0%9F%8C%90-a2a-10-%E6%9C%8D%E5%8A%A1%E6%A8%A1%E5%BC%8F) and start DataAgent in A2A service mode. - > Nexent does not currently support agents that require authentication. Do not set `auth-token` when starting DataAgent. - -
- -
- -2. Refer to [Discover Agent via URL](#discover-agent-via-url) to integrate the agent. The URL is `http://:9999/.well-known/agent-card.json`. -3. Refer to [Manage Discovered External Agents](#manage-discovered-external-agents) to configure the invocation protocol, and select HTTP + JSON for integration. - ### 🛠️ Select Agent Tools Agents can use various tools to complete tasks, such as knowledge base search, file parsing, image parsing, email sending/receiving, file management, and other local tools. They can also integrate third-party MCP tools or custom tools. diff --git a/doc/docs/en/user-guide/assets/agent-development/dataagent_deploy.png b/doc/docs/en/user-guide/assets/agent-development/dataagent_deploy.png deleted file mode 100644 index 46fa9fde31c66bbe8c91a8dba20ec23db69fc030..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 60851 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
xFu?Ux4B$WtsjIF0&t!3sZ? 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 E11AciR{#J2 diff --git a/doc/docs/zh/quick-start/installation.md b/doc/docs/zh/quick-start/installation.md index 6d3538b90..871cae0cc 100644 --- a/doc/docs/zh/quick-start/installation.md +++ b/doc/docs/zh/quick-start/installation.md @@ -269,111 +269,6 @@ Provider 启用规则: 本地默认回调示例为 `http://localhost:3000/api/user/oauth/callback?provider=github`。生产环境应改为公网 HTTPS 域名,例如 `https://nexent.example.com/api/user/oauth/callback?provider=github`,并在 OAuth provider 控制台中登记相同地址。 -### CAS 登录配置 - -CAS SSO 不依赖 `supabase`。启用 CAS 时,请将 `CAS_CALLBACK_BASE_URL` 设置为浏览器可访问的 Nexent Web 地址,且不要带结尾 `/`。`CAS_SERVER_URL` 是 CAS Server 根地址,也不要带结尾 `/`。 - -Docker 部署在 `docker/.env` 中配置 CAS: - -```bash -CAS_ENABLED=true -CAS_SERVER_URL=http://localhost:8080/cas -CAS_VALIDATE_PATH=/p3/serviceValidate -CAS_CALLBACK_BASE_URL=http://localhost:3000 - -# disabled: 禁用 CAS 登录入口和自动跳转 -# button: 在登录页显示 CAS 登录按钮 -# force: 未登录访问 Nexent 时自动跳转到 CAS -CAS_LOGIN_MODE=force - -# 为空时使用 ;填写 userName 时从 取用户标识 -CAS_USER_ATTRIBUTE= -CAS_EMAIL_ATTRIBUTE=email -CAS_ROLE_ATTRIBUTE=role -CAS_TENANT_ATTRIBUTE=tenant_id -CAS_ROLE_MAP_JSON={"cas-admin":"ADMIN","cas-user":"USER"} -CAS_SESSION_MAX_AGE_SECONDS=3600 -LOCAL_SESSION_MAX_AGE_SECONDS=3600 -CAS_RENEW_BEFORE_SECONDS=300 -CAS_RENEW_TIMEOUT_SECONDS=10 -CAS_SYNTHETIC_EMAIL_DOMAIN=cas.local - -# 为空时 Nexent 主动退出不会调用 CAS Server 登出接口。 -# 可配置为 /logout,系统会基于 CAS_SERVER_URL 拼接。 -CAS_LOGOUT_URL=/logout -CAS_SSL_VERIFY=true -CAS_CA_BUNDLE= -``` - -常用 CAS 地址: - -| 用途 | 地址 | -|------|------| -| Nexent 登录入口 | `{CAS_CALLBACK_BASE_URL}/api/user/cas/login?redirect=/` | -| CAS service 回调 | `{CAS_CALLBACK_BASE_URL}/api/user/cas/callback` | -| CAS 无感续期回调 | `{CAS_CALLBACK_BASE_URL}/api/user/cas/renew_callback` | -| CAS 单点登出回调 | `POST {CAS_CALLBACK_BASE_URL}/api/user/cas/logout_callback` | - -Apereo CAS 使用 JSON Service Registry 时,可以新增一个服务注册文件,例如 `Nexent-10001.json`。文件需要放到 CAS 部署配置的 service registry 目录中,`id` 必须全局唯一。下面是本地 Docker 示例: - -```json -{ - "@class": "org.apereo.cas.services.RegexRegisteredService", - "serviceId": "http://localhost:3000.*", - "name": "Nexent CAS Client", - "id": 10001, - "description": "Nexent CAS SSO client", - "evaluationOrder": 1, - "logoutType": "BACK_CHANNEL", - "logoutUrl": "http://localhost:3000/api/user/cas/logout_callback" -} -``` - -生产环境建议保持 `CAS_SSL_VERIFY=true`;自签名证书优先配置 `CAS_CA_BUNDLE`,仅本地验证时再临时设置 `CAS_SSL_VERIFY=false`。 - -#### CAS对接ModelEngine -当使用CAS协议对接ModelEngine时,可以使用如下配置部署Nexent: -```bash -CAS_ENABLED=true -CAS_SERVER_URL=https://:5443/SSOSvr -CAS_VALIDATE_PATH=/p3/serviceValidate -CAS_CALLBACK_BASE_URL=http://:3000 -CAS_LOGIN_MODE=force -CAS_USER_ATTRIBUTE=userName -CAS_EMAIL_ATTRIBUTE=email -CAS_ROLE_ATTRIBUTE=userType -CAS_TENANT_ATTRIBUTE=tenant_id -CAS_ROLE_MAP_JSON={"1":"ADMIN","3":"DEV"} -CAS_SESSION_MAX_AGE_SECONDS=3600 -LOCAL_SESSION_MAX_AGE_SECONDS=3600 -CAS_RENEW_BEFORE_SECONDS=300 -CAS_RENEW_TIMEOUT_SECONDS=10 -CAS_SYNTHETIC_EMAIL_DOMAIN=cas.local -CAS_LOGOUT_URL=/logout?service=http://:3000 -CAS_SSL_VERIFY=false -CAS_CA_BUNDLE= -``` - -同时,需要进入oms容器添加cas client的注册配置文件,参考如下步骤: -```bash -# 创建注册配置文件,将json部分输入文件并保存 -vim Nexent-10000001.json -{ - "@class": "org.apereo.cas.services.CasRegisteredService", - "serviceId": "http://:3000.*", - "name": "Nexent CAS Client", - "id": 1000001, - "description": "Nexent CAS SSO client", - "evaluationOrder": 1, - "logoutType": "BACK_CHANNEL", - "logoutUrl": "http://:3000/api/user/cas/logout_callback" -} - -# 执行如下命令,将配置文件拷贝到容器中 -kubectl cp Nexent-10000001.json model-engine/$(kubectl get pods -n model-engine -l app=oms --no-headers | awk '{print $1}'):/opt/huawei/fce/apps/platform/webapps/SSOSvr/WEB-INF/classes/services/Nexent-10000001.json -kubectl exec -i -n model-engine $(kubectl get pods -n model-engine -l app=oms --no-headers | awk '{print $1}') -- chown tomcat:fusioncube /opt/huawei/fce/apps/platform/webapps/SSOSvr/WEB-INF/classes/services/Nexent-10000001.json -``` - ### 北向接口配置 (NORTHBOUND_EXTERNAL_URL) 如果您需要使用以下功能,需要配置 `NORTHBOUND_EXTERNAL_URL` 环境变量: diff --git a/doc/docs/zh/quick-start/kubernetes-installation.md b/doc/docs/zh/quick-start/kubernetes-installation.md index 7229f1ea8..47d2af816 100644 --- a/doc/docs/zh/quick-start/kubernetes-installation.md +++ b/doc/docs/zh/quick-start/kubernetes-installation.md @@ -291,122 +291,6 @@ Provider 回调地址: 本地 NodePort 默认回调示例为 `http://localhost:30000/api/user/oauth/callback?provider=github`。生产环境应改为公网 HTTPS 域名,并在 OAuth provider 控制台中登记相同地址。 -### CAS 登录配置 - -CAS SSO 不依赖 `supabase`。启用 CAS 时,请将 `nexent-common.config.cas.callbackBaseUrl` 设置为浏览器可访问的 Nexent Web 地址,且不要带结尾 `/`。`nexent-common.config.cas.serverUrl` 是 CAS Server 根地址,也不要带结尾 `/`。 - -Kubernetes 部署通过 `nexent-common` 的 `config.cas.*` values 写入后端环境变量: - -```bash -helm upgrade --install nexent nexent \ - --namespace nexent --create-namespace \ - --set nexent-common.config.cas.enabled=true \ - --set nexent-common.config.cas.serverUrl=https://cas.example.com/cas \ - --set nexent-common.config.cas.callbackBaseUrl=https://nexent.example.com \ - --set nexent-common.config.cas.loginMode=force \ - --set nexent-common.config.cas.logoutUrl=/logout -``` - -可配置的 CAS values: - -| Values | 对应环境变量 | 说明 | -|--------|--------------|------| -| `nexent-common.config.cas.enabled` | `CAS_ENABLED` | 是否启用 CAS | -| `nexent-common.config.cas.serverUrl` | `CAS_SERVER_URL` | CAS Server 根地址 | -| `nexent-common.config.cas.validatePath` | `CAS_VALIDATE_PATH` | serviceValidate 路径,默认 `/p3/serviceValidate` | -| `nexent-common.config.cas.callbackBaseUrl` | `CAS_CALLBACK_BASE_URL` | Web 入口地址,CAS 回调路径会自动拼接 | -| `nexent-common.config.cas.loginMode` | `CAS_LOGIN_MODE` | `disabled`、`button` 或 `force` | -| `nexent-common.config.cas.userAttribute` | `CAS_USER_ATTRIBUTE` | 用户标识属性。为空时使用 `` | -| `nexent-common.config.cas.emailAttribute` | `CAS_EMAIL_ATTRIBUTE` | 邮箱属性 | -| `nexent-common.config.cas.roleAttribute` | `CAS_ROLE_ATTRIBUTE` | 角色属性 | -| `nexent-common.config.cas.tenantAttribute` | `CAS_TENANT_ATTRIBUTE` | 租户属性 | -| `nexent-common.config.cas.roleMapJson` | `CAS_ROLE_MAP_JSON` | CAS 角色到 Nexent 角色的 JSON 映射 | -| `nexent-common.config.cas.sessionMaxAgeSeconds` | `CAS_SESSION_MAX_AGE_SECONDS` | CAS 本地会话最长有效期 | -| `nexent-common.config.cas.localSessionMaxAgeSeconds` | `LOCAL_SESSION_MAX_AGE_SECONDS` | Nexent 本地会话有效期 | -| `nexent-common.config.cas.renewBeforeSeconds` | `CAS_RENEW_BEFORE_SECONDS` | 距离过期多少秒内触发无感续期 | -| `nexent-common.config.cas.renewTimeoutSeconds` | `CAS_RENEW_TIMEOUT_SECONDS` | 无感续期等待超时时间 | -| `nexent-common.config.cas.syntheticEmailDomain` | `CAS_SYNTHETIC_EMAIL_DOMAIN` | CAS 未返回邮箱时生成邮箱使用的域名 | -| `nexent-common.config.cas.logoutUrl` | `CAS_LOGOUT_URL` | CAS 登出地址。为空时 Nexent 主动退出不调用 CAS Server 登出接口 | -| `nexent-common.config.cas.sslVerify` | `CAS_SSL_VERIFY` | 访问 CAS Server 时是否校验证书 | -| `nexent-common.config.cas.caBundle` | `CAS_CA_BUNDLE` | 自定义 CA bundle 路径 | - -常用 CAS 地址: - -| 用途 | 地址 | -|------|------| -| Nexent 登录入口 | `{CAS_CALLBACK_BASE_URL}/api/user/cas/login?redirect=/` | -| CAS service 回调 | `{CAS_CALLBACK_BASE_URL}/api/user/cas/callback` | -| CAS 无感续期回调 | `{CAS_CALLBACK_BASE_URL}/api/user/cas/renew_callback` | -| CAS 单点登出回调 | `POST {CAS_CALLBACK_BASE_URL}/api/user/cas/logout_callback` | - -Apereo CAS 使用 JSON Service Registry 时,可以新增一个服务注册文件,例如 `Nexent-10001.json`。文件需要放到 CAS 部署配置的 service registry 目录中,`id` 必须全局唯一。本地 NodePort 示例: - -```json -{ - "@class": "org.apereo.cas.services.RegexRegisteredService", - "serviceId": "http://localhost:30000.*", - "name": "Nexent CAS Client", - "id": 10001, - "description": "Nexent CAS SSO client", - "evaluationOrder": 1, - "logoutType": "BACK_CHANNEL", - "logoutUrl": "http://localhost:30000/api/user/cas/logout_callback" -} -``` - -生产环境建议保持 `CAS_SSL_VERIFY=true`;自签名证书优先配置 `CAS_CA_BUNDLE`,仅本地验证时再临时设置 `CAS_SSL_VERIFY=false`。 - -#### CAS 对接 ModelEngine - -当使用 CAS 协议对接 ModelEngine 时,建议通过 values 文件配置 Nexent,避免 `CAS_ROLE_MAP_JSON` 在命令行中转义复杂。 - -创建 `cas-modelengine-values.yaml`: - -```yaml -nexent-common: - config: - cas: - enabled: true - serverUrl: "https://:5443/SSOSvr" - validatePath: "/p3/serviceValidate" - callbackBaseUrl: "http://:30000" - loginMode: "force" - userAttribute: "userName" - emailAttribute: "email" - roleAttribute: "userType" - tenantAttribute: "tenant_id" - roleMapJson: '{"1":"ADMIN","3":"DEV"}' - sessionMaxAgeSeconds: 3600 - localSessionMaxAgeSeconds: 3600 - renewBeforeSeconds: 300 - renewTimeoutSeconds: 10 - syntheticEmailDomain: "cas.local" - logoutUrl: "/logout?service=http://:30000" - sslVerify: false - caBundle: "" -``` - -同时,需要进入 OMS 容器添加 CAS client 的注册配置文件,参考如下步骤: - -```bash -# 创建注册配置文件,将 JSON 部分输入文件并保存 -vim Nexent-10000001.json -{ - "@class": "org.apereo.cas.services.CasRegisteredService", - "serviceId": "http://:30000.*", - "name": "Nexent CAS Client", - "id": 1000001, - "description": "Nexent CAS SSO client", - "evaluationOrder": 1, - "logoutType": "BACK_CHANNEL", - "logoutUrl": "http://:30000/api/user/cas/logout_callback" -} - -# 执行如下命令,将配置文件拷贝到容器中 -kubectl cp Nexent-10000001.json model-engine/$(kubectl get pods -n model-engine -l app=oms --no-headers | awk '{print $1}'):/opt/huawei/fce/apps/platform/webapps/SSOSvr/WEB-INF/classes/services/Nexent-10000001.json -kubectl exec -i -n model-engine $(kubectl get pods -n model-engine -l app=oms --no-headers | awk '{print $1}') -- chown tomcat:fusioncube /opt/huawei/fce/apps/platform/webapps/SSOSvr/WEB-INF/classes/services/Nexent-10000001.json -``` - ## 🔍 故障排查 ### 查看 Pod 状态 diff --git a/doc/docs/zh/sdk/vector-database.md b/doc/docs/zh/sdk/vector-database.md index b940400fd..940af9c33 100644 --- a/doc/docs/zh/sdk/vector-database.md +++ b/doc/docs/zh/sdk/vector-database.md @@ -579,11 +579,7 @@ python -m nexent.service.vectordatabase_service - 参数: - `index_name`: 索引名称 (路径参数) - `path_or_url`: 文档路径或URL (查询参数) - - `scope`: 删除范围 (查询参数,默认 `full`) - - `source_only`: 仅删除 MinIO 源文件,保留 ES 中的切片与向量(检索仍可用,预览不可用) - - `full`: 删除 ES 文档、MinIO 源文件,并清理相关 Redis 任务记录 - - 返回示例 (`source_only`): `{"status": "success", "scope": "source_only", "deleted_es_count": 0, "deleted_minio": true, "source_available": false}` - - 返回示例 (`full`): `{"status": "success", "scope": "full", "deleted_es_count": 5, "deleted_minio": true}` + - 返回示例: `{"status": "success", "deleted_count": 1}` #### 搜索操作 @@ -732,11 +728,8 @@ curl -X POST "http://localhost:8000/indices/search/hybrid" \ "weight_accurate": 0.3 }' -# 删除源文件(保留索引) -curl -X DELETE "http://localhost:8000/indices/my_documents/documents?path_or_url=knowledge_base/doc1.pdf&scope=source_only" - -# 从知识库彻底移除文档 -curl -X DELETE "http://localhost:8000/indices/my_documents/documents?path_or_url=knowledge_base/doc1.pdf&scope=full" +# 删除文档 +curl -X DELETE "http://localhost:8000/indices/my_documents/documents?path_or_url=https://example.com/doc1" # 创建索引 curl -X POST "http://localhost:8000/indices/my_documents" diff --git a/doc/docs/zh/user-guide/agent-development.md b/doc/docs/zh/user-guide/agent-development.md index 40805aeea..3edf31de7 100644 --- a/doc/docs/zh/user-guide/agent-development.md +++ b/doc/docs/zh/user-guide/agent-development.md @@ -113,17 +113,6 @@ Nexent 支持通过 A2A 协议与第三方 Agent 进行通信。您可以通过 > - 通过 Nacos 发现批量接入同一服务注册中心的所有 Agent > - 配置协议以兼容不同 Agent 服务提供商的要求 - -###### 通过URL对接[DataAgent](https://gitcode.com/datagallery/dataagent) A2A Agent -1. 参考[DataAgent文档](https://gitcode.com/datagallery/dataagent#%F0%9F%8C%90-a2a-10-%E6%9C%8D%E5%8A%A1%E6%A8%A1%E5%BC%8F)以A2A服务模式启动DataAgent - >当前Nexent不支持带认证的agent,启动DataAgent时请勿设置auth-token -
- -
- -2. 参考[通过 URL 发现 Agent](#通过-url-发现-agent)接入agent,url为http://\:9999/.well-known/agent-card.json -3. 参考[管理已发现的外部 Agent](#管理已发现的外部-agent)配置调用协议,选择HTTP+JSON方式接入 - ### 🛠️ 选择智能体的工具 智能体可以使用各种工具来完成任务,如知识库检索、文件解析、图片解析、收发邮件、文件管理等本地工具,也可接入第三方 MCP 工具,或自定义工具。 diff --git a/doc/docs/zh/user-guide/assets/agent-development/dataagent_deploy.png b/doc/docs/zh/user-guide/assets/agent-development/dataagent_deploy.png deleted file mode 100644 index 46fa9fde31c66bbe8c91a8dba20ec23db69fc030..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 60851 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
xFu?Ux4B$WtsjIF0&t!3sZ? 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 E11AciR{#J2 diff --git a/doc/procedural-memory-verification.md b/doc/procedural-memory-verification.md deleted file mode 100644 index ea9f53290..000000000 --- a/doc/procedural-memory-verification.md +++ /dev/null @@ -1,315 +0,0 @@ -# Procedural Memory Verification Report - -## Summary -**Status: ⚠️ FULLY SUPPORTED but REQUIRES OPTIONAL DEPENDENCY** - -Procedural memory is a fully implemented feature in mem0ai version 0.1.117, **BUT it requires `langchain-core` to be installed separately**. Without this dependency, the feature will fail at runtime. - ---- - -## ⚠️ CRITICAL FINDING: Optional Dependency Required - -**Your colleague is partially correct.** The procedural memory code is NOT empty (it's 50 lines of real implementation), but it has a critical dependency issue: - -### The Problem - -The `_create_procedural_memory()` method contains: - -```python -try: - from langchain_core.messages.utils import convert_to_messages -except Exception: - logger.error( - "Import error while loading langchain-core. " - "Please install 'langchain-core' to use procedural memory." - ) - raise # ← Fails here if langchain-core not installed -``` - -### Reality Check - -| Aspect | Status | -|--------|--------| -| Code exists? | ✅ Yes, 50 lines of real implementation | -| Code is empty/stub? | ❌ No, it's fully implemented | -| Works out of the box? | ❌ **NO** - requires `langchain-core` package | -| Documented requirement? | ⚠️ Only in error message, not in main docs | - -### Why Your Colleague Thought It Was Empty - -1. They called `memory.add(..., memory_type="procedural_memory")` -2. Got `ImportError: No module named 'langchain_core'` -3. Saw the error and concluded "it doesn't work" or "it's empty" -4. This is understandable - the feature exists but is **disabled by default** - ---- - -## Verification Results - -### 1. API Support ✅ -The `memory_type` parameter is available in both `AsyncMemory.add()` and `Memory.add()`: - -```python -async def add( - self, - messages, - *, - user_id: Optional[str] = None, - agent_id: Optional[str] = None, - run_id: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - infer: bool = True, - memory_type: Optional[str] = None, # ✅ SUPPORTED - prompt: Optional[str] = None, - llm=None -) -``` - -### 2. MemoryType Enum ✅ -Located in `mem0.configs.enums.MemoryType`: - -```python -class MemoryType(Enum): - SEMANTIC = "semantic_memory" - EPISODIC = "episodic_memory" - PROCEDURAL = "procedural_memory" # ✅ AVAILABLE -``` - -### 3. Implementation ✅ -The `_create_procedural_memory()` method exists in both `AsyncMemory` and `Memory` classes: - -**AsyncMemory signature:** -```python -async def _create_procedural_memory( - self, - messages, - metadata=None, - llm=None, - prompt=None -) -``` - -**Memory (sync) signature:** -```python -def _create_procedural_memory( - self, - messages, - metadata=None, - prompt=None -) -``` - -### 4. Validation Logic ✅ -The `add()` method validates `memory_type` and enforces constraints: - -```python -# Only "procedural_memory" is accepted -if memory_type is not None and memory_type != MemoryType.PROCEDURAL.value: - raise ValueError( - f"Invalid 'memory_type'. Please pass {MemoryType.PROCEDURAL.value} " - "to create procedural memories." - ) - -# agent_id is REQUIRED for procedural memory -if agent_id is not None and memory_type == MemoryType.PROCEDURAL.value: - results = await self._create_procedural_memory( - messages, metadata=processed_metadata, prompt=prompt, llm=llm - ) - return results -``` - -### 5. System Prompt ✅ -A comprehensive 5,100-character system prompt exists in `mem0.configs.prompts.PROCEDURAL_MEMORY_SYSTEM_PROMPT`: - -**Purpose:** Records and preserves complete interaction history between human and AI agent - -**Structure:** -- Overview (Global Metadata) - - Task Objective - - Progress Status -- Sequential Agent Actions (Numbered Steps) - - Agent Action - - Action Result (Mandatory, Unmodified) - - Embedded Metadata (Key Findings, Navigation History, Errors, Current Context) - -**Key Guidelines:** -1. Preserve every output verbatim -2. Maintain chronological order -3. Include exact data (URLs, element indexes, error messages, JSON responses) -4. Output only the structured summary - ---- - -## Usage Example - -```python -from mem0 import AsyncMemory - -# Initialize memory -memory = await AsyncMemory.from_config(config) - -# Create procedural memory -messages = [ - {"role": "user", "content": "Search for AI news"}, - {"role": "assistant", "content": "I'll search for recent AI news..."}, - # ... more conversation history -] - -result = await memory.add( - messages=messages, - user_id="user_123", - agent_id="research_agent", # ⚠️ REQUIRED for procedural memory - memory_type="procedural_memory", - metadata={ - "task": "AI news research", - "session_id": "session_456" - } -) - -# Result format: -# { -# "results": [ -# { -# "id": "memory_id_here", -# "memory": "## Summary of the agent's execution history...", -# "event": "ADD" -# } -# ] -# } -``` - ---- - -## Requirements & Constraints - -### Required Parameters -- ✅ `agent_id`: **MUST** be provided when using `memory_type="procedural_memory"` -- ✅ `metadata`: **MUST** be provided (cannot be None) -- ✅ `messages`: List of conversation messages to summarize - -### Optional Parameters -- `prompt`: Custom prompt to override default `PROCEDURAL_MEMORY_SYSTEM_PROMPT` -- `llm`: Custom LangChain ChatModel (async version only) - -### Validation Rules -1. `memory_type` must be exactly `"procedural_memory"` (or None) -2. If `memory_type="procedural_memory"` is set, `agent_id` must be provided -3. `metadata` cannot be None for procedural memories - ---- - -## Implementation Details - -### How It Works -1. **Validation**: Checks `memory_type` and required parameters -2. **Prompt Construction**: Uses default or custom system prompt -3. **LLM Summarization**: Calls LLM to generate comprehensive execution summary -4. **Embedding**: Generates embedding for the summary -5. **Storage**: Stores in vector database with `metadata["memory_type"] = "procedural_memory"` -6. **Return**: Returns memory ID and summary text - -### Async vs Sync -- **AsyncMemory**: Supports custom LangChain `llm` parameter -- **Memory**: Uses internal LLM from config only - ---- - -## Integration with Nexent - -### Current Status -The Nexent codebase does **NOT** currently use procedural memory. The `memory_type` parameter is not passed in any `add_memory()` calls. - -### Recommended Integration Points - -1. **Agent Service** (`backend/services/agent_service.py`): - - Detect when agent completes a multi-step task - - Call `add_memory_in_levels()` with `memory_type="procedural_memory"` - - Pass the full conversation history as messages - -2. **Memory Service** (`sdk/nexent/memory/memory_service.py`): - - Add `memory_type` parameter to `add_memory()` and `add_memory_in_levels()` - - Pass through to mem0's `add()` method - -3. **Agent Run Info** (`sdk/nexent/core/agents/agent_model.py`): - - Add `memory_type` field to track if current run should create procedural memory - -### Example Integration - -```python -# In agent_service.py, after agent completes a complex task -if task_complexity >= threshold: # Your logic here - await add_memory_in_levels( - messages=conversation_history, - memory_config=memory_ctx.memory_config, - tenant_id=memory_ctx.tenant_id, - user_id=memory_ctx.user_id, - agent_id=memory_ctx.agent_id, - memory_levels=["agent", "user_agent"], - memory_type="procedural_memory", # ✅ NEW PARAMETER - metadata={ - "task_type": "complex_research", - "duration_seconds": duration, - "steps_completed": step_count - } - ) -``` - ---- - -## Conclusion - -Procedural memory is a **fully functional feature** in mem0ai==0.1.117, **BUT it requires an optional dependency**. It provides: - -- ✅ Complete API support -- ✅ Comprehensive system prompt (5,100 characters) -- ✅ Proper validation and error handling -- ✅ Both sync and async implementations -- ✅ Integration with existing memory infrastructure -- ⚠️ **REQUIRES `langchain-core` package to be installed** - -### The Truth About "Empty Function" Claims - -**The code is NOT empty.** It's a 50-line implementation that: -1. Calls LLM to generate execution summary -2. Creates embeddings -3. Stores in vector database -4. Returns proper results - -**However, it fails at runtime** if `langchain-core` is not installed, which is why your colleague might have thought it was a no-op. - -### How to Enable - -**Option 1: Install the dependency** -```bash -pip install langchain-core -``` - -**Option 2: Add to Nexent's dependencies** -```toml -# In sdk/pyproject.toml -dependencies = [ - # ... existing deps ... - "langchain-core>=0.1.0", # Required for procedural memory -] -``` - -**Option 3: Make it optional with fallback** -```python -try: - result = await memory.add(..., memory_type="procedural_memory") -except ImportError as e: - if "langchain-core" in str(e): - logger.warning("Procedural memory requires langchain-core. Using regular memory.") - result = await memory.add(...) # Fallback - else: - raise -``` - -### Final Recommendation - -This feature **can be integrated into Nexent**, but you must: -1. Add `langchain-core` to dependencies, OR -2. Implement graceful fallback when dependency is missing, OR -3. Document it as an optional feature requiring extra installation - -Without addressing the dependency issue, procedural memory will fail at runtime despite having complete implementation code. diff --git a/docker/.env.example b/docker/.env.example index 3970efb95..c34300523 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -226,27 +226,3 @@ OAUTH_CALLBACK_BASE_URL=http://localhost:3000 # Asset owner role (opt-in; default false). Set true to enable ASSET_OWNER. ENABLE_ASSET_OWNER_ROLE=false - -# ===== CAS SSO Configuration ===== -CAS_ENABLED=false -CAS_SERVER_URL= -CAS_VALIDATE_PATH=/p3/serviceValidate -CAS_CALLBACK_BASE_URL=http://localhost:3000 -# Supported values: -# - disabled: disable CAS login entry and automatic CAS redirects. -# - button: show CAS as an optional login entry. -# - force: automatically redirect unauthenticated users to CAS login. -CAS_LOGIN_MODE=disabled -CAS_USER_ATTRIBUTE= -CAS_EMAIL_ATTRIBUTE=email -CAS_ROLE_ATTRIBUTE=role -CAS_TENANT_ATTRIBUTE=tenant_id -CAS_ROLE_MAP_JSON= -CAS_SESSION_MAX_AGE_SECONDS=3600 -LOCAL_SESSION_MAX_AGE_SECONDS=3600 -CAS_RENEW_BEFORE_SECONDS=300 -CAS_RENEW_TIMEOUT_SECONDS=10 -CAS_SYNTHETIC_EMAIL_DOMAIN=cas.local -CAS_LOGOUT_URL=/logout -CAS_SSL_VERIFY=true -CAS_CA_BUNDLE= diff --git a/docker/deploy.sh b/docker/deploy.sh index fbf3664b5..2069330d1 100755 --- a/docker/deploy.sh +++ b/docker/deploy.sh @@ -1367,7 +1367,7 @@ main_deploy() { echo "--------------------------------" echo "" - APP_VERSION="$(get_app_version)" + APP_VERSION="latest" if [ -z "$APP_VERSION" ]; then echo "❌ Failed to get app version, please check the backend/consts/const.py file" exit 1 diff --git a/docker/init.sql b/docker/init.sql index 046bdecf1..0668def01 100644 --- a/docker/init.sql +++ b/docker/init.sql @@ -230,7 +230,6 @@ CREATE TABLE IF NOT EXISTS "knowledge_record_t" ( "summary_frequency" varchar(10) COLLATE "pg_catalog"."default", "last_summary_time" timestamp(0), "last_doc_update_time" timestamp(0), - "preserve_source_file" boolean NOT NULL DEFAULT true, CONSTRAINT "knowledge_record_t_pk" PRIMARY KEY ("knowledge_id") ); ALTER TABLE "knowledge_record_t" OWNER TO "root"; @@ -252,7 +251,6 @@ COMMENT ON COLUMN "knowledge_record_t"."created_by" IS 'User who created the rec COMMENT ON COLUMN "knowledge_record_t"."summary_frequency" IS 'Auto-summary frequency: 1h, 3h, 6h, 1d, 1w, or NULL (disabled)'; COMMENT ON COLUMN "knowledge_record_t"."last_summary_time" IS 'Timestamp of last summary generation'; COMMENT ON COLUMN "knowledge_record_t"."last_doc_update_time" IS 'Timestamp of last document add/delete operation, used for auto-summary optimization to skip unnecessary summary regeneration'; -COMMENT ON COLUMN "knowledge_record_t"."preserve_source_file" IS 'Whether to preserve uploaded source documents after vectorization'; COMMENT ON COLUMN "knowledge_record_t"."updated_by" IS 'Last updater ID, audit field'; COMMENT ON COLUMN "knowledge_record_t"."created_by" IS 'Creator ID, audit field'; COMMENT ON TABLE "knowledge_record_t" IS 'Records knowledge base description and status information'; @@ -339,12 +337,9 @@ CREATE TABLE IF NOT EXISTS nexent.ag_tenant_agent_t ( is_new BOOLEAN DEFAULT FALSE, provide_run_summary BOOLEAN DEFAULT FALSE, enable_context_manager BOOLEAN DEFAULT FALSE, - verification_config JSONB, version_no INTEGER DEFAULT 0 NOT NULL, current_version_no INTEGER NULL, ingroup_permission VARCHAR(30), - greeting_message TEXT, - example_questions JSONB, create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_by VARCHAR(100), @@ -402,9 +397,6 @@ COMMENT ON COLUMN nexent.ag_tenant_agent_t.version_no IS 'Version number. 0 = dr COMMENT ON COLUMN nexent.ag_tenant_agent_t.current_version_no IS 'Current published version number. NULL means no version published yet'; COMMENT ON COLUMN nexent.ag_tenant_agent_t.ingroup_permission IS 'In-group permission: EDIT, READ_ONLY, PRIVATE'; COMMENT ON COLUMN nexent.ag_tenant_agent_t.enable_context_manager IS 'Whether to enable context management (compression) for this agent'; -COMMENT ON COLUMN nexent.ag_tenant_agent_t.verification_config IS 'Layered ReAct self-verification configuration'; -COMMENT ON COLUMN nexent.ag_tenant_agent_t.greeting_message IS 'Agent greeting message displayed on chat initial screen'; -COMMENT ON COLUMN nexent.ag_tenant_agent_t.example_questions IS 'List of example questions for starting a conversation with this agent'; -- Create index for is_new queries CREATE INDEX IF NOT EXISTS idx_ag_tenant_agent_t_is_new @@ -723,7 +715,6 @@ CREATE TABLE IF NOT EXISTS nexent.ag_agent_relation_t ( parent_agent_id INTEGER, tenant_id VARCHAR(100), version_no INTEGER DEFAULT 0 NOT NULL, - selected_agent_version_no INTEGER, create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_by VARCHAR(100), @@ -756,7 +747,6 @@ COMMENT ON COLUMN nexent.ag_agent_relation_t.selected_agent_id IS 'Selected agen COMMENT ON COLUMN nexent.ag_agent_relation_t.parent_agent_id IS 'Parent agent ID'; COMMENT ON COLUMN nexent.ag_agent_relation_t.tenant_id IS 'Tenant ID'; COMMENT ON COLUMN nexent.ag_agent_relation_t.version_no IS 'Version number. 0 = draft/editing state, >=1 = published snapshot'; -COMMENT ON COLUMN nexent.ag_agent_relation_t.selected_agent_version_no IS 'Pinned version of selected_agent_id. NULL = use child current published version at runtime (legacy/draft).'; COMMENT ON COLUMN nexent.ag_agent_relation_t.create_time IS 'Creation time, audit field'; COMMENT ON COLUMN nexent.ag_agent_relation_t.update_time IS 'Update time, audit field'; COMMENT ON COLUMN nexent.ag_agent_relation_t.created_by IS 'Creator ID, audit field'; @@ -1270,6 +1260,7 @@ CREATE TABLE IF NOT EXISTS nexent.ag_skill_info_t ( config_schemas JSON, config_values JSON, source VARCHAR(30) DEFAULT 'official', + tenant_id VARCHAR(100), created_by VARCHAR(100), create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(100), @@ -1909,31 +1900,3 @@ FOR EACH ROW EXECUTE FUNCTION update_mcp_community_record_update_time(); COMMENT ON TRIGGER update_mcp_community_record_update_time_trigger ON nexent.mcp_community_record_t IS 'Trigger to maintain update_time'; - -CREATE TABLE IF NOT EXISTS nexent.user_cas_session_t ( - cas_session_id SERIAL PRIMARY KEY, - session_id VARCHAR(100) NOT NULL UNIQUE, - user_id VARCHAR(100) NOT NULL, - cas_user_id VARCHAR(200) NOT NULL, - cas_session_index VARCHAR(500), - status VARCHAR(30) NOT NULL DEFAULT 'active', - expires_at TIMESTAMP NOT NULL, - revoked_at TIMESTAMP, - create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - created_by VARCHAR(100), - updated_by VARCHAR(100), - delete_flag VARCHAR(1) DEFAULT 'N' -); - -CREATE INDEX IF NOT EXISTS ix_user_cas_session_session_id - ON nexent.user_cas_session_t (session_id); -CREATE INDEX IF NOT EXISTS ix_user_cas_session_user_id - ON nexent.user_cas_session_t (user_id); -CREATE INDEX IF NOT EXISTS ix_user_cas_session_cas_user_id - ON nexent.user_cas_session_t (cas_user_id); - -COMMENT ON TABLE nexent.user_cas_session_t IS 'Server-side session records for CAS SSO login and logout synchronization'; -COMMENT ON COLUMN nexent.user_cas_session_t.session_id IS 'JWT sid claim for revocation checks'; -COMMENT ON COLUMN nexent.user_cas_session_t.cas_user_id IS 'User identifier returned by CAS'; -COMMENT ON COLUMN nexent.user_cas_session_t.cas_session_index IS 'CAS SessionIndex or service ticket'; diff --git a/docker/official-skills-zip/create-docx.zip b/docker/official-skills-zip/create-docx.zip deleted file mode 100644 index aa53e82b0ac65deceefb904d83d1eb6afaeb9b41..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39296 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 R1OQ0UuSe*2*a?_V{{w0Jg}ML$ diff --git a/docker/sql/v2.2.0_0526_add_cas_session_t.sql b/docker/sql/v2.2.0_0526_add_cas_session_t.sql deleted file mode 100644 index 3f1aab4fa..000000000 --- a/docker/sql/v2.2.0_0526_add_cas_session_t.sql +++ /dev/null @@ -1,27 +0,0 @@ -CREATE TABLE IF NOT EXISTS nexent.user_cas_session_t ( - cas_session_id SERIAL PRIMARY KEY, - session_id VARCHAR(100) NOT NULL UNIQUE, - user_id VARCHAR(100) NOT NULL, - cas_user_id VARCHAR(200) NOT NULL, - cas_session_index VARCHAR(500), - status VARCHAR(30) NOT NULL DEFAULT 'active', - expires_at TIMESTAMP NOT NULL, - revoked_at TIMESTAMP, - create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - created_by VARCHAR(100), - updated_by VARCHAR(100), - delete_flag VARCHAR(1) DEFAULT 'N' -); - -CREATE INDEX IF NOT EXISTS ix_user_cas_session_session_id - ON nexent.user_cas_session_t (session_id); -CREATE INDEX IF NOT EXISTS ix_user_cas_session_user_id - ON nexent.user_cas_session_t (user_id); -CREATE INDEX IF NOT EXISTS ix_user_cas_session_cas_user_id - ON nexent.user_cas_session_t (cas_user_id); - -COMMENT ON TABLE nexent.user_cas_session_t IS 'Server-side session records for CAS SSO login and logout synchronization'; -COMMENT ON COLUMN nexent.user_cas_session_t.session_id IS 'JWT sid claim for revocation checks'; -COMMENT ON COLUMN nexent.user_cas_session_t.cas_user_id IS 'User identifier returned by CAS'; -COMMENT ON COLUMN nexent.user_cas_session_t.cas_session_index IS 'CAS SessionIndex or service ticket'; diff --git a/docker/sql/v2.2.1_0601_add_agent_verification_config.sql b/docker/sql/v2.2.1_0601_add_agent_verification_config.sql deleted file mode 100644 index d3882e1e2..000000000 --- a/docker/sql/v2.2.1_0601_add_agent_verification_config.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Migration: Add layered ReAct self-verification config to agents --- Description: Stores per-agent verification controls for step-level and final-answer validation. - -ALTER TABLE nexent.ag_tenant_agent_t -ADD COLUMN IF NOT EXISTS verification_config JSONB; - -COMMENT ON COLUMN nexent.ag_tenant_agent_t.verification_config IS 'Layered ReAct self-verification configuration'; diff --git a/docker/sql/v2.2.1_0601_add_preserve_source_file_to_knowledge_record_t.sql b/docker/sql/v2.2.1_0601_add_preserve_source_file_to_knowledge_record_t.sql deleted file mode 100644 index 30b588a51..000000000 --- a/docker/sql/v2.2.1_0601_add_preserve_source_file_to_knowledge_record_t.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Migration: Add preserve_source_file to knowledge_record_t table --- Date: 2026-06-01 --- Description: Whether to preserve uploaded source documents after vectorization (default: true) - -ALTER TABLE nexent.knowledge_record_t -ADD COLUMN IF NOT EXISTS preserve_source_file BOOLEAN NOT NULL DEFAULT true; - -COMMENT ON COLUMN nexent.knowledge_record_t.preserve_source_file IS 'Whether to preserve uploaded source documents after vectorization'; diff --git a/docker/sql/v2.2.1_0603_add_greeting_fields_to_ag_tenant_agent_t.sql b/docker/sql/v2.2.1_0603_add_greeting_fields_to_ag_tenant_agent_t.sql deleted file mode 100644 index 7786bb902..000000000 --- a/docker/sql/v2.2.1_0603_add_greeting_fields_to_ag_tenant_agent_t.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Migration: Add greeting_message and example_questions columns to ag_tenant_agent_t table --- Date: 2026-06-03 --- Description: Add greeting message and example questions fields for agent chat initial screen - --- Add greeting_message column to ag_tenant_agent_t table -ALTER TABLE nexent.ag_tenant_agent_t -ADD COLUMN IF NOT EXISTS greeting_message TEXT; - --- Add example_questions column to ag_tenant_agent_t table -ALTER TABLE nexent.ag_tenant_agent_t -ADD COLUMN IF NOT EXISTS example_questions JSONB; - --- Add comments to the columns -COMMENT ON COLUMN nexent.ag_tenant_agent_t.greeting_message IS 'Agent greeting message displayed on chat initial screen'; -COMMENT ON COLUMN nexent.ag_tenant_agent_t.example_questions IS 'List of example questions for starting a conversation with this agent'; \ No newline at end of file diff --git a/docker/sql/v2.2.1_0605_add_ag_agent_repository_t.sql b/docker/sql/v2.2.1_0605_add_ag_agent_repository_t.sql deleted file mode 100644 index d719fc5aa..000000000 --- a/docker/sql/v2.2.1_0605_add_ag_agent_repository_t.sql +++ /dev/null @@ -1,96 +0,0 @@ --- Migration: Add ag_agent_repository_t table --- Date: 2026-06-05 --- Description: Agent marketplace repository for frozen shareable agent snapshots. - -SET search_path TO nexent; - -BEGIN; - -CREATE SEQUENCE IF NOT EXISTS nexent.ag_agent_repository_t_agent_repository_id_seq; - -CREATE TABLE IF NOT EXISTS nexent.ag_agent_repository_t ( - agent_repository_id BIGINT NOT NULL DEFAULT nextval('nexent.ag_agent_repository_t_agent_repository_id_seq'), - publisher_tenant_id VARCHAR(100) NOT NULL, - publisher_user_id VARCHAR(100) NOT NULL, - agent_id INTEGER NOT NULL, - source_version_no INTEGER NOT NULL, - name VARCHAR(100) NOT NULL, - display_name VARCHAR(100), - description TEXT, - author VARCHAR(100), - category_id INTEGER, - tags TEXT[], - tool_count INTEGER, - version_label VARCHAR(100), - agent_info_json JSONB NOT NULL, - status VARCHAR(30) DEFAULT 'NOT_SHARED', - create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, - created_by VARCHAR(100), - updated_by VARCHAR(100), - delete_flag VARCHAR(1) DEFAULT 'N', - CONSTRAINT ag_agent_repository_t_pkey PRIMARY KEY (agent_repository_id) -); - -ALTER SEQUENCE nexent.ag_agent_repository_t_agent_repository_id_seq - OWNED BY nexent.ag_agent_repository_t.agent_repository_id; - -ALTER TABLE nexent.ag_agent_repository_t OWNER TO root; - -COMMENT ON TABLE nexent.ag_agent_repository_t IS 'Agent marketplace repository for frozen shareable agent snapshots'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.agent_repository_id IS 'Agent repository listing ID, unique primary key'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.publisher_tenant_id IS 'Publisher tenant ID'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.publisher_user_id IS 'Publisher user ID'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.agent_id IS 'Root agent ID from ag_tenant_agent_t; upsert key with publisher_tenant_id'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.source_version_no IS 'Published version number frozen at share time'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.name IS 'Root agent programmatic name for display and search'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.display_name IS 'Root agent display name'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.description IS 'Root agent description'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.author IS 'Agent author'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.category_id IS 'Optional marketplace category ID'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.tags IS 'Marketplace tags'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.tool_count IS 'Total tool count across all agents in the bundle (display only)'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.version_label IS 'Repository entry version label for display (e.g. v1.0)'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.agent_info_json IS 'Frozen ExportAndImportDataFormat snapshot with optional skills'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.status IS 'Listing status: NOT_SHARED (未共享) / PENDING_REVIEW (待审核) / REJECTED (审核驳回) / SHARED (已共享)'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.create_time IS 'Creation time'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.update_time IS 'Update time'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.created_by IS 'Creator ID'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.updated_by IS 'Updater ID'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.delete_flag IS 'Soft delete flag: Y/N'; - -CREATE UNIQUE INDEX IF NOT EXISTS uq_agent_repository_tenant_agent_active - ON nexent.ag_agent_repository_t (publisher_tenant_id, agent_id) - WHERE delete_flag = 'N'; - -CREATE INDEX IF NOT EXISTS idx_agent_repository_publisher_delete - ON nexent.ag_agent_repository_t (publisher_tenant_id, delete_flag); - -CREATE INDEX IF NOT EXISTS idx_agent_repository_status_delete - ON nexent.ag_agent_repository_t (status, delete_flag); - -CREATE INDEX IF NOT EXISTS idx_agent_repository_name_delete - ON nexent.ag_agent_repository_t (name, delete_flag); - -CREATE INDEX IF NOT EXISTS idx_agent_repository_tags_gin - ON nexent.ag_agent_repository_t USING GIN (tags); - -CREATE OR REPLACE FUNCTION update_ag_agent_repository_update_time() -RETURNS TRIGGER AS $$ -BEGIN - NEW.update_time = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION update_ag_agent_repository_update_time() IS 'Auto-update update_time for ag_agent_repository_t'; - -DROP TRIGGER IF EXISTS update_ag_agent_repository_update_time_trigger ON nexent.ag_agent_repository_t; -CREATE TRIGGER update_ag_agent_repository_update_time_trigger -BEFORE UPDATE ON nexent.ag_agent_repository_t -FOR EACH ROW -EXECUTE FUNCTION update_ag_agent_repository_update_time(); - -COMMENT ON TRIGGER update_ag_agent_repository_update_time_trigger ON nexent.ag_agent_repository_t IS 'Trigger to maintain update_time'; - -COMMIT; diff --git a/docker/sql/v2.2.1_0609_add_selected_agent_version_no_to_agent_relation_t.sql b/docker/sql/v2.2.1_0609_add_selected_agent_version_no_to_agent_relation_t.sql deleted file mode 100644 index 9a67c1ab2..000000000 --- a/docker/sql/v2.2.1_0609_add_selected_agent_version_no_to_agent_relation_t.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Migration: Add selected_agent_version_no to ag_agent_relation_t --- Date: 2026-06-09 --- Description: Pin child agent version on parent-child relations at publish time. - -SET search_path TO nexent; - -BEGIN; - -ALTER TABLE nexent.ag_agent_relation_t - ADD COLUMN IF NOT EXISTS selected_agent_version_no INTEGER; - -COMMENT ON COLUMN nexent.ag_agent_relation_t.selected_agent_version_no IS - 'Pinned version of selected_agent_id. NULL = use child current published version at runtime (legacy/draft).'; - -COMMIT; diff --git a/frontend/app/[locale]/agents/components/AgentConfigComp.tsx b/frontend/app/[locale]/agents/components/AgentConfigComp.tsx index 1e750d5eb..13484595f 100644 --- a/frontend/app/[locale]/agents/components/AgentConfigComp.tsx +++ b/frontend/app/[locale]/agents/components/AgentConfigComp.tsx @@ -29,8 +29,6 @@ export default function AgentConfigComp({}: AgentConfigCompProps) { const currentAgentId = useAgentConfigStore((state) => state.currentAgentId); const isCreatingMode = useAgentConfigStore((state) => state.isCreatingMode); const isReadOnly = useAgentConfigStore((state) => state.isReadOnly()); - const selectedTools = useAgentConfigStore((state) => state.editedAgent.tools); - const selectedSkills = useAgentConfigStore((state) => state.editedAgent.skills); const [isMcpModalOpen, setIsMcpModalOpen] = useState(false); const [isSkillModalOpen, setIsSkillModalOpen] = useState(false); @@ -127,12 +125,7 @@ export default function AgentConfigComp({}: AgentConfigCompProps) { - - {t("toolPool.title")} - {selectedTools.length > 0 && ( - - )} - + {t("toolPool.title")} {t("toolPool.tooltip.functionGuide")}} color="#ffffff" @@ -151,14 +144,7 @@ export default function AgentConfigComp({}: AgentConfigCompProps) { - - - {t("skillPool.title")} - {selectedSkills && selectedSkills.length > 0 && ( - - )} - - + {t("skillPool.title")} diff --git a/frontend/app/[locale]/agents/components/agentConfig/McpConfigModal.tsx b/frontend/app/[locale]/agents/components/agentConfig/McpConfigModal.tsx index 41c8baa45..277e85d3d 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/McpConfigModal.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/McpConfigModal.tsx @@ -80,7 +80,6 @@ export default function McpConfigModal({ const [openApiJson, setOpenApiJson] = useState(""); const [openApiServiceName, setOpenApiServiceName] = useState(""); const [openApiServerUrl, setOpenApiServerUrl] = useState(""); - const [openApiHeadersTemplate, setOpenApiHeadersTemplate] = useState(""); const [importingOpenApi, setImportingOpenApi] = useState(false); const [openapiServices, setOpenapiServices] = useState([]); const [loadingOpenapiServices, setLoadingOpenapiServices] = useState(false); @@ -507,7 +506,6 @@ export default function McpConfigModal({ service_name: openApiServiceName.trim(), server_url: openApiServerUrl.trim(), openapi_json: parsedJson, - headers_template: openApiHeadersTemplate.trim() ? JSON.parse(openApiHeadersTemplate.trim()) : null, }), }); @@ -516,7 +514,6 @@ export default function McpConfigModal({ setOpenApiJson(""); setOpenApiServiceName(""); setOpenApiServerUrl(""); - setOpenApiHeadersTemplate(""); await loadOpenapiServices(); await refreshToolsAndAgents(); } else { @@ -1223,20 +1220,15 @@ export default function McpConfigModal({ style={{ flex: 3 }} /> - setOpenApiHeadersTemplate(e.target.value)} - rows={2} - disabled={actionsLocked || importingOpenApi} - /> - setOpenApiJson(e.target.value)} - rows={6} - disabled={actionsLocked || importingOpenApi} - /> +
+ setOpenApiJson(e.target.value)} + rows={6} + disabled={actionsLocked || importingOpenApi} + /> +
{ - const selectedCount = group.skills.filter(s => originalSelectedSkillIdsSet.has(s.skill_id)).length; - return { key: group.key, label: ( - - - {group.label} - - {selectedCount > 0 && ( - - )} + + {group.label} ), diff --git a/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx b/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx index 62edc3ac8..0cb73de62 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; import ToolConfigModal from "./tool/ToolConfigModal"; import { ToolGroup, Tool, ToolParam } from "@/types/agentConfig"; -import { Tabs, Collapse, message, Tooltip, Badge } from "antd"; +import { Tabs, Collapse, message, Tooltip } from "antd"; import { useAgentConfigStore } from "@/stores/agentConfigStore"; import { useToolList } from "@/hooks/agent/useToolList"; import { usePrefetchKnowledgeBases } from "@/hooks/useKnowledgeBaseSelector"; @@ -307,29 +307,21 @@ export default function ToolManagement({ // Generate Tabs configuration const tabItems = toolGroups.map((group) => { const label = t(group.label); - const selectedCount = group.subGroups - ? group.subGroups.reduce( - (sum, sg) => sum + sg.tools.filter(t => originalSelectedToolIdsSet.has(t.id)).length, 0) - : group.tools.filter(t => originalSelectedToolIdsSet.has(t.id)).length; return { key: group.key, label: ( - - - {label} - - {selectedCount > 0 && ( - - )} + + {label} ), @@ -359,25 +351,17 @@ export default function ToolManagement({ items={group.subGroups.map((subGroup, index) => ({ key: subGroup.key, label: ( - - - {subGroup.label} - - {subGroup.tools.filter(t => originalSelectedToolIdsSet.has(t.id)).length > 0 && ( - originalSelectedToolIdsSet.has(t.id)).length} - size="small" - color="blue" - /> - )} + + {subGroup.label} ), className: `tool-category-panel ${ diff --git a/frontend/app/[locale]/agents/components/agentConfig/skill/SkillConfigModal.tsx b/frontend/app/[locale]/agents/components/agentConfig/skill/SkillConfigModal.tsx index 9729007e2..6f372e2b4 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/skill/SkillConfigModal.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/skill/SkillConfigModal.tsx @@ -12,13 +12,13 @@ import { message, Tag, Skeleton, - Tooltip } from "antd"; import { Settings } from "lucide-react"; import { CloseOutlined } from "@ant-design/icons"; import { Skill, SkillParam } from "@/types/agentConfig"; import { KnowledgeBase } from "@/types/knowledgeBase"; +import { Tooltip } from "@/components/ui/tooltip"; import { saveSkillInstance } from "@/services/agentConfigService"; import KnowledgeBaseSelectorModal from "@/components/tool-config/KnowledgeBaseSelectorModal"; import { diff --git a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx index cd46d2aa3..8b6cd82d7 100644 --- a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx +++ b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useMemo, useRef } from "react"; +import { useState, useEffect, useMemo, useRef, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Button, @@ -17,11 +17,9 @@ import { } from "antd"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Zap, Maximize2, Settings2, Sparkles } from "lucide-react"; -import { Textarea } from "@/components/ui/textarea"; import { AgentConfigUpdate, - DEFAULT_AGENT_VERIFICATION_CONFIG, PromptTemplate, } from "@/types/agentConfig"; import { @@ -171,7 +169,6 @@ export default function AgentGenerateDetail({}) { constraintPrompt: editedAgent.constraint_prompt || "", fewShotsPrompt: editedAgent.few_shots_prompt || "", provideRunSummary: editedAgent.provide_run_summary || false, - verificationEnabled: editedAgent.verification_config?.enabled ?? false, businessDescription: editedAgent.business_description || "", businessLogicModelName:editedAgent.business_logic_model_name, businessLogicModelId: editedAgent.business_logic_model_id, @@ -236,7 +233,6 @@ export default function AgentGenerateDetail({}) { setOptimizeModalOpen(true); }; - const renderExpandButton = (type: "duty" | "constraint" | "few-shots") => { return (