From f4fdbeba761435f734f17a87dd1a0aa6dd1d345b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9B=90=E7=B2=92=20Yanli?= Date: Tue, 23 Jun 2026 16:05:16 +0800 Subject: [PATCH] feat(agent-v2): sync nightly updates to main (2026-06-22) (#37651) Co-authored-by: yyh Co-authored-by: Joel Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- .github/workflows/build-push.yml | 21 + api/clients/agent_backend/request_builder.py | 23 +- api/controllers/console/app/agent.py | 89 +-- .../inner_api/knowledge/retrieval.py | 14 +- .../inner_api/plugin/agent_drive.py | 36 +- .../apps/agent_app/runtime_request_builder.py | 17 +- .../agent_v2/runtime_feature_manifest.py | 37 +- .../nodes/agent_v2/runtime_request_builder.py | 168 +++-- .../workflow/nodes/agent_v2/validators.py | 1 - api/fields/agent_fields.py | 19 +- ...-b7c2d9e8a1f4_add_tenant_last_opened_at.py | 15 +- ...c2a_agent_drive_skill_metadata_refactor.py | 45 +- api/models/agent_config_entities.py | 6 - api/openapi/markdown/console-openapi.md | 75 +- api/services/agent/composer_candidates.py | 4 - api/services/agent/composer_service.py | 240 +------ api/services/agent/composer_validator.py | 2 + api/services/agent/prompt_mentions.py | 48 +- api/services/agent/skill_package_service.py | 25 +- .../agent/skill_standardize_service.py | 40 +- .../agent/skill_tool_inference_service.py | 39 -- api/services/agent_drive_service.py | 64 +- .../agent_backend/test_request_builder.py | 34 +- .../console/app/test_agent_skills.py | 73 +- .../inner_api/plugin/test_agent_drive.py | 71 +- .../inner_api/test_knowledge_retrieval.py | 45 +- .../agent_app/test_runtime_request_builder.py | 214 +++++- .../agent_v2/test_runtime_request_builder.py | 181 ++++- ...est_agent_drive_skill_metadata_refactor.py | 122 ++++ .../services/agent/test_agent_services.py | 299 ++------ .../agent/test_composer_candidates.py | 27 +- .../agent/test_composer_mention_validation.py | 1 - .../services/agent/test_prompt_mentions.py | 28 +- .../agent/test_skill_package_service.py | 13 - .../agent/test_skill_standardize_service.py | 23 +- .../test_skill_tool_inference_service.py | 79 +-- .../services/test_agent_drive_service.py | 232 +++++- api/uv.lock | 2 +- dify-agent/docker/local-sandbox/Dockerfile | 59 ++ dify-agent/docker/shellctl/Dockerfile | 25 - .../docs/dify-agent/get-started/index.md | 9 +- dify-agent/docs/dify-agent/guide/index.md | 12 +- .../user-manual/shell-layer/index.md | 37 +- dify-agent/pyproject.toml | 2 +- .../src/dify_agent/agent_stub/cli/_drive.py | 32 +- .../src/dify_agent/agent_stub/cli/_env.py | 28 +- .../src/dify_agent/agent_stub/cli/main.py | 30 +- .../agent_stub/protocol/__init__.py | 14 +- .../agent_stub/protocol/agent_stub.py | 51 +- .../dify_agent/agent_stub/server/__init__.py | 11 - .../agent_stub/server/agent_stub_drive.py | 12 +- .../agent_stub/server/agent_stub_files.py | 8 +- .../src/dify_agent/agent_stub/server/cli.py | 11 +- .../agent_stub/server/shell_agent_stub_env.py | 29 +- .../src/dify_agent/layers/drive/__init__.py | 4 +- .../src/dify_agent/layers/drive/configs.py | 32 +- .../src/dify_agent/layers/drive/layer.py | 322 ++++++++- .../src/dify_agent/layers/knowledge/layer.py | 16 +- .../src/dify_agent/layers/shell/layer.py | 18 +- .../dify_agent/runtime/compositor_factory.py | 27 +- dify-agent/src/dify_agent/server/app.py | 14 +- dify-agent/src/dify_agent/server/settings.py | 69 +- .../dify_agent/agent_stub/cli/test_drive.py | 135 +++- .../dify_agent/agent_stub/cli/test_files.py | 12 +- .../dify_agent/agent_stub/cli/test_main.py | 106 ++- .../client/test_agent_stub_client.py | 4 +- .../protocol/test_agent_stub_protocol.py | 54 +- .../agent_stub/server/test_agent_stub_app.py | 14 +- .../server/test_agent_stub_drive.py | 28 +- .../server/test_agent_stub_files.py | 28 +- .../dify_agent/agent_stub/server/test_cli.py | 8 +- .../dify_agent/layers/drive/test_configs.py | 23 +- .../dify_agent/layers/drive/test_layer.py | 200 ++++++ .../dify_agent/layers/knowledge/test_layer.py | 4 +- .../dify_agent/layers/shell/test_layer.py | 34 +- .../runtime/test_compositor_factory.py | 2 +- .../tests/local/dify_agent/server/test_app.py | 33 +- .../dify_agent/server/test_sandbox_files.py | 11 +- .../local/dify_agent/server/test_settings.py | 88 ++- .../dify_agent/test_import_boundaries.py | 10 +- dify-agent/tests/local/test_packaging.py | 21 +- dify-agent/uv.lock | 8 +- .../generated/api/console/agent/types.gen.ts | 97 +-- .../generated/api/console/agent/zod.gen.ts | 133 +--- .../generated/api/console/apps/orpc.gen.ts | 9 +- .../generated/api/console/apps/types.gen.ts | 97 +-- .../generated/api/console/apps/zod.gen.ts | 83 +-- .../workflow-log/__tests__/filter.spec.tsx | 4 +- web/app/components/base/chat/chat/hooks.ts | 12 +- .../base/chip/__tests__/index.spec.tsx | 30 +- web/app/components/base/chip/index.tsx | 11 +- .../base/sort/__tests__/index.spec.tsx | 6 +- web/app/components/base/sort/index.tsx | 17 +- .../__tests__/features-trigger.spec.tsx | 18 + .../workflow-header/features-trigger.tsx | 29 +- .../block-selector/__tests__/blocks.spec.tsx | 2 +- .../block-selector/agent-selector.tsx | 165 +++-- .../nodes/agent-v2/__tests__/hooks.spec.tsx | 6 + .../nodes/agent-v2/__tests__/node.spec.tsx | 5 +- .../nodes/agent-v2/__tests__/panel.spec.tsx | 144 +++- .../agent-orchestrate-drawer-panel.tsx | 1 + .../components/agent-roster-field.tsx | 19 +- .../workflow/nodes/agent-v2/hooks.ts | 3 + .../workflow/nodes/agent-v2/node.tsx | 6 +- .../workflow/nodes/agent-v2/panel.tsx | 35 +- web/contract/console/agent-drive.ts | 123 ++++ web/contract/router.ts | 20 +- .../agent-composer/__tests__/store.spec.ts | 42 +- .../agent-v2/agent-composer/conversions.ts | 128 +--- .../agent-v2/agent-composer/form-state.ts | 10 +- .../agent-composer/store-modules/files.ts | 17 - .../agent-composer/store-modules/skills.ts | 26 - .../agent-detail/access/access-sources.ts | 34 - .../configure/__tests__/page.spec.tsx | 29 +- .../use-agent-configure-sync.spec.tsx | 45 ++ .../__tests__/agent-prompt-editor.spec.tsx | 29 +- .../__tests__/empty-sections.spec.tsx | 14 +- .../__tests__/publish-bar.spec.tsx | 291 +++++--- .../advanced/__tests__/index.spec.tsx | 28 + .../advanced/content-moderation.tsx | 42 +- .../components/orchestrate/advanced/index.tsx | 1 + .../orchestrate/common/configurable-item.tsx | 4 +- .../components/orchestrate/common/section.tsx | 4 +- .../components/orchestrate/drive-context.ts | 146 ++++ .../files/__tests__/index.spec.tsx | 401 +++++++++-- .../orchestrate/files/api-context.ts | 7 - .../components/orchestrate/files/index.tsx | 98 +-- .../orchestrate/files/upload-dialog.tsx | 8 +- .../components/orchestrate/index.tsx | 55 +- .../knowledge/__tests__/index.spec.tsx | 10 +- .../orchestrate/prompt-editor/index.tsx | 7 +- .../orchestrate/prompt-editor/slash.tsx | 16 +- .../orchestrate/publish-bar/index.tsx | 376 ++++++++-- .../publish-bar/publish-impact-details.tsx | 97 +++ .../publish-bar/publish-impact-popover.tsx | 203 ------ .../skills/__tests__/index.spec.tsx | 663 +++++++++++++++--- .../orchestrate/skills/detail-dialog.tsx | 4 +- .../components/orchestrate/skills/index.tsx | 60 +- .../components/orchestrate/skills/item.tsx | 126 +--- .../orchestrate/skills/upload-dialog.tsx | 70 +- .../orchestrate/skills/use-skill-detail.ts | 308 ++++++++ .../tools/__tests__/index.spec.tsx | 26 + .../tools/cli-tool/__tests__/dialog.spec.tsx | 19 + .../orchestrate/tools/cli-tool/dialog.tsx | 4 +- .../preview/__tests__/chat.spec.tsx | 94 ++- .../preview/__tests__/versions-panel.spec.tsx | 94 +++ .../configure/components/preview/chat.tsx | 194 ++++- .../components/preview/versions-panel.tsx | 33 +- .../agent-v2/agent-detail/configure/page.tsx | 37 +- .../configure/use-agent-configure-sync.ts | 15 +- .../agent-detail/logs/__tests__/page.spec.tsx | 270 +++++++ .../logs/components/source-picker.tsx | 2 - .../agent-v2/agent-detail/logs/page.tsx | 62 +- .../monitoring/__tests__/metrics.spec.ts | 110 +++ .../monitoring/__tests__/page.spec.tsx | 294 ++++++++ .../agent-detail/monitoring/chart-utils.ts | 90 +-- .../agent-detail/monitoring/chart.tsx | 31 +- .../agent-detail/monitoring/metrics.ts | 145 ++++ .../agent-detail/monitoring/mock-data.ts | 85 --- .../agent-v2/agent-detail/monitoring/page.tsx | 233 ++++-- .../__tests__/agent-roster-list.spec.tsx | 149 +++- .../__tests__/create-agent-dialog.spec.tsx | 20 +- .../__tests__/edit-agent-dialog.spec.tsx | 16 + .../roster/components/agent-form-fields.tsx | 122 ++++ .../agent-v2/roster/components/agent-form.ts | 54 ++ .../roster/components/agent-roster-list.tsx | 50 +- .../roster/components/create-agent-dialog.tsx | 127 +--- .../components/duplicate-agent-dialog.tsx | 234 +++++++ .../roster/components/edit-agent-dialog.tsx | 210 ++---- web/i18n/ar-TN/agent-v-2.json | 6 +- web/i18n/de-DE/agent-v-2.json | 6 +- web/i18n/en-US/agent-v-2.json | 9 +- web/i18n/es-ES/agent-v-2.json | 6 +- web/i18n/fa-IR/agent-v-2.json | 6 +- web/i18n/fr-FR/agent-v-2.json | 6 +- web/i18n/hi-IN/agent-v-2.json | 6 +- web/i18n/id-ID/agent-v-2.json | 6 +- web/i18n/it-IT/agent-v-2.json | 6 +- web/i18n/ja-JP/agent-v-2.json | 6 +- web/i18n/ko-KR/agent-v-2.json | 6 +- web/i18n/nl-NL/agent-v-2.json | 6 +- web/i18n/pl-PL/agent-v-2.json | 6 +- web/i18n/pt-BR/agent-v-2.json | 6 +- web/i18n/ro-RO/agent-v-2.json | 6 +- web/i18n/ru-RU/agent-v-2.json | 6 +- web/i18n/sl-SI/agent-v-2.json | 6 +- web/i18n/th-TH/agent-v-2.json | 6 +- web/i18n/tr-TR/agent-v-2.json | 6 +- web/i18n/uk-UA/agent-v-2.json | 6 +- web/i18n/vi-VN/agent-v-2.json | 6 +- web/i18n/zh-Hans/agent-v-2.json | 9 +- web/i18n/zh-Hant/agent-v-2.json | 6 +- 192 files changed, 7930 insertions(+), 3675 deletions(-) create mode 100644 api/tests/unit_tests/migrations/test_agent_drive_skill_metadata_refactor.py create mode 100644 dify-agent/docker/local-sandbox/Dockerfile delete mode 100644 dify-agent/docker/shellctl/Dockerfile create mode 100644 dify-agent/tests/local/dify_agent/layers/drive/test_layer.py create mode 100644 web/contract/console/agent-drive.ts delete mode 100644 web/features/agent-v2/agent-composer/store-modules/files.ts delete mode 100644 web/features/agent-v2/agent-composer/store-modules/skills.ts delete mode 100644 web/features/agent-v2/agent-detail/access/access-sources.ts create mode 100644 web/features/agent-v2/agent-detail/configure/components/orchestrate/advanced/__tests__/index.spec.tsx create mode 100644 web/features/agent-v2/agent-detail/configure/components/orchestrate/drive-context.ts delete mode 100644 web/features/agent-v2/agent-detail/configure/components/orchestrate/files/api-context.ts create mode 100644 web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/publish-impact-details.tsx delete mode 100644 web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/publish-impact-popover.tsx create mode 100644 web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/use-skill-detail.ts create mode 100644 web/features/agent-v2/agent-detail/configure/components/preview/__tests__/versions-panel.spec.tsx create mode 100644 web/features/agent-v2/agent-detail/logs/__tests__/page.spec.tsx create mode 100644 web/features/agent-v2/agent-detail/monitoring/__tests__/metrics.spec.ts create mode 100644 web/features/agent-v2/agent-detail/monitoring/__tests__/page.spec.tsx create mode 100644 web/features/agent-v2/agent-detail/monitoring/metrics.ts delete mode 100644 web/features/agent-v2/agent-detail/monitoring/mock-data.ts create mode 100644 web/features/agent-v2/roster/components/agent-form-fields.tsx create mode 100644 web/features/agent-v2/roster/components/agent-form.ts create mode 100644 web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 0134ea850d0..63ce63cad42 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -21,6 +21,7 @@ env: DIFY_WEB_IMAGE_NAME: ${{ vars.DIFY_WEB_IMAGE_NAME || 'langgenius/dify-web' }} DIFY_API_IMAGE_NAME: ${{ vars.DIFY_API_IMAGE_NAME || 'langgenius/dify-api' }} DIFY_AGENT_IMAGE_NAME: ${{ vars.DIFY_AGENT_IMAGE_NAME || 'langgenius/dify-agent-backend' }} + DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME: ${{ vars.DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME || 'langgenius/dify-agent-local-sandbox' }} jobs: build: @@ -74,6 +75,20 @@ jobs: file: "dify-agent/Dockerfile" platform: linux/arm64 runs_on: depot-ubuntu-24.04-4 + - service_name: "build-agent-local-sandbox-amd64" + image_name_env: "DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME" + artifact_context: "local-sandbox" + build_context: "{{defaultContext}}:dify-agent" + file: "docker/local-sandbox/Dockerfile" + platform: linux/amd64 + runs_on: depot-ubuntu-24.04-4 + - service_name: "build-agent-local-sandbox-arm64" + image_name_env: "DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME" + artifact_context: "local-sandbox" + build_context: "{{defaultContext}}:dify-agent" + file: "docker/local-sandbox/Dockerfile" + platform: linux/arm64 + runs_on: depot-ubuntu-24.04-4 steps: - name: Prepare @@ -139,6 +154,9 @@ jobs: - service_name: "validate-agent-amd64" build_context: "{{defaultContext}}" file: "dify-agent/Dockerfile" + - service_name: "validate-agent-local-sandbox-amd64" + build_context: "{{defaultContext}}:dify-agent" + file: "docker/local-sandbox/Dockerfile" steps: - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 @@ -167,6 +185,9 @@ jobs: - service_name: "merge-agent-images" image_name_env: "DIFY_AGENT_IMAGE_NAME" context: "agent" + - service_name: "merge-agent-local-sandbox-images" + image_name_env: "DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME" + context: "local-sandbox" steps: - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py index c245a09e970..6eadd4ce3d8 100644 --- a/api/clients/agent_backend/request_builder.py +++ b/api/clients/agent_backend/request_builder.py @@ -78,6 +78,13 @@ def _filter_snapshot_to_specs( return CompositorSessionSnapshot(schema_version=snapshot.schema_version, layers=filtered_layers) +def _shell_layer_deps(*, include_drive: bool) -> dict[str, str]: + deps = {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} + if include_drive: + deps["drive"] = DIFY_DRIVE_LAYER_ID + return deps + + class AgentBackendModelConfig(BaseModel): """API-side model/plugin selection before it is converted to Dify Agent layers.""" @@ -263,6 +270,7 @@ class AgentBackendRunRequestBuilder: RunLayerSpec( name=DIFY_DRIVE_LAYER_ID, type=DIFY_DRIVE_LAYER_TYPE_ID, + deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, metadata=run_input.metadata, config=run_input.drive_config, ) @@ -329,14 +337,15 @@ class AgentBackendRunRequestBuilder: ) if run_input.include_shell: - # Sandboxed bash workspace (dify.shell). Depends on execution_context so - # the agent server can mint per-command Agent Stub env (back proxy); + # Sandboxed bash workspace (dify.shell). Depends on execution_context + # so the agent server can mint per-command Agent Stub env, and on + # drive when present so that env points at /mnt/drive/. # shellctl connection itself is server-injected. layers.append( RunLayerSpec( name=DIFY_SHELL_LAYER_ID, type=DIFY_SHELL_LAYER_TYPE_ID, - deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, + deps=_shell_layer_deps(include_drive=run_input.drive_config is not None), metadata=run_input.metadata, config=run_input.shell_config or DifyShellLayerConfig(), ) @@ -460,6 +469,7 @@ class AgentBackendRunRequestBuilder: RunLayerSpec( name=DIFY_DRIVE_LAYER_ID, type=DIFY_DRIVE_LAYER_TYPE_ID, + deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, metadata=run_input.metadata, config=run_input.drive_config, ) @@ -528,14 +538,15 @@ class AgentBackendRunRequestBuilder: ) if run_input.include_shell: - # Sandboxed bash workspace (dify.shell). Depends on execution_context so - # the agent server can mint per-command Agent Stub env (back proxy); + # Sandboxed bash workspace (dify.shell). Depends on execution_context + # so the agent server can mint per-command Agent Stub env, and on + # drive when present so that env points at /mnt/drive/. # shellctl connection itself is server-injected. layers.append( RunLayerSpec( name=DIFY_SHELL_LAYER_ID, type=DIFY_SHELL_LAYER_TYPE_ID, - deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, + deps=_shell_layer_deps(include_drive=run_input.drive_config is not None), metadata=run_input.metadata, config=run_input.shell_config or DifyShellLayerConfig(), ) diff --git a/api/controllers/console/app/agent.py b/api/controllers/console/app/agent.py index a53a174da42..86a3c473547 100644 --- a/api/controllers/console/app/agent.py +++ b/api/controllers/console/app/agent.py @@ -1,4 +1,3 @@ -import logging from typing import Any from uuid import UUID @@ -30,7 +29,6 @@ from fields.base import ResponseModel from libs.helper import uuid_value from libs.login import login_required from models import Account -from models.agent_config_entities import AgentFileRefConfig, AgentSkillRefConfig from models.model import App, AppMode, UploadFile from services.agent.composer_service import AgentComposerService from services.agent.skill_package_service import SkillManifest, SkillPackageError @@ -49,8 +47,6 @@ from services.agent_drive_service import ( ) from services.agent_service import AgentService -logger = logging.getLogger(__name__) - _WORKFLOW_AGENT_DRIVE_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT] _AGENT_SKILL_UPLOAD_PARAMS = { "file": { @@ -130,8 +126,16 @@ class AgentLogResponse(ResponseModel): files: list[Any] = Field(default_factory=list) +class AgentUploadedSkillResponse(ResponseModel): + name: str + description: str + path: str + skill_md_key: str + archive_key: str | None = None + + class AgentSkillUploadResponse(ResponseModel): - skill: AgentSkillRefConfig + skill: AgentUploadedSkillResponse manifest: SkillManifest @@ -145,13 +149,11 @@ class AgentDriveFileResponse(ResponseModel): class AgentDriveFileCommitResponse(ResponseModel): file: AgentDriveFileResponse - config_version_id: str | None = None class AgentDriveDeleteResponse(ResponseModel): result: str removed_keys: list[str] = Field(default_factory=list) - config_version_id: str | None = None register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload, AgentDriveDeleteFileByAgentQuery) @@ -161,6 +163,7 @@ register_response_schema_models( AgentDriveFileCommitResponse, AgentDriveFileResponse, AgentLogResponse, + AgentUploadedSkillResponse, AgentSkillUploadResponse, SkillToolInferenceResult, ) @@ -242,24 +245,6 @@ def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_n return {"code": exc.code, "message": exc.message}, exc.status_code row = committed[0] - file_ref = AgentFileRefConfig.model_validate( - { - "id": row["key"], - "name": upload_file.name, - "file_id": upload_file.id, - "drive_key": row["key"], - "type": row.get("mime_type"), - "size": row.get("size"), - } - ) - config_version_id = AgentComposerService.add_drive_file_ref( - tenant_id=app_model.tenant_id, - agent_id=agent_id, - account_id=current_user.id, - file_ref=file_ref, - app_id=app_model.id, - node_id=node_id, - ) return { "file": { "name": upload_file.name, @@ -268,7 +253,6 @@ def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_n "size": row.get("size"), "mime_type": row.get("mime_type"), }, - "config_version_id": config_version_id, }, 201 @@ -283,24 +267,17 @@ def _delete_drive_file_for_app(*, current_user: Account, app_model: App, allow_n except AgentDriveError as exc: return {"code": exc.code, "message": exc.message}, exc.status_code - config_version_id = AgentComposerService.remove_drive_refs( - tenant_id=app_model.tenant_id, - agent_id=agent_id, - account_id=current_user.id, - file_key=key, - app_id=app_model.id, - node_id=node_id, - ) - removed_keys: list[str] = [] try: - removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, key=key) + result = AgentDriveService().commit( + tenant_id=app_model.tenant_id, + user_id=current_user.id, + agent_id=agent_id, + items=[DriveCommitItem(key=key, file_ref=None)], + ) except AgentDriveError as exc: return {"code": exc.code, "message": exc.message}, exc.status_code - except Exception: - # Soul-first ordering: the ref is already gone; orphan KV rows are - # harmless and an idempotent DELETE retry cleans them. - logger.exception("agent drive delete failed for key %s (soul already updated)", key) - return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id} + removed_keys = [item["key"] for item in result if item.get("removed")] + return {"result": "success", "removed_keys": removed_keys} def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, allow_node_id: bool = True): @@ -312,22 +289,20 @@ def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, a if "/" in slug or not slug.strip(): return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400 - config_version_id = AgentComposerService.remove_drive_refs( - tenant_id=app_model.tenant_id, - agent_id=agent_id, - account_id=current_user.id, - skill_slug=slug, - app_id=app_model.id, - node_id=node_id, - ) - removed_keys: list[str] = [] try: - removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=f"{slug}/") + result = AgentDriveService().commit( + tenant_id=app_model.tenant_id, + user_id=current_user.id, + agent_id=agent_id, + items=[ + DriveCommitItem(key=f"{slug}/SKILL.md", file_ref=None), + DriveCommitItem(key=f"{slug}/.DIFY-SKILL-FULL.zip", file_ref=None), + ], + ) except AgentDriveError as exc: return {"code": exc.code, "message": exc.message}, exc.status_code - except Exception: - logger.exception("agent drive delete failed for skill %s (soul already updated)", slug) - return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id} + removed_keys = [item["key"] for item in result if item.get("removed")] + return {"result": "success", "removed_keys": removed_keys} def _infer_skill_tools_for_app(*, app_model: App, slug: str): @@ -455,7 +430,7 @@ class AgentDriveFilesApi(Resource): return _commit_drive_file_for_app(current_user=current_user, app_model=app_model) @console_ns.doc("delete_agent_drive_file") - @console_ns.doc(description="Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5)") + @console_ns.doc(description="Delete one drive file by key via drive commit-null semantics") @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveDeleteFileQuery)}) @console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__]) @setup_required @@ -486,9 +461,7 @@ class AgentSkillByAgentApi(Resource): @console_ns.route("/apps//agent/skills/") class AgentSkillApi(Resource): @console_ns.doc("delete_agent_skill") - @console_ns.doc( - description="Delete a standardized skill: soul ref first, then the / drive prefix (ENG-625 D5)" - ) + @console_ns.doc(description="Delete a standardized skill by removing its known drive keys via commit-null") @console_ns.doc( params={ "app_id": "Application ID", diff --git a/api/controllers/inner_api/knowledge/retrieval.py b/api/controllers/inner_api/knowledge/retrieval.py index ef33fbda518..1c1320fde42 100644 --- a/api/controllers/inner_api/knowledge/retrieval.py +++ b/api/controllers/inner_api/knowledge/retrieval.py @@ -1,9 +1,10 @@ -"""Inner API endpoint for tenant-scoped knowledge retrieval. +"""Plugin inner API endpoint for tenant-scoped knowledge retrieval. This controller is a thin HTTP wrapper around ``services.knowledge_retrieval_inner_service.InnerKnowledgeRetrievalService``. -It intentionally keeps authorization simple: shared inner API key plus -tenant-scoped app/dataset validation in the service layer. +It uses the plugin inner API key because dify-agent calls this endpoint through +the same trusted Dify API bridge as other agent/plugin inner calls; tenant-scoped +app/dataset validation remains in the service layer. """ from flask_restx import Resource @@ -11,7 +12,7 @@ from pydantic import ValidationError from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.inner_api import inner_api_ns -from controllers.inner_api.wraps import inner_api_only +from controllers.inner_api.wraps import plugin_inner_api_only from core.workflow.nodes.knowledge_retrieval import exc as retrieval_exc from libs.exception import BaseHTTPException from services.entities.knowledge_retrieval_inner import InnerKnowledgeRetrieveRequest, InnerKnowledgeRetrieveResponse @@ -48,7 +49,7 @@ register_response_schema_models(inner_api_ns, InnerKnowledgeRetrieveResponse) class InnerKnowledgeRetrieveApi(Resource): """Retrieve knowledge from one or more datasets within the caller tenant.""" - @inner_api_only + @plugin_inner_api_only @inner_api_ns.doc("inner_knowledge_retrieve") @inner_api_ns.doc(description="Retrieve knowledge for trusted internal callers") @inner_api_ns.expect(inner_api_ns.models[InnerKnowledgeRetrieveRequest.__name__]) @@ -60,9 +61,8 @@ class InnerKnowledgeRetrieveApi(Resource): @inner_api_ns.doc( responses={ 400: "Invalid request body", - 401: "Unauthorized - invalid inner API key", 403: "Caller tenant does not own the requested resource", - 404: "App or dataset not found", + 404: "Invalid plugin inner API key, app not found, or dataset not found", 422: "Invalid retrieval configuration", 429: "Knowledge retrieval rate limited", 502: "External knowledge retrieval failed", diff --git a/api/controllers/inner_api/plugin/agent_drive.py b/api/controllers/inner_api/plugin/agent_drive.py index a80caea3c55..0cdb9dab35f 100644 --- a/api/controllers/inner_api/plugin/agent_drive.py +++ b/api/controllers/inner_api/plugin/agent_drive.py @@ -1,10 +1,12 @@ -"""Inner API for the agent drive (agent 网盘) control plane — ENG-591. +"""Inner API for the agent drive (agent 网盘) control plane. -Two endpoints, called by the dify-agent server (not the sandbox) with the inner -API key. The drive ref is the URL segment ``agent-``; the path-like -file key travels in the query/body, never as a URL path segment (so its ``/`` -characters do not collide with routing). Drive-owned semantics: tenant scoped, -no user-level FileAccessScope. +These endpoints are called by the dify-agent server (not the sandbox) with the +inner API key. The drive ref is the URL segment ``agent-``; the +path-like file key travels in the query/body, never as a URL path segment (so +its ``/`` characters do not collide with routing). Drive-owned semantics: +tenant scoped, no user-level FileAccessScope. Commit still canonicalizes the +trusted execution-context user through the same EndUser lookup as plugin file +upload before validating ToolFile ownership. """ from flask import request @@ -13,6 +15,7 @@ from pydantic import BaseModel, ValidationError from controllers.console.wraps import setup_required from controllers.inner_api import inner_api_ns +from controllers.inner_api.plugin.wraps import get_user from controllers.inner_api.wraps import plugin_inner_api_only from services.agent_drive_service import ( AgentDriveError, @@ -56,6 +59,24 @@ class AgentDriveManifestApi(Resource): return {"items": items} +@inner_api_ns.route("/drive//skills") +class AgentDriveSkillsApi(Resource): + @setup_required + @plugin_inner_api_only + @inner_api_ns.doc("agent_drive_skills") + @inner_api_ns.doc(description="List the skill catalog of an agent drive") + def get(self, drive_ref: str): + try: + agent_id = parse_agent_drive_ref(drive_ref) + tenant_id = (request.args.get("tenant_id") or "").strip() + if not tenant_id: + raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400) + items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=agent_id) + except AgentDriveError as exc: + return _error_response(exc) + return {"items": items} + + @inner_api_ns.route("/drive//commit") class AgentDriveCommitApi(Resource): @setup_required @@ -69,9 +90,10 @@ class AgentDriveCommitApi(Resource): body = _CommitRequest.model_validate(request.get_json(silent=True) or {}) except ValidationError as exc: raise AgentDriveError("invalid_request", str(exc), status_code=400) from exc + user = get_user(body.tenant_id, body.user_id) items = AgentDriveService().commit( tenant_id=body.tenant_id, - user_id=body.user_id, + user_id=user.id, agent_id=agent_id, items=body.items, ) diff --git a/api/core/app/apps/agent_app/runtime_request_builder.py b/api/core/app/apps/agent_app/runtime_request_builder.py index 01206b12db6..fc1fcb0b168 100644 --- a/api/core/app/apps/agent_app/runtime_request_builder.py +++ b/api/core/app/apps/agent_app/runtime_request_builder.py @@ -37,6 +37,7 @@ from core.workflow.nodes.agent_v2.plugin_tools_builder import ( from core.workflow.nodes.agent_v2.runtime_request_builder import ( append_runtime_warnings, build_ask_human_layer_config, + build_drive_aware_soul_mention_resolver, build_drive_layer_config, build_knowledge_layer_config, build_shell_layer_config, @@ -123,9 +124,19 @@ class AgentAppRuntimeRequestBuilder: } drive_config = None + soul_prompt_resolver = build_soul_mention_resolver(agent_soul) if dify_config.AGENT_DRIVE_MANIFEST_ENABLED: - drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent_id) + drive_config, drive_warnings = build_drive_layer_config( + agent_soul, + tenant_id=context.dify_context.tenant_id, + agent_id=context.agent_id, + ) append_runtime_warnings(metadata, drive_warnings) + soul_prompt_resolver = build_drive_aware_soul_mention_resolver( + agent_soul, + tenant_id=context.dify_context.tenant_id, + agent_id=context.agent_id, + ) knowledge_config = build_knowledge_layer_config(agent_soul) request = self._request_builder.build_for_agent_app( @@ -154,9 +165,7 @@ class AgentAppRuntimeRequestBuilder: ), # ENG-616: expand slash-menu mention tokens to canonical names so # no frontend-internal {{#…#}} marker ever reaches the model. - agent_soul_prompt=expand_prompt_mentions( - agent_soul.prompt.system_prompt, build_soul_mention_resolver(agent_soul) - ).strip() + agent_soul_prompt=expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip() or None, user_prompt=context.user_query, tools=tools_layer, diff --git a/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py index 65c5d42e916..fa7b28cbb0a 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py +++ b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py @@ -16,9 +16,6 @@ SUPPORTED_AGENT_BACKEND_FEATURES = frozenset( "knowledge", "env", "sandbox", - # ENG-623: exposed at runtime as the dify.drive declaration layer - # (an index the agent pulls through the back proxy). - "skills_files", # ENG-635: human involvement is exposed at runtime as the dify.ask_human # deferred tool; a call pauses via the existing HITL form mechanism. "human", @@ -32,11 +29,7 @@ RESERVED_AGENT_BACKEND_FEATURES = frozenset( ) -def build_runtime_feature_manifest( - agent_soul: AgentSoulConfig, - *, - drive_manifest_enabled: bool = False, -) -> dict[str, Any]: +def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any]: """Describe PRD capabilities supported by or still reserved from Agent backend runtime.""" warnings: list[dict[str, str]] = [] soul_dump = agent_soul.model_dump(mode="json", exclude_none=True, exclude_defaults=True) @@ -54,38 +47,10 @@ def build_runtime_feature_manifest( } ) - has_skills_files = bool(agent_soul.skills_files.skills or agent_soul.skills_files.files) - if has_skills_files and not drive_manifest_enabled: - warnings.append( - { - "section": "agent_soul.skills_files", - "code": "drive_manifest_disabled", - "message": ( - "skills_files is configured but AGENT_DRIVE_MANIFEST_ENABLED is off; " - "the drive declaration layer is not injected into this run." - ), - } - ) - for skill in agent_soul.skills_files.skills: - if not skill.skill_md_key: - warnings.append( - { - "section": "agent_soul.skills_files", - "code": "skill_ref_dangling", - "message": ( - f"skill_ref_dangling: skill '{skill.name or skill.id or 'unknown'}' has no drive key; " - "re-standardize it to expose it at runtime." - ), - } - ) - reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed") reserved_status["knowledge"] = ( "supported_by_knowledge_layer" if list_configured_knowledge_dataset_ids(agent_soul) else "not_configured" ) - reserved_status["skills_files"] = ( - "supported_by_drive_manifest" if drive_manifest_enabled else "drive_manifest_disabled" - ) reserved_status["tools.dify_tools"] = "supported_when_config_valid" reserved_status["tools.cli_tools"] = "supported_by_shell_bootstrap" reserved_status["env"] = "supported_by_shell_bootstrap" diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index 8aaa4fcc1d3..e3c2dcee839 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -7,7 +7,6 @@ from typing import Any, Literal, Protocol, assert_never, cast from agenton.compositor import CompositorSessionSnapshot from dify_agent.layers.ask_human import DifyAskHumanLayerConfig from dify_agent.layers.drive import ( - DifyDriveFileConfig, DifyDriveLayerConfig, DifyDriveSkillConfig, ) @@ -55,10 +54,13 @@ from models.agent_config_entities import ( ) from models.provider_ids import ModelProviderID from services.agent.prompt_mentions import ( + MentionKind, build_node_job_mention_resolver, build_soul_mention_resolver, expand_prompt_mentions, + parse_prompt_mentions, ) +from services.agent_drive_service import AgentDriveService, decode_drive_mention_ref from .output_failure_orchestrator import retry_idempotency_key from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError @@ -153,9 +155,6 @@ class WorkflowAgentRuntimeRequestBuilder: expand_prompt_mentions(node_job.workflow_prompt, build_node_job_mention_resolver(node_job)).strip() or "Run this workflow Agent Node for the current run." ) - soul_prompt = expand_prompt_mentions( - agent_soul.prompt.system_prompt, build_soul_mention_resolver(agent_soul) - ).strip() user_prompt = workflow_context_prompt.strip() or "Use the current workflow context." credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model) try: @@ -182,9 +181,20 @@ class WorkflowAgentRuntimeRequestBuilder: } drive_config: DifyDriveLayerConfig | None = None + soul_prompt_resolver = build_soul_mention_resolver(agent_soul) if dify_config.AGENT_DRIVE_MANIFEST_ENABLED: - drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent.id) + drive_config, drive_warnings = build_drive_layer_config( + agent_soul, + tenant_id=context.dify_context.tenant_id, + agent_id=context.agent.id, + ) append_runtime_warnings(metadata, drive_warnings) + soul_prompt_resolver = build_drive_aware_soul_mention_resolver( + agent_soul, + tenant_id=context.dify_context.tenant_id, + agent_id=context.agent.id, + ) + soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip() knowledge_config = build_knowledge_layer_config(agent_soul) request = self._request_builder.build_for_workflow_node( @@ -292,10 +302,7 @@ class WorkflowAgentRuntimeRequestBuilder: "agent_config_snapshot_id": context.snapshot.id, "binding_id": context.binding.id, "workflow_node_job_mode": node_job.mode.value, - "runtime_support": build_runtime_feature_manifest( - agent_soul, - drive_manifest_enabled=dify_config.AGENT_DRIVE_MANIFEST_ENABLED, - ), + "runtime_support": build_runtime_feature_manifest(agent_soul), } def _build_workflow_context_prompt( @@ -603,76 +610,107 @@ def append_runtime_warnings(metadata: dict[str, Any], warnings: list[dict[str, s existing.extend(warnings) +def build_drive_aware_soul_mention_resolver( + agent_soul: AgentSoulConfig, + *, + tenant_id: str, + agent_id: str, +): + """Resolve skill/file mentions against the agent drive and everything else via Agent Soul.""" + + base_resolver = build_soul_mention_resolver(agent_soul) + drive_service = AgentDriveService() + skill_catalog = drive_service.list_skills(tenant_id=tenant_id, agent_id=agent_id) + skill_names_by_key = {skill["skill_md_key"]: skill["name"] for skill in skill_catalog} + drive_keys = {item["key"] for item in drive_service.manifest(tenant_id=tenant_id, agent_id=agent_id)} + + def _resolve(mention: object) -> str | None: + if not hasattr(mention, "kind") or not hasattr(mention, "ref_id"): + return None + kind = cast(MentionKind, mention.kind) + ref_id = cast(str, mention.ref_id) + label = cast(str | None, getattr(mention, "label", None)) + if kind == MentionKind.SKILL: + decoded_key = decode_drive_mention_ref(ref_id) + return skill_names_by_key.get(decoded_key) or label or decoded_key + if kind == MentionKind.FILE: + decoded_key = decode_drive_mention_ref(ref_id) + if decoded_key in drive_keys: + return decoded_key.rsplit("/", 1)[-1] + return label or decoded_key + return base_resolver(cast(Any, mention)) + + return _resolve + + def build_drive_layer_config( agent_soul: AgentSoulConfig, *, + tenant_id: str, agent_id: str | None, ) -> tuple[DifyDriveLayerConfig | None, list[dict[str, str]]]: - """Catalog the soul's drive-backed Skills & Files into the dify.drive declaration. + """Derive drive runtime catalog + prompt-mentioned eager-pull keys from the drive.""" - Returns ``(config, warnings)`` — ``config is None`` means nothing to inject - (no skills/files configured, or no agent identity to address the drive by). - Refs that predate standardization (no drive key) are skipped with a warning - instead of failing the run, so historic souls keep running. - """ - skill_refs = agent_soul.skills_files.skills - file_refs = agent_soul.skills_files.files - if not skill_refs and not file_refs: - return None, [] - - warnings: list[dict[str, str]] = [] + mentioned_drive_refs = [ + decode_drive_mention_ref(mention.ref_id) + for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt) + if mention.kind in {MentionKind.SKILL, MentionKind.FILE} + ] + ordered_mentions = list(dict.fromkeys(ref for ref in mentioned_drive_refs if ref)) if not agent_id: + if not ordered_mentions: + return None, [] + return None, [ + { + "section": "agent_soul.prompt.system_prompt", + "code": "drive_ref_dangling", + "message": "drive mentions are configured but the run has no bound agent to address a drive by.", + } + ] + + drive_service = AgentDriveService() + skills_catalog = drive_service.list_skills(tenant_id=tenant_id, agent_id=agent_id) + manifest_items = drive_service.manifest(tenant_id=tenant_id, agent_id=agent_id) + manifest_by_key = {item["key"]: item for item in manifest_items} + skill_keys = {skill["skill_md_key"] for skill in skills_catalog} + warnings: list[dict[str, str]] = [] + mentioned_skill_keys: list[str] = [] + mentioned_file_keys: list[str] = [] + for drive_key in ordered_mentions: + if drive_key in skill_keys: + mentioned_skill_keys.append(drive_key) + continue + if drive_key in manifest_by_key: + mentioned_file_keys.append(drive_key) + continue warnings.append( { - "section": "agent_soul.skills_files", - "code": "skill_ref_dangling", - "message": "skills_files is configured but the run has no bound agent to address a drive by.", + "section": "agent_soul.prompt.system_prompt", + "code": "mention_target_missing", + "message": f"drive mention '{drive_key}' has no matching drive entry.", } ) - return None, warnings - skills: list[DifyDriveSkillConfig] = [] - for skill in skill_refs: - if not skill.skill_md_key: - warnings.append( - { - "section": "agent_soul.skills_files", - "code": "skill_ref_dangling", - "message": ( - f"skill_ref_dangling: skill '{skill.name or skill.id or 'unknown'}' has no drive key; " - "re-standardize it to expose it at runtime." - ), - } - ) - continue - skills.append( - DifyDriveSkillConfig( - name=skill.name or skill.skill_md_key.split("/", 1)[0], - description=skill.description or "", - skill_md_key=skill.skill_md_key, - archive_key=skill.full_archive_key, - ) + skills = [ + DifyDriveSkillConfig( + path=skill["path"], + name=skill["name"], + description=skill["description"], + skill_md_key=skill["skill_md_key"], + archive_key=skill["archive_key"], ) + for skill in skills_catalog + ] - files: list[DifyDriveFileConfig] = [] - for file in file_refs: - if not file.drive_key: - # Plain upload references (pre-ENG-625) are not drive-backed; they are - # simply invisible to the manifest rather than a defect worth warning on. - continue - size = file.get("size") - files.append( - DifyDriveFileConfig( - name=file.name or file.drive_key.rsplit("/", 1)[-1], - key=file.drive_key, - size=size if isinstance(size, int) else None, - mime_type=file.type, - ) - ) - - if not skills and not files: - return None, warnings - return DifyDriveLayerConfig(drive_ref=f"agent-{agent_id}", skills=skills, files=files), warnings + return ( + DifyDriveLayerConfig( + drive_ref=f"agent-{agent_id}", + skills=skills, + mentioned_skill_keys=mentioned_skill_keys, + mentioned_file_keys=mentioned_file_keys, + ), + warnings, + ) def _cli_tool_enabled(item: object) -> bool: diff --git a/api/core/workflow/nodes/agent_v2/validators.py b/api/core/workflow/nodes/agent_v2/validators.py index ca3adb5b0d1..2eabac10dd6 100644 --- a/api/core/workflow/nodes/agent_v2/validators.py +++ b/api/core/workflow/nodes/agent_v2/validators.py @@ -35,7 +35,6 @@ class WorkflowAgentNodeValidator: "soul", "prompt", "system_prompt", - "skills_files", "skills", "files", "tools", diff --git a/api/fields/agent_fields.py b/api/fields/agent_fields.py index 07bcbad26e3..e60a6b01426 100644 --- a/api/fields/agent_fields.py +++ b/api/fields/agent_fields.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Annotated, Literal +from typing import Literal from pydantic import Field, field_validator @@ -16,10 +16,8 @@ from models.agent import ( ) from models.agent_config_entities import ( AgentCliToolConfig, - AgentFileRefConfig, AgentHumanContactConfig, AgentKnowledgeDatasetConfig, - AgentSkillRefConfig, AgentSoulConfig, DeclaredOutputConfig, DeclaredOutputType, @@ -396,20 +394,6 @@ class AgentComposerDifyToolCandidateResponse(ResponseModel): tools_count: int | None = None -class AgentComposerSkillCandidateResponse(AgentSkillRefConfig): - kind: Literal["skill"] = "skill" - - -class AgentComposerFileCandidateResponse(AgentFileRefConfig): - kind: Literal["file"] = "file" - - -AgentComposerSkillFileCandidateResponse = Annotated[ - AgentComposerSkillCandidateResponse | AgentComposerFileCandidateResponse, - Field(discriminator="kind"), -] - - class AgentComposerNodeJobCandidatesResponse(ResponseModel): previous_node_outputs: list[WorkflowPreviousNodeOutputRef] = Field(default_factory=list) declare_output_types: list[DeclaredOutputType] = Field(default_factory=list) @@ -417,7 +401,6 @@ class AgentComposerNodeJobCandidatesResponse(ResponseModel): class AgentComposerSoulCandidatesResponse(ResponseModel): - skills_files: list[AgentComposerSkillFileCandidateResponse] = Field(default_factory=list) dify_tools: list[AgentComposerDifyToolCandidateResponse] = Field(default_factory=list) cli_tools: list[AgentCliToolConfig] = Field(default_factory=list) knowledge_datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list) diff --git a/api/migrations/versions/2026_06_15_1100-b7c2d9e8a1f4_add_tenant_last_opened_at.py b/api/migrations/versions/2026_06_15_1100-b7c2d9e8a1f4_add_tenant_last_opened_at.py index ce2fd2b79ca..066a2ba8ca8 100644 --- a/api/migrations/versions/2026_06_15_1100-b7c2d9e8a1f4_add_tenant_last_opened_at.py +++ b/api/migrations/versions/2026_06_15_1100-b7c2d9e8a1f4_add_tenant_last_opened_at.py @@ -7,7 +7,7 @@ Create Date: 2026-06-05 11:00:00.000000 """ import sqlalchemy as sa -from alembic import op +from alembic import context, op # revision identifiers, used by Alembic. revision = "b7c2d9e8a1f4" @@ -17,10 +17,23 @@ depends_on = None def upgrade(): + if _has_last_opened_at_column(): + return with op.batch_alter_table("tenant_account_joins", schema=None) as batch_op: batch_op.add_column(sa.Column("last_opened_at", sa.DateTime(), nullable=True)) def downgrade(): + if not _has_last_opened_at_column(): + return with op.batch_alter_table("tenant_account_joins", schema=None) as batch_op: batch_op.drop_column("last_opened_at") + + +def _has_last_opened_at_column() -> bool: + if context.is_offline_mode(): + # Offline SQL generation cannot inspect the target schema. Assume the + # linear migration path so generated SQL stays explicit. + return False + inspector = sa.inspect(op.get_bind()) + return "last_opened_at" in {column["name"] for column in inspector.get_columns("tenant_account_joins")} diff --git a/api/migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py b/api/migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py index 9dc85d2a89b..3398c2eb018 100644 --- a/api/migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py +++ b/api/migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py @@ -6,9 +6,15 @@ Create Date: 2026-06-18 23:00:00.000000 """ -import sqlalchemy as sa +from __future__ import annotations + +import json +from typing import Any + from alembic import op +import sqlalchemy as sa from sqlalchemy.dialects import mysql +from sqlalchemy.engine.mock import MockConnection # revision identifiers, used by Alembic. revision = "b2515f9d4c2a" @@ -31,9 +37,46 @@ def upgrade() -> None: "agent_drive_files", ["tenant_id", "agent_id", "is_skill", "key"], ) + _remove_skills_files_from_snapshots() def downgrade() -> None: op.drop_index("agent_drive_files_tenant_agent_is_skill_key_idx", table_name="agent_drive_files") op.drop_column("agent_drive_files", "skill_metadata") op.drop_column("agent_drive_files", "is_skill") + + +def _remove_skills_files_from_snapshots() -> None: + connection = op.get_bind() + if connection is None or isinstance(connection, MockConnection): + return + snapshots = sa.table( + "agent_config_snapshots", + sa.column("id", sa.String()), + sa.column("config_snapshot", sa.Text()), + ) + rows = connection.execute(sa.select(snapshots.c.id, snapshots.c.config_snapshot)).fetchall() + for row in rows: + cleaned = _strip_skills_files(row.config_snapshot) + if cleaned is None: + continue + connection.execute( + snapshots.update() + .where(snapshots.c.id == row.id) + .values(config_snapshot=json.dumps(cleaned, separators=(",", ":"), sort_keys=True)) + ) + + +def _strip_skills_files(raw_snapshot: Any) -> dict[str, Any] | None: + if raw_snapshot is None: + return None + if isinstance(raw_snapshot, str): + snapshot = json.loads(raw_snapshot) + elif isinstance(raw_snapshot, dict): + snapshot = dict(raw_snapshot) + else: + snapshot = dict(raw_snapshot) + if not isinstance(snapshot, dict) or "skills_files" not in snapshot: + return None + snapshot.pop("skills_files", None) + return snapshot diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index 76108f271d4..2503ba66f06 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -361,11 +361,6 @@ class AgentSoulPromptConfig(BaseModel): system_prompt: str = "" -class AgentSoulSkillsFilesConfig(BaseModel): - files: list[AgentFileRefConfig] = Field(default_factory=list) - skills: list[AgentSkillRefConfig] = Field(default_factory=list) - - class AgentSoulDifyToolCredentialRef(BaseModel): """Reference to a stored Dify Plugin Tool credential. @@ -514,7 +509,6 @@ class AgentSoulConfig(BaseModel): schema_version: int = 1 prompt: AgentSoulPromptConfig = Field(default_factory=AgentSoulPromptConfig) - skills_files: AgentSoulSkillsFilesConfig = Field(default_factory=AgentSoulSkillsFilesConfig) tools: AgentSoulToolsConfig = Field(default_factory=AgentSoulToolsConfig) knowledge: AgentSoulKnowledgeConfig = Field(default_factory=AgentSoulKnowledgeConfig) human: AgentSoulHumanConfig = Field(default_factory=AgentSoulHumanConfig) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 881e83061cc..c600984c089 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -1622,7 +1622,7 @@ Inspect one drive-backed skill for slash-menu hover/detail UI | 200 | Drive skill inspect view | **application/json**: [AgentDriveSkillInspectResponse](#agentdriveskillinspectresponse)
| ### [DELETE] /apps/{app_id}/agent/files -Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5) +Delete one drive file by key via drive commit-null semantics #### Parameters @@ -1708,7 +1708,7 @@ Upload + standardize a Skill into the agent drive | 400 | Invalid skill package or no bound agent | | ### [DELETE] /apps/{app_id}/agent/skills/{slug} -Delete a standardized skill: soul ref first, then the / drive prefix (ENG-625 D5) +Delete a standardized skill by removing its known drive keys via commit-null #### Parameters @@ -12417,23 +12417,6 @@ Risk marker for CLI tool bootstrap commands. | provider_id | string | | No | | tools_count | integer | | No | -#### AgentComposerFileCandidateResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| drive_key | string | | No | -| file_id | string | | No | -| id | string | | No | -| kind | string,
**Default:** file | | No | -| name | string | | No | -| reference | string | | No | -| remote_url | string | | No | -| tenant_id | string | | No | -| transfer_method | string | | No | -| type | string | | No | -| upload_file_id | string | | No | -| url | string | | No | - #### AgentComposerImpactBindingResponse | Name | Type | Description | Required | @@ -12458,22 +12441,6 @@ Risk marker for CLI tool bootstrap commands. | human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No | | previous_node_outputs | [ [WorkflowPreviousNodeOutputRef](#workflowpreviousnodeoutputref) ] | | No | -#### AgentComposerSkillCandidateResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| description | string | | No | -| file_id | string | | No | -| full_archive_file_id | string | | No | -| full_archive_key | string | | No | -| id | string | | No | -| kind | string,
**Default:** skill | | No | -| manifest_files | [ string ] | | No | -| name | string | | No | -| path | string | | No | -| skill_md_file_id | string | | No | -| skill_md_key | string | | No | - #### AgentComposerSoulCandidatesResponse | Name | Type | Description | Required | @@ -12482,7 +12449,6 @@ Risk marker for CLI tool bootstrap commands. | dify_tools | [ [AgentComposerDifyToolCandidateResponse](#agentcomposerdifytoolcandidateresponse) ] | | No | | human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No | | knowledge_datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No | -| skills_files | [ ] | | No | #### AgentComposerSoulLockResponse @@ -12603,7 +12569,6 @@ Audit operation recorded for Agent Soul version/revision changes. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| config_version_id | string | | No | | removed_keys | [ string ] | | No | | result | string | | Yes | @@ -12617,7 +12582,6 @@ Audit operation recorded for Agent Soul version/revision changes. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| config_version_id | string | | No | | file | [AgentDriveFileResponse](#agentdrivefileresponse) | | Yes | #### AgentDriveFilePayload @@ -13203,27 +13167,12 @@ Visibility and lifecycle scope of an Agent record. | enabled | boolean | | No | | type | string | | No | -#### AgentSkillRefConfig - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| description | string | | No | -| file_id | string | | No | -| full_archive_file_id | string | | No | -| full_archive_key | string | | No | -| id | string | | No | -| manifest_files | [ string ] | | No | -| name | string | | No | -| path | string | | No | -| skill_md_file_id | string | | No | -| skill_md_key | string | | No | - #### AgentSkillUploadResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | manifest | [SkillManifest](#skillmanifest) | | Yes | -| skill | [AgentSkillRefConfig](#agentskillrefconfig) | | Yes | +| skill | [AgentUploadedSkillResponse](#agentuploadedskillresponse) | | Yes | #### AgentSoulAppFeaturesConfig @@ -13252,7 +13201,6 @@ Visibility and lifecycle scope of an Agent record. | prompt | [AgentSoulPromptConfig](#agentsoulpromptconfig) | | No | | sandbox | [AgentSoulSandboxConfig](#agentsoulsandboxconfig) | | No | | schema_version | integer,
**Default:** 1 | | No | -| skills_files | [AgentSoulSkillsFilesConfig](#agentsoulskillsfilesconfig) | | No | | tools | [AgentSoulToolsConfig](#agentsoultoolsconfig) | | No | #### AgentSoulDifyToolConfig @@ -13369,13 +13317,6 @@ Reference to model credentials resolved only at runtime. | config | [AgentSandboxProviderConfig](#agentsandboxproviderconfig) | | No | | provider | string | | No | -#### AgentSoulSkillsFilesConfig - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| files | [ [AgentFileRefConfig](#agentfilerefconfig) ] | | No | -| skills | [ [AgentSkillRefConfig](#agentskillrefconfig) ] | | No | - #### AgentSoulToolsConfig | Name | Type | Description | Required | @@ -13507,6 +13448,16 @@ Soft lifecycle state for Agent records. | tool_output | object | | Yes | | tool_parameters | object | | Yes | +#### AgentUploadedSkillResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| archive_key | string | | No | +| description | string | | Yes | +| name | string | | Yes | +| path | string | | Yes | +| skill_md_key | string | | Yes | + #### AgentUserSatisfactionRateStatisticResponse | Name | Type | Description | Required | diff --git a/api/services/agent/composer_candidates.py b/api/services/agent/composer_candidates.py index 0a1419be399..7868f2a2f63 100644 --- a/api/services/agent/composer_candidates.py +++ b/api/services/agent/composer_candidates.py @@ -137,9 +137,6 @@ def soul_candidates( soul = agent_soul or AgentSoulConfig() truncated = False - skills_files = [{"kind": "skill", **skill.model_dump(exclude_none=True)} for skill in soul.skills_files.skills] - skills_files += [{"kind": "file", **file.model_dump(exclude_none=True)} for file in soul.skills_files.files] - cli_tools = [tool.model_dump(exclude_none=True) for tool in soul.tools.cli_tools if tool.enabled] dataset_ids = [dataset.id for dataset in soul.knowledge.datasets if dataset.id] @@ -162,7 +159,6 @@ def soul_candidates( dify_tools = workspace_tools_loader() lists = { - "skills_files": skills_files, "dify_tools": dify_tools, "cli_tools": cli_tools, "knowledge_datasets": knowledge_datasets, diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index af6be0abfa6..815fdcc4420 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -21,7 +21,6 @@ from models.agent import ( WorkflowAgentNodeBinding, ) from models.agent_config_entities import ( - AgentFileRefConfig, DeclaredOutputConfig, ) from models.agent_config_entities import ( @@ -34,7 +33,6 @@ from services.agent.errors import ( AgentNameConflictError, AgentNotFoundError, AgentVersionNotFoundError, - InvalidComposerConfigError, ) from services.entities.agent_entities import ( AgentSoulConfig, @@ -106,29 +104,6 @@ class AgentComposerService: workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id) binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id) - # ENG-623 §4.4: drive-backed refs must point at real drive rows before the - # soul is persisted. Only strategies that write the soul onto an *existing* - # agent are checked — new-agent strategies create a fresh (empty) drive, so - # any carried drive key would be flagged on the next save instead. - if ( - payload.agent_soul is not None - and binding is not None - and binding.agent_id - and payload.save_strategy - in ( - ComposerSaveStrategy.NODE_JOB_ONLY, - ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, - ComposerSaveStrategy.SAVE_AS_NEW_VERSION, - ) - and ( - payload.save_strategy != ComposerSaveStrategy.NODE_JOB_ONLY - or binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT - ) - ): - cls._require_drive_refs_resolved( - tenant_id=tenant_id, agent_id=binding.agent_id, agent_soul=payload.agent_soul - ) - match payload.save_strategy: case ComposerSaveStrategy.NODE_JOB_ONLY: binding = cls._save_node_job_only( @@ -176,7 +151,11 @@ class AgentComposerService: version_id=version_id, ) state = cls._serialize_workflow_state(binding=binding, agent=agent, version=version) - state["validation"] = cls.collect_validation_findings(tenant_id=tenant_id, payload=payload) + state["validation"] = cls.collect_validation_findings( + tenant_id=tenant_id, + payload=payload, + agent_id=binding.agent_id, + ) return state @classmethod @@ -250,9 +229,6 @@ class AgentComposerService: db.session.rollback() raise AgentNameConflictError() from exc - # ENG-623 §4.4: dangling drive-backed refs are rejected before persisting. - cls._require_drive_refs_resolved(tenant_id=tenant_id, agent_id=agent.id, agent_soul=payload.agent_soul) - if payload.save_strategy == ComposerSaveStrategy.SAVE_AS_NEW_VERSION or not agent.active_config_snapshot_id: version = cls._create_config_version( tenant_id=tenant_id, @@ -281,7 +257,11 @@ class AgentComposerService: db.session.commit() state = cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id) - state["validation"] = cls.collect_validation_findings(tenant_id=tenant_id, payload=payload) + state["validation"] = cls.collect_validation_findings( + tenant_id=tenant_id, + payload=payload, + agent_id=agent.id, + ) return state @classmethod @@ -292,11 +272,7 @@ class AgentComposerService: payload: ComposerSavePayload, agent_id: str | None = None, ) -> dict[str, Any]: - """ENG-617 soft findings, with DB-backed dataset existence for placeholders. - - With ``agent_id`` the drive-backed skill/file refs are also checked against - the agent drive (ENG-623 §4.4) and dangling ones surface as warnings. - """ + """ENG-617 soft findings, with DB-backed dataset and drive mention checks.""" from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions mentioned_ids: set[str] = set() @@ -312,136 +288,14 @@ class AgentComposerService: findings = ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids) if agent_id and payload.agent_soul is not None: findings["warnings"].extend( - cls._drive_ref_findings(tenant_id=tenant_id, agent_id=agent_id, agent_soul=payload.agent_soul) + cls._drive_mention_findings( + tenant_id=tenant_id, + agent_id=agent_id, + prompt=payload.agent_soul.prompt.system_prompt, + ) ) return findings - @classmethod - def remove_drive_refs( - cls, - *, - tenant_id: str, - agent_id: str, - account_id: str, - skill_slug: str | None = None, - file_key: str | None = None, - app_id: str | None = None, - node_id: str | None = None, - ) -> str | None: - """Drop the soul refs backed by a drive skill/file before the drive rows go. - - Soul-first ordering (ENG-625 D5): a mid-failure leaves harmless orphan KV - rows that an idempotent DELETE retry cleans, instead of a soul ref that - keeps failing dangling-ref validation. Returns the new config version id, - or ``None`` when the soul held no matching ref (idempotent re-delete). - """ - if (skill_slug is None) == (file_key is None): - raise ValueError("remove_drive_refs requires exactly one of skill_slug or file_key") - agent = db.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id).limit(1)) - if agent is None or not agent.active_config_snapshot_id: - return None - current_snapshot = cls._require_version( - tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id - ) - agent_soul = AgentSoulConfig.model_validate(current_snapshot.config_snapshot_dict) - - removed_display: str | None = None - if skill_slug is not None: - kept_skills = [] - for skill in agent_soul.skills_files.skills: - slug = (skill.skill_md_key or "").split("/", 1)[0] or (skill.path or "").strip("/") - if slug == skill_slug: - removed_display = skill.name or skill.id or skill_slug - continue - kept_skills.append(skill) - if removed_display is None: - return None - agent_soul.skills_files.skills = kept_skills - note = f"Removed skill '{removed_display}' from the drive." - else: - kept_files = [] - for file in agent_soul.skills_files.files: - if file.drive_key == file_key: - removed_display = file.name or file.drive_key - continue - kept_files.append(file) - if removed_display is None: - return None - agent_soul.skills_files.files = kept_files - note = f"Removed file '{removed_display}' from the drive." - - version = cls._update_current_version( - current_snapshot=current_snapshot, - account_id=account_id, - agent_soul=agent_soul, - operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION, - version_note=note, - ) - agent.active_config_snapshot_id = version.id - agent.updated_by = account_id - cls._sync_draft_binding_snapshot( - tenant_id=tenant_id, - app_id=app_id, - node_id=node_id, - agent_id=agent_id, - snapshot_id=version.id, - account_id=account_id, - ) - db.session.commit() - return version.id - - @classmethod - def add_drive_file_ref( - cls, - *, - tenant_id: str, - agent_id: str, - account_id: str, - file_ref: AgentFileRefConfig, - app_id: str | None = None, - node_id: str | None = None, - ) -> str | None: - """Add or replace one drive-backed file ref in the active Agent Soul. - - ``POST /agent/files`` is an ADD FILE user action, not just a low-level - drive commit. The committed file must be present in ``skills_files.files`` - because runtime ``dify.drive`` is built from the active Agent Soul. - """ - if not file_ref.drive_key: - raise ValueError("file_ref.drive_key is required") - agent = db.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id).limit(1)) - if agent is None or not agent.active_config_snapshot_id: - return None - current_snapshot = cls._require_version( - tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id - ) - agent_soul = AgentSoulConfig.model_validate(current_snapshot.config_snapshot_dict) - kept_files = [item for item in agent_soul.skills_files.files if item.drive_key != file_ref.drive_key] - kept_files.append(file_ref) - agent_soul.skills_files.files = kept_files - - display = file_ref.name or file_ref.drive_key - version = cls._update_current_version( - current_snapshot=current_snapshot, - account_id=account_id, - agent_soul=agent_soul, - operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION, - version_note=f"Added file '{display}' to the drive.", - ) - agent.active_config_snapshot_id = version.id - agent.active_config_has_model = agent_soul_has_model(agent_soul) - agent.updated_by = account_id - cls._sync_draft_binding_snapshot( - tenant_id=tenant_id, - app_id=app_id, - node_id=node_id, - agent_id=agent_id, - snapshot_id=version.id, - account_id=account_id, - ) - db.session.commit() - return version.id - @classmethod def resolve_bound_agent_id(cls, *, tenant_id: str, app_id: str) -> str | None: """The Agent App's bound roster agent id, if any (validate-endpoint context).""" @@ -468,49 +322,25 @@ class AgentComposerService: return binding.agent_id if binding else None @classmethod - def _sync_draft_binding_snapshot( - cls, - *, - tenant_id: str, - app_id: str | None, - node_id: str | None, - agent_id: str, - snapshot_id: str, - account_id: str, - ) -> None: - """Keep workflow node bindings on the new active snapshot after direct drive edits.""" - if not app_id or not node_id: - return - try: - workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id) - except ValueError: - return - binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id) - if binding is None or binding.agent_id != agent_id: - return - binding.current_snapshot_id = snapshot_id - binding.updated_by = account_id - - @classmethod - def _drive_ref_findings( + def _drive_mention_findings( cls, *, tenant_id: str, agent_id: str, - agent_soul: AgentSoulConfig, + prompt: str, ) -> list[dict[str, str | None]]: - """Drive-backed refs whose keys have no row in the agent drive (ENG-623 §4.4). + """Soft warnings for missing drive-backed prompt mentions.""" + from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions + from services.agent_drive_service import decode_drive_mention_ref - Each finding message starts with its stable code token - (``skill_ref_dangling`` / ``file_ref_dangling``) in the ENG-616/617 style. - """ wanted_keys: dict[str, tuple[str, str]] = {} - for skill in agent_soul.skills_files.skills: - if skill.skill_md_key: - wanted_keys[skill.skill_md_key] = ("skill_ref_dangling", skill.name or skill.id or "unknown") - for file in agent_soul.skills_files.files: - if file.drive_key: - wanted_keys[file.drive_key] = ("file_ref_dangling", file.name or file.id or "unknown") + for mention in parse_prompt_mentions(prompt): + if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}: + continue + decoded_key = decode_drive_mention_ref(mention.ref_id) + if not decoded_key: + continue + wanted_keys[decoded_key] = (mention.kind.value, mention.label or decoded_key) if not wanted_keys: return [] @@ -524,28 +354,20 @@ class AgentComposerService: ) ) findings: list[dict[str, str | None]] = [] - for key, (code, display) in wanted_keys.items(): + for key, (kind, display) in wanted_keys.items(): if key in existing_keys: continue - kind = "skill" if code == "skill_ref_dangling" else "file" findings.append( { - "code": code, + "code": "mention_target_missing", "surface": "agent_soul", "kind": kind, "id": key, - "message": f"{code}: {kind} '{display}' has no drive entry for key '{key}'.", + "message": f"{kind} '{display}' has no drive entry for key '{key}'.", } ) return findings - @classmethod - def _require_drive_refs_resolved(cls, *, tenant_id: str, agent_id: str, agent_soul: AgentSoulConfig) -> None: - """Hard save-time guard: dangling drive-backed refs are rejected (400).""" - findings = cls._drive_ref_findings(tenant_id=tenant_id, agent_id=agent_id, agent_soul=agent_soul) - if findings: - raise InvalidComposerConfigError("; ".join(str(finding["message"]) for finding in findings)) - @classmethod def get_workflow_candidates(cls, *, tenant_id: str, app_id: str, node_id: str, user_id: str) -> dict[str, Any]: """Slash-menu data source for the workflow Agent node composer (ENG-615).""" diff --git a/api/services/agent/composer_validator.py b/api/services/agent/composer_validator.py index b9519272c4a..a1d5ce07655 100644 --- a/api/services/agent/composer_validator.py +++ b/api/services/agent/composer_validator.py @@ -191,6 +191,8 @@ class ComposerConfigValidator: } ) continue + if mention.kind in {MentionKind.SKILL, MentionKind.FILE}: + continue if resolved is None: warnings.append( { diff --git a/api/services/agent/prompt_mentions.py b/api/services/agent/prompt_mentions.py index 921d6838b26..27bed49c53b 100644 --- a/api/services/agent/prompt_mentions.py +++ b/api/services/agent/prompt_mentions.py @@ -4,13 +4,14 @@ Slash-menu insertions are stored inline in the plain-string prompt as tokens: [§:[: