From 8cac86d5c55e7e18f8f2fac6ed31cb50c2d0092f Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Sat, 13 Jun 2026 10:30:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20Skills=20&=20Files=20effective?= =?UTF-8?q?=20chain=20=E2=80=94=20drive=20runtime=20exposure,=20inspector,?= =?UTF-8?q?=20lifecycle,=20infer-tools=20(#37370)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Fable 5 Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/clients/agent_backend/request_builder.py | 32 + api/configs/extra/agent_backend_config.py | 10 + api/controllers/console/__init__.py | 2 + api/controllers/console/agent/composer.py | 14 +- api/controllers/console/app/agent.py | 318 ++++++- .../console/app/agent_drive_inspector.py | 162 ++++ .../apps/agent_app/runtime_request_builder.py | 12 +- .../agent_v2/runtime_feature_manifest.py | 38 +- .../nodes/agent_v2/runtime_request_builder.py | 99 ++- api/models/agent_config_entities.py | 18 + api/openapi/markdown/console-openapi.md | 253 +++++- api/services/agent/composer_service.py | 278 +++++- api/services/agent/skill_package_service.py | 1 + .../agent/skill_standardize_service.py | 2 + .../agent/skill_tool_inference_service.py | 215 +++++ api/services/agent_drive_service.py | 132 ++- .../console/agent/test_agent_controllers.py | 8 + .../console/app/test_agent_drive_inspector.py | 110 +++ .../console/app/test_agent_skills.py | 261 +++++- .../agent_app/test_runtime_request_builder.py | 49 ++ .../agent_v2/test_runtime_request_builder.py | 121 +++ .../services/agent/test_agent_services.py | 333 +++++++- .../agent/test_skill_standardize_service.py | 3 + .../test_skill_tool_inference_service.py | 225 +++++ .../services/test_agent_drive_service.py | 163 ++++ .../src/dify_agent/layers/drive/__init__.py | 19 + .../src/dify_agent/layers/drive/configs.py | 67 ++ .../src/dify_agent/layers/drive/layer.py | 34 + .../dify_agent/runtime/compositor_factory.py | 6 + .../local/dify_agent/layers/drive/__init__.py | 0 .../dify_agent/layers/drive/test_configs.py | 58 ++ .../dify_agent/test_client_safe_exports.py | 2 + .../dify_agent/test_import_boundaries.py | 3 + .../generated/api/console/agents/types.gen.ts | 7 + .../generated/api/console/agents/zod.gen.ts | 7 + .../generated/api/console/apps/orpc.gen.ts | 793 +++++++++++------- .../generated/api/console/apps/types.gen.ts | 251 +++++- .../generated/api/console/apps/zod.gen.ts | 284 ++++++- 38 files changed, 4032 insertions(+), 358 deletions(-) create mode 100644 api/controllers/console/app/agent_drive_inspector.py create mode 100644 api/services/agent/skill_tool_inference_service.py create mode 100644 api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py create mode 100644 api/tests/unit_tests/services/agent/test_skill_tool_inference_service.py create mode 100644 dify-agent/src/dify_agent/layers/drive/__init__.py create mode 100644 dify-agent/src/dify_agent/layers/drive/configs.py create mode 100644 dify-agent/src/dify_agent/layers/drive/layer.py create mode 100644 dify-agent/tests/local/dify_agent/layers/drive/__init__.py create mode 100644 dify-agent/tests/local/dify_agent/layers/drive/test_configs.py diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py index 34b2db12e7..0915c127eb 100644 --- a/api/clients/agent_backend/request_builder.py +++ b/api/clients/agent_backend/request_builder.py @@ -26,6 +26,7 @@ from dify_agent.layers.dify_plugin import ( DifyPluginLLMLayerConfig, DifyPluginToolsLayerConfig, ) +from dify_agent.layers.drive import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig from dify_agent.layers.execution_context import ( DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig, @@ -50,6 +51,7 @@ WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt" WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt" AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt" DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context" +DIFY_DRIVE_LAYER_ID = "drive" DIFY_PLUGIN_TOOLS_LAYER_ID = "tools" DIFY_SHELL_LAYER_ID = "shell" @@ -134,6 +136,9 @@ class AgentBackendWorkflowNodeRunInput(BaseModel): idempotency_key: str | None = None output: AgentBackendOutputConfig | None = None tools: DifyPluginToolsLayerConfig | None = None + # Drive Skills & Files declaration (dify.drive) — an index the agent pulls + # through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED. + drive_config: DifyDriveLayerConfig | None = None # Inject the sandboxed shell layer (dify.shell). Requires the agent backend # to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED. include_shell: bool = False @@ -170,6 +175,9 @@ class AgentBackendAgentAppRunInput(BaseModel): idempotency_key: str | None = None output: AgentBackendOutputConfig | None = None tools: DifyPluginToolsLayerConfig | None = None + # Drive Skills & Files declaration (dify.drive) — an index the agent pulls + # through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED. + drive_config: DifyDriveLayerConfig | None = None # Inject the sandboxed shell layer (dify.shell). Requires the agent backend # to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED. include_shell: bool = False @@ -228,6 +236,18 @@ class AgentBackendRunRequestBuilder: ] ) + if run_input.drive_config is not None: + # Drive Skills & Files declaration (dify.drive): a config-only index; + # the agent pulls listed entries through the back proxy by drive_ref. + layers.append( + RunLayerSpec( + name=DIFY_DRIVE_LAYER_ID, + type=DIFY_DRIVE_LAYER_TYPE_ID, + metadata=run_input.metadata, + config=run_input.drive_config, + ) + ) + if run_input.include_history: layers.append( RunLayerSpec( @@ -383,6 +403,18 @@ class AgentBackendRunRequestBuilder: ] ) + if run_input.drive_config is not None: + # Drive Skills & Files declaration (dify.drive): a config-only index; + # the agent pulls listed entries through the back proxy by drive_ref. + layers.append( + RunLayerSpec( + name=DIFY_DRIVE_LAYER_ID, + type=DIFY_DRIVE_LAYER_TYPE_ID, + metadata=run_input.metadata, + config=run_input.drive_config, + ) + ) + if run_input.include_history: layers.append( RunLayerSpec( diff --git a/api/configs/extra/agent_backend_config.py b/api/configs/extra/agent_backend_config.py index 347302ceb3..0d65d3de97 100644 --- a/api/configs/extra/agent_backend_config.py +++ b/api/configs/extra/agent_backend_config.py @@ -31,3 +31,13 @@ class AgentBackendConfig(BaseSettings): ), default=False, ) + + AGENT_DRIVE_MANIFEST_ENABLED: bool = Field( + description=( + "Inject the dify.drive layer (Skills & Files drive manifest declaration) " + "into Agent runs. The declaration is an index only — the agent backend " + "pulls the actual SKILL.md / files through the back proxy. Keep it off " + "until the agent backend registers the dify.drive layer type." + ), + default=False, + ) diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index eb6bc7e3e4..e2bf0bd22c 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -54,6 +54,7 @@ from .app import ( agent_app_access, agent_app_feature, agent_app_sandbox, + agent_drive_inspector, annotation, app, audio, @@ -155,6 +156,7 @@ __all__ = [ "agent_app_feature", "agent_app_sandbox", "agent_composer", + "agent_drive_inspector", "agent_providers", "agent_roster", "annotation", diff --git a/api/controllers/console/agent/composer.py b/api/controllers/console/agent/composer.py index 8d2297a1b8..5e778f67f9 100644 --- a/api/controllers/console/agent/composer.py +++ b/api/controllers/console/agent/composer.py @@ -94,7 +94,13 @@ class WorkflowAgentComposerValidateApi(Resource): def post(self, tenant_id: str, app_model: App, node_id: str): payload = ComposerSavePayload.model_validate(console_ns.payload or {}) ComposerConfigValidator.validate_save_payload(payload) - findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload) + findings = AgentComposerService.collect_validation_findings( + tenant_id=tenant_id, + payload=payload, + agent_id=AgentComposerService.resolve_workflow_node_agent_id( + tenant_id=tenant_id, app_id=app_model.id, node_id=node_id + ), + ) return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings}) @@ -220,7 +226,11 @@ class AgentAppComposerValidateApi(Resource): def post(self, tenant_id: str, app_model: App): payload = ComposerSavePayload.model_validate(console_ns.payload or {}) ComposerConfigValidator.validate_save_payload(payload) - findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload) + findings = AgentComposerService.collect_validation_findings( + tenant_id=tenant_id, + payload=payload, + agent_id=AgentComposerService.resolve_bound_agent_id(tenant_id=tenant_id, app_id=app_model.id), + ) return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings}) diff --git a/api/controllers/console/app/agent.py b/api/controllers/console/app/agent.py index b2b3ac942c..929aa1d1ff 100644 --- a/api/controllers/console/app/agent.py +++ b/api/controllers/console/app/agent.py @@ -1,10 +1,17 @@ +import logging from typing import Any from flask import request from flask_restx import Resource -from pydantic import BaseModel, Field, RootModel, field_validator +from pydantic import BaseModel, Field, field_validator +from sqlalchemy import select -from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models +from controllers.common.schema import ( + query_params_from_model, + query_params_from_request, + register_response_schema_models, + register_schema_models, +) from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, setup_required, with_current_user @@ -13,13 +20,30 @@ from fields.base import ResponseModel from libs.helper import uuid_value from libs.login import login_required from models import Account -from models.model import App, AppMode -from services.agent.skill_package_service import SkillPackageError, SkillPackageService +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, SkillPackageService from services.agent.skill_standardize_service import SkillStandardizeService -from services.agent_drive_service import AgentDriveError +from services.agent.skill_tool_inference_service import ( + SkillToolInferenceError, + SkillToolInferenceResult, + SkillToolInferenceService, +) +from services.agent_drive_service import ( + AgentDriveError, + AgentDriveService, + DriveCommitItem, + DriveFileRef, + normalize_drive_key, +) from services.agent_service import AgentService from services.file_service import FileService +logger = logging.getLogger(__name__) + +_AGENT_DRIVE_APP_MODES = [AppMode.AGENT, AppMode.WORKFLOW, AppMode.ADVANCED_CHAT] + class AgentLogQuery(BaseModel): message_id: str = Field(..., description="Message UUID") @@ -31,6 +55,23 @@ class AgentLogQuery(BaseModel): return uuid_value(value) +class AgentDriveFilePayload(BaseModel): + upload_file_id: str = Field(..., description="UploadFile UUID from POST /console/api/files/upload") + + @field_validator("upload_file_id") + @classmethod + def validate_upload_file_id(cls, value: str) -> str: + return uuid_value(value) + + +class AgentDriveMutationQuery(BaseModel): + node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)") + + +class AgentDriveDeleteFileQuery(AgentDriveMutationQuery): + key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf") + + class AgentLogMetaResponse(ResponseModel): status: str executor: str @@ -68,16 +109,58 @@ class AgentLogResponse(ResponseModel): files: list[Any] = Field(default_factory=list) -class AgentSkillUploadResponse(RootModel[dict[str, Any]]): - root: dict[str, Any] +class AgentSkillUploadResponse(ResponseModel): + skill: AgentSkillRefConfig + manifest: SkillManifest -class AgentSkillStandardizeResponse(RootModel[dict[str, Any]]): - root: dict[str, Any] +class AgentSkillStandardizeResponse(ResponseModel): + skill: AgentSkillRefConfig + manifest: SkillManifest -register_schema_models(console_ns, AgentLogQuery) -register_response_schema_models(console_ns, AgentLogResponse, AgentSkillUploadResponse, AgentSkillStandardizeResponse) +class AgentDriveFileResponse(ResponseModel): + name: str + drive_key: str + file_id: str + size: int | None = None + mime_type: str | None = None + + +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) +register_response_schema_models( + console_ns, + AgentDriveDeleteResponse, + AgentDriveFileCommitResponse, + AgentDriveFileResponse, + AgentLogResponse, + AgentSkillStandardizeResponse, + AgentSkillUploadResponse, + SkillToolInferenceResult, +) + + +def _resolve_agent_id(app_model: App, node_id: str | None) -> str | None: + if node_id: + return AgentComposerService.resolve_workflow_node_agent_id( + tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id + ) + return app_model.bound_agent_id + + +def _agent_not_bound() -> tuple[dict[str, str], int]: + return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400 @console_ns.route("/apps//agent/logs") @@ -109,7 +192,7 @@ class AgentSkillUploadApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) + @get_app_model(mode=_AGENT_DRIVE_APP_MODES) @with_current_user def post(self, current_user: Account, app_model: App): """Validate an uploaded Skill package and persist the archive. @@ -143,7 +226,7 @@ class AgentSkillUploadApi(Resource): class AgentSkillStandardizeApi(Resource): @console_ns.doc("standardize_agent_skill") @console_ns.doc(description="Validate + standardize a Skill into the agent drive (ENG-594)") - @console_ns.doc(params={"app_id": "Application ID"}) + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveMutationQuery)}) @console_ns.response( 201, "Skill standardized into drive", @@ -153,13 +236,14 @@ class AgentSkillStandardizeApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.AGENT]) + @get_app_model(mode=_AGENT_DRIVE_APP_MODES) @with_current_user def post(self, current_user: Account, app_model: App): """Upload a Skill, validate it, and standardize it into the app agent's drive.""" - agent_id = app_model.bound_agent_id + query = query_params_from_request(AgentDriveMutationQuery) + agent_id = _resolve_agent_id(app_model, query.node_id) if not agent_id: - return {"code": "no_bound_agent", "message": "app has no bound agent"}, 400 + return _agent_not_bound() if "file" not in request.files: return {"code": "no_file", "message": "no skill file uploaded"}, 400 if len(request.files) > 1: @@ -178,3 +262,205 @@ class AgentSkillStandardizeApi(Resource): except (SkillPackageError, AgentDriveError) as exc: return {"code": exc.code, "message": exc.message}, exc.status_code return result, 201 + + +@console_ns.route("/apps//agent/files") +class AgentDriveFilesApi(Resource): + @console_ns.doc("commit_agent_drive_file") + @console_ns.doc(description="Commit an uploaded file into the agent drive under files/ (ENG-625 D3)") + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveMutationQuery)}) + @console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__]) + @console_ns.response( + 201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__] + ) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_AGENT_DRIVE_APP_MODES) + @with_current_user + def post(self, current_user: Account, app_model: App): + """ADD FILE: commit one uploaded file into the bound agent's drive.""" + query = query_params_from_request(AgentDriveMutationQuery) + agent_id = _resolve_agent_id(app_model, query.node_id) + if not agent_id: + return _agent_not_bound() + payload = AgentDriveFilePayload.model_validate(console_ns.payload or {}) + + upload_file = db.session.scalar( + select(UploadFile).where( + UploadFile.id == payload.upload_file_id, + UploadFile.tenant_id == app_model.tenant_id, + ) + ) + if upload_file is None: + return {"code": "upload_file_not_found", "message": "upload file not found in this workspace"}, 404 + + try: + key = normalize_drive_key(f"files/{upload_file.name}") + committed = AgentDriveService().commit( + tenant_id=app_model.tenant_id, + user_id=current_user.id, + agent_id=agent_id, + items=[ + DriveCommitItem( + key=key, + file_ref=DriveFileRef(kind="upload_file", id=upload_file.id), + # ADD FILE uploads exist solely to live in the drive, so the + # drive owns (and physically cleans) the value on delete. + value_owned_by_drive=True, + ) + ], + ) + except AgentDriveError as exc: + 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=query.node_id, + ) + return { + "file": { + "name": upload_file.name, + "drive_key": row["key"], + "file_id": upload_file.id, + "size": row.get("size"), + "mime_type": row.get("mime_type"), + }, + "config_version_id": config_version_id, + }, 201 + + @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(params={"app_id": "Application ID", **query_params_from_model(AgentDriveDeleteFileQuery)}) + @console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_AGENT_DRIVE_APP_MODES) + @with_current_user + def delete(self, current_user: Account, app_model: App): + query = query_params_from_request(AgentDriveDeleteFileQuery) + agent_id = _resolve_agent_id(app_model, query.node_id) + if not agent_id: + return _agent_not_bound() + try: + key = normalize_drive_key(query.key) + 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=query.node_id, + ) + removed_keys: list[str] = [] + try: + removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, key=key) + 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} + + +@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( + params={ + "app_id": "Application ID", + "slug": "Skill slug (single path segment)", + **query_params_from_model(AgentDriveMutationQuery), + } + ) + @console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_AGENT_DRIVE_APP_MODES) + @with_current_user + def delete(self, current_user: Account, app_model: App, slug: str): + query = query_params_from_request(AgentDriveMutationQuery) + agent_id = _resolve_agent_id(app_model, query.node_id) + if not agent_id: + return _agent_not_bound() + 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=query.node_id, + ) + removed_keys: list[str] = [] + try: + removed_keys = AgentDriveService().delete( + tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=f"{slug}/" + ) + 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} + + +@console_ns.route("/apps//agent/skills//infer-tools") +class AgentSkillInferToolsApi(Resource): + @console_ns.doc("infer_agent_skill_tools") + @console_ns.doc( + description="Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)" + ) + @console_ns.doc( + params={ + "app_id": "Application ID", + "slug": "Skill slug (single path segment)", + **query_params_from_model(AgentDriveMutationQuery), + } + ) + @console_ns.response( + 200, + "Inference result (draft suggestions, nothing persisted)", + console_ns.models[SkillToolInferenceResult.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_AGENT_DRIVE_APP_MODES) + def post(self, app_model: App, slug: str): + """Suggest CLI tools/env for a skill. Saving still goes through composer validation.""" + query = query_params_from_request(AgentDriveMutationQuery) + agent_id = _resolve_agent_id(app_model, query.node_id) + if not agent_id: + return _agent_not_bound() + if "/" in slug or not slug.strip(): + return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400 + try: + return SkillToolInferenceService().infer(tenant_id=app_model.tenant_id, agent_id=agent_id, slug=slug) + except SkillToolInferenceError as exc: + return {"code": exc.code, "message": exc.message}, exc.status_code diff --git a/api/controllers/console/app/agent_drive_inspector.py b/api/controllers/console/app/agent_drive_inspector.py new file mode 100644 index 0000000000..ff5b87fc28 --- /dev/null +++ b/api/controllers/console/app/agent_drive_inspector.py @@ -0,0 +1,162 @@ +"""Console read-only inspector for the agent drive (ENG-624). + +``agent-drive`` looks at the *static* drive assets (standardized skills and +committed files); the sibling ``agent-sandbox`` routes look at a *runtime* +sandbox workspace. Unlike the sandbox routes this never proxies to the agent +backend — drive data lives in the API's own DB/storage, served straight from +``AgentDriveService``. Download hands the browser an **external** signed URL +(the inner manifest hands agents internal ones — the two must never mix). +""" + +from __future__ import annotations + +from flask_restx import Resource +from pydantic import BaseModel, Field + +from controllers.common.schema import ( + query_params_from_model, + query_params_from_request, + register_response_schema_models, +) +from controllers.console import console_ns +from controllers.console.app.wraps import get_app_model +from controllers.console.wraps import account_initialization_required, setup_required +from fields.base import ResponseModel +from libs.login import login_required +from models.model import App, AppMode +from services.agent.composer_service import AgentComposerService +from services.agent_drive_service import AgentDriveError, AgentDriveService + + +class AgentDriveListQuery(BaseModel): + prefix: str = Field(default="", description="Key prefix filter: '/' for one skill, 'files/' for files") + node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)") + + +class AgentDriveFileQuery(BaseModel): + key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md") + node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)") + + +class AgentDriveItemResponse(ResponseModel): + key: str + size: int | None = None + mime_type: str | None = None + hash: str | None = None + file_kind: str + created_at: int | None = None + + +class AgentDriveListResponse(ResponseModel): + items: list[AgentDriveItemResponse] = Field(default_factory=list) + + +class AgentDrivePreviewResponse(ResponseModel): + key: str + size: int | None = None + truncated: bool + binary: bool + text: str | None = None + + +class AgentDriveDownloadResponse(ResponseModel): + url: str + + +register_response_schema_models( + console_ns, AgentDriveListResponse, AgentDrivePreviewResponse, AgentDriveDownloadResponse +) + + +def _resolve_agent_id(app_model: App, node_id: str | None) -> str | None: + """Agent identity for the drive: app-bound agent, or the workflow node binding.""" + if node_id: + return AgentComposerService.resolve_workflow_node_agent_id( + tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id + ) + return app_model.bound_agent_id + + +def _agent_not_bound() -> tuple[dict[str, object], int]: + return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400 + + +def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]: + return {"code": exc.code, "message": exc.message}, exc.status_code + + +_APP_MODES = [AppMode.AGENT, AppMode.WORKFLOW, AppMode.ADVANCED_CHAT] + + +@console_ns.route("/apps//agent/drive/files") +class AgentDriveListApi(Resource): + @console_ns.doc("list_agent_drive_files") + @console_ns.doc(description="List agent drive entries (read-only inspector; one endpoint for both tabs)") + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)}) + @console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_APP_MODES) + def get(self, app_model: App): + query = query_params_from_request(AgentDriveListQuery) + agent_id = _resolve_agent_id(app_model, query.node_id) + if not agent_id: + return _agent_not_bound() + try: + items = AgentDriveService().manifest(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix) + except AgentDriveError as exc: + return _handle(exc) + # the inner manifest exposes file_id for agent-side pulls; the console + # inspector is a pure read surface and does not need value pointers + return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]} + + +@console_ns.route("/apps//agent/drive/files/preview") +class AgentDrivePreviewApi(Resource): + @console_ns.doc("preview_agent_drive_file") + @console_ns.doc(description="Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)") + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)}) + @console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_APP_MODES) + def get(self, app_model: App): + query = query_params_from_request(AgentDriveFileQuery) + agent_id = _resolve_agent_id(app_model, query.node_id) + if not agent_id: + return _agent_not_bound() + try: + return AgentDriveService().preview(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key) + except AgentDriveError as exc: + return _handle(exc) + + +@console_ns.route("/apps//agent/drive/files/download") +class AgentDriveDownloadApi(Resource): + @console_ns.doc("download_agent_drive_file") + @console_ns.doc(description="Time-limited external signed URL for one drive value (no streaming proxy)") + @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)}) + @console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=_APP_MODES) + def get(self, app_model: App): + query = query_params_from_request(AgentDriveFileQuery) + agent_id = _resolve_agent_id(app_model, query.node_id) + if not agent_id: + return _agent_not_bound() + try: + url = AgentDriveService().download_url(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key) + except AgentDriveError as exc: + return _handle(exc) + return {"url": url} + + +__all__ = [ + "AgentDriveDownloadApi", + "AgentDriveListApi", + "AgentDrivePreviewApi", +] 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 df1e161b64..73d7fdedb8 100644 --- a/api/core/app/apps/agent_app/runtime_request_builder.py +++ b/api/core/app/apps/agent_app/runtime_request_builder.py @@ -32,7 +32,11 @@ from core.workflow.nodes.agent_v2.plugin_tools_builder import ( WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError, ) -from core.workflow.nodes.agent_v2.runtime_request_builder import build_shell_layer_config +from core.workflow.nodes.agent_v2.runtime_request_builder import ( + append_runtime_warnings, + build_drive_layer_config, + build_shell_layer_config, +) from models.agent_config_entities import AgentSoulConfig from models.provider_ids import ModelProviderID from services.agent.prompt_mentions import build_soul_mention_resolver, expand_prompt_mentions @@ -112,6 +116,11 @@ class AgentAppRuntimeRequestBuilder: "cli_tool_count": len(agent_soul.tools.cli_tools), } + drive_config = None + if dify_config.AGENT_DRIVE_MANIFEST_ENABLED: + drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent_id) + append_runtime_warnings(metadata, drive_warnings) + request = self._request_builder.build_for_agent_app( AgentBackendAgentAppRunInput( model=AgentBackendModelConfig( @@ -144,6 +153,7 @@ class AgentAppRuntimeRequestBuilder: or None, user_prompt=context.user_query, tools=tools_layer, + drive_config=drive_config, include_shell=dify_config.AGENT_SHELL_ENABLED, shell_config=build_shell_layer_config(agent_soul), session_snapshot=context.session_snapshot, 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 2859063242..35da898f1e 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py +++ b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py @@ -15,12 +15,14 @@ SUPPORTED_AGENT_BACKEND_FEATURES = frozenset( "tools.cli_tools", "env", "sandbox", + # ENG-623: exposed at runtime as the dify.drive declaration layer + # (an index the agent pulls through the back proxy). + "skills_files", } ) RESERVED_AGENT_BACKEND_FEATURES = frozenset( { - "skills_files", "knowledge", "human", "memory", @@ -28,7 +30,11 @@ RESERVED_AGENT_BACKEND_FEATURES = frozenset( ) -def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any]: +def build_runtime_feature_manifest( + agent_soul: AgentSoulConfig, + *, + drive_manifest_enabled: bool = False, +) -> 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) @@ -46,7 +52,35 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any } ) + 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["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 98bc644f7c..984a0a2069 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -5,6 +5,11 @@ from dataclasses import dataclass from typing import Any, Literal, Protocol, assert_never, cast from agenton.compositor import CompositorSessionSnapshot +from dify_agent.layers.drive import ( + DifyDriveFileConfig, + DifyDriveLayerConfig, + DifyDriveSkillConfig, +) from dify_agent.layers.execution_context import ( DifyExecutionContextInvokeFrom, DifyExecutionContextLayerConfig, @@ -169,6 +174,11 @@ class WorkflowAgentRuntimeRequestBuilder: "cli_tool_count": len(agent_soul.tools.cli_tools), } + drive_config: DifyDriveLayerConfig | None = None + if dify_config.AGENT_DRIVE_MANIFEST_ENABLED: + drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent.id) + append_runtime_warnings(metadata, drive_warnings) + request = self._request_builder.build_for_workflow_node( AgentBackendWorkflowNodeRunInput( model=AgentBackendModelConfig( @@ -206,6 +216,7 @@ class WorkflowAgentRuntimeRequestBuilder: user_prompt=user_prompt, output=self._build_output_config(node_job.declared_outputs), tools=tools_layer, + drive_config=drive_config, include_shell=dify_config.AGENT_SHELL_ENABLED, shell_config=build_shell_layer_config(agent_soul), session_snapshot=context.session_snapshot, @@ -269,7 +280,10 @@ 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), + "runtime_support": build_runtime_feature_manifest( + agent_soul, + drive_manifest_enabled=dify_config.AGENT_DRIVE_MANIFEST_ENABLED, + ), } def _build_workflow_context_prompt( @@ -482,6 +496,89 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi ) +def append_runtime_warnings(metadata: dict[str, Any], warnings: list[dict[str, str]]) -> None: + """Merge build-time warnings into the metadata runtime-support manifest.""" + if not warnings: + return + manifest = metadata.setdefault("runtime_support", {}) + if isinstance(manifest, dict): + existing = manifest.setdefault("unsupported_runtime_warnings", []) + if isinstance(existing, list): + existing.extend(warnings) + + +def build_drive_layer_config( + agent_soul: AgentSoulConfig, + *, + agent_id: str | None, +) -> tuple[DifyDriveLayerConfig | None, list[dict[str, str]]]: + """Catalog the soul's drive-backed Skills & Files into the dify.drive declaration. + + 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]] = [] + if not agent_id: + 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.", + } + ) + 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, + ) + ) + + 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 + + def _cli_tool_enabled(item: object) -> bool: """A CLI tool is bootstrapped unless explicitly disabled (default is enabled).""" data = _plain_mapping(item) diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index 6b99b1baab..1f50924681 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -99,6 +99,10 @@ class AgentFileRefConfig(AgentFlexibleConfig): transfer_method: str | None = Field(default=None, max_length=64) url: str | None = None remote_url: str | None = None + # Drive key once the file is committed to the agent drive ("files/", + # ENG-625). Files without it are plain upload references and stay invisible + # to the runtime drive manifest. + drive_key: str | None = Field(default=None, max_length=512) class AgentSkillRefConfig(AgentFlexibleConfig): @@ -107,6 +111,16 @@ class AgentSkillRefConfig(AgentFlexibleConfig): description: str | None = None file_id: str | None = Field(default=None, max_length=255) path: str | None = None + # Standardization outputs (ENG-594) — previously riding along via + # ``extra="allow"``, promoted to the explicit schema because the runtime + # drive manifest (ENG-623) keys off them. + skill_md_key: str | None = Field(default=None, max_length=512) + skill_md_file_id: str | None = Field(default=None, max_length=255) + full_archive_key: str | None = Field(default=None, max_length=512) + full_archive_file_id: str | None = Field(default=None, max_length=255) + # Zip member path listing from standardization (ENG-371): lets infer-tools + # show the model strong signals like ``scripts/*.sh`` without unpacking. + manifest_files: list[str] | None = None class AgentPermissionConfig(BaseModel): @@ -175,6 +189,10 @@ class AgentCliToolConfig(AgentFlexibleConfig): risk_accepted: bool = False approved: bool = False risk_level: AgentCliToolRiskLevel | None = None + # Slug of the skill an infer-tools suggestion came from (ENG-371); drives + # the "inferred from " badge. Plain provenance metadata — saving an + # inferred tool still passes every composer validation rule. + inferred_from: str | None = Field(default=None, max_length=255) class AgentKnowledgeDatasetConfig(AgentFlexibleConfig): diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 838b479bd9..211bf30a6d 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -1042,6 +1042,98 @@ Upload one Agent App sandbox file as a Dify ToolFile mapping | ---- | ----------- | ------ | | 200 | Uploaded | **application/json**: [SandboxUploadResponse](#sandboxuploadresponse)
| +### [GET] /apps/{app_id}/agent/drive/files +List agent drive entries (read-only inspector; one endpoint for both tabs) + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | +| prefix | query | Key prefix filter: '/' for one skill, 'files/' for files | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Drive entries | **application/json**: [AgentDriveListResponse](#agentdrivelistresponse)
| + +### [GET] /apps/{app_id}/agent/drive/files/download +Time-limited external signed URL for one drive value (no streaming proxy) + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Signed URL | **application/json**: [AgentDriveDownloadResponse](#agentdrivedownloadresponse)
| + +### [GET] /apps/{app_id}/agent/drive/files/preview +Truncated text preview of one drive value (binary-safe; SKILL.md is the main case) + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Preview | **application/json**: [AgentDrivePreviewResponse](#agentdrivepreviewresponse)
| + +### [DELETE] /apps/{app_id}/agent/files +Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5) + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| key | query | Drive key, e.g. files/sample.pdf | Yes | string | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | File removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)
| + +### [POST] /apps/{app_id}/agent/files +**ADD FILE: commit one uploaded file into the bound agent's drive** + +Commit an uploaded file into the agent drive under files/ (ENG-625 D3) + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AgentDriveFilePayload](#agentdrivefilepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | File committed into the agent drive | **application/json**: [AgentDriveFileCommitResponse](#agentdrivefilecommitresponse)
| + ### [GET] /apps/{app_id}/agent/logs **Get agent logs** @@ -1072,6 +1164,7 @@ Validate + standardize a Skill into the agent drive (ENG-594) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID | Yes | string | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | #### Responses @@ -1100,6 +1193,43 @@ plus its manifest. Standardizing into the agent drive is ENG-594. | 201 | Skill validated | **application/json**: [AgentSkillUploadResponse](#agentskilluploadresponse)
| | 400 | Invalid skill package | | +### [DELETE] /apps/{app_id}/agent/skills/{slug} +Delete a standardized skill: soul ref first, then the / drive prefix (ENG-625 D5) + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| slug | path | Skill slug (single path segment) | Yes | string | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Skill removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)
| + +### [POST] /apps/{app_id}/agent/skills/{slug}/infer-tools +**Suggest CLI tools/env for a skill** + +Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371) +Saving still goes through composer validation. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| slug | path | Skill slug (single path segment) | Yes | string | +| node_id | query | Workflow node ID (workflow composer variant) | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Inference result (draft suggestions, nothing persisted) | **application/json**: [SkillToolInferenceResult](#skilltoolinferenceresult)
| + ### [POST] /apps/{app_id}/annotation-reply/{action} Enable or disable annotation reply for an app @@ -10852,6 +10982,7 @@ composer/publish validators and skipped by runtime request builders. | enabled | boolean,
**Default:** true | | No | | env | [AgentCliToolEnvConfig](#agentclitoolenvconfig) | | No | | id | string | | No | +| inferred_from | string | | No | | install | string | | No | | install_command | string | | No | | install_commands | [ string ] | | No | @@ -10930,6 +11061,7 @@ Risk marker for CLI tool bootstrap commands. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| drive_key | string | | No | | file_id | string | | No | | id | string | | No | | kind | string,
**Default:** file | | No | @@ -10972,10 +11104,15 @@ Risk marker for CLI tool bootstrap commands. | ---- | ---- | ----------- | -------- | | 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 @@ -11058,6 +11195,70 @@ Audit operation recorded for Agent Soul version/revision changes. | version | integer | | Yes | | version_note | string | | No | +#### AgentDriveDeleteResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| config_version_id | string | | No | +| removed_keys | [ string ] | | No | +| result | string | | Yes | + +#### AgentDriveDownloadResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| url | string | | Yes | + +#### AgentDriveFileCommitResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| config_version_id | string | | No | +| file | [AgentDriveFileResponse](#agentdrivefileresponse) | | Yes | + +#### AgentDriveFilePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| upload_file_id | string | UploadFile UUID from POST /console/api/files/upload | Yes | + +#### AgentDriveFileResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| drive_key | string | | Yes | +| file_id | string | | Yes | +| mime_type | string | | No | +| name | string | | Yes | +| size | integer | | No | + +#### AgentDriveItemResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| file_kind | string | | Yes | +| hash | string | | No | +| key | string | | Yes | +| mime_type | string | | No | +| size | integer | | No | + +#### AgentDriveListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| items | [ [AgentDriveItemResponse](#agentdriveitemresponse) ] | | No | + +#### AgentDrivePreviewResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| binary | boolean | | Yes | +| key | string | | Yes | +| size | integer | | No | +| text | string | | No | +| truncated | boolean | | Yes | + #### AgentEnvVariableConfig | Name | Type | Description | Required | @@ -11081,6 +11282,7 @@ Audit operation recorded for Agent Soul version/revision changes. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| drive_key | string | | No | | file_id | string | | No | | id | string | | No | | name | string | | No | @@ -11425,21 +11627,28 @@ Visibility and lifecycle scope of an Agent record. | ---- | ---- | ----------- | -------- | | 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 | #### AgentSkillStandardizeResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| AgentSkillStandardizeResponse | object | | | +| manifest | [SkillManifest](#skillmanifest) | | Yes | +| skill | [AgentSkillRefConfig](#agentskillrefconfig) | | Yes | #### AgentSkillUploadResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| AgentSkillUploadResponse | object | | | +| manifest | [SkillManifest](#skillmanifest) | | Yes | +| skill | [AgentSkillRefConfig](#agentskillrefconfig) | | Yes | #### AgentSoulAppFeaturesConfig @@ -12457,6 +12666,17 @@ Button styles for user actions. | ---- | ---- | ----------- | -------- | | content | string | | Yes | +#### CliToolSuggestion + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| command | string | | No | +| description | string | | No | +| env_suggestions | [ [EnvSuggestion](#envsuggestion) ] | | No | +| inferred_from | string | | No | +| install_commands | [ string ] | | No | +| name | string | | Yes | + #### CodeBasedExtensionQuery | Name | Type | Description | Required | @@ -14087,6 +14307,14 @@ Request payload for bulk downloading documents as a zip archive. | ---- | ---- | ----------- | -------- | | success | boolean | Operation success | Yes | +#### EnvSuggestion + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| key | string | | Yes | +| reason | string | | No | +| secret_likely | boolean | | No | + #### EnvironmentVariableItemResponse | Name | Type | Description | Required | @@ -17117,6 +17345,27 @@ Simple provider entity response. | title | string | | Yes | | use_icon_as_answer_icon | boolean | | Yes | +#### SkillManifest + +Validated metadata extracted from a Skill package. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | Yes | +| entry_path | string | | Yes | +| files | [ string ] | | Yes | +| hash | string | | Yes | +| name | string | | Yes | +| size | integer | | Yes | + +#### SkillToolInferenceResult + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| cli_tools | [ [CliToolSuggestion](#clitoolsuggestion) ] | | No | +| inferable | boolean | | Yes | +| reason | string | | No | + #### Snippet | Name | Type | Description | Required | diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index b29fad3722..3f544e9438 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -12,6 +12,7 @@ from models.agent import ( AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot, + AgentDriveFile, AgentKind, AgentScope, AgentSource, @@ -20,6 +21,7 @@ from models.agent import ( WorkflowAgentNodeBinding, ) from models.agent_config_entities import ( + AgentFileRefConfig, DeclaredOutputConfig, ) from models.agent_config_entities import ( @@ -28,7 +30,12 @@ from models.agent_config_entities import ( from models.workflow import Workflow from services.agent.agent_soul_state import agent_soul_has_model from services.agent.composer_validator import ComposerConfigValidator -from services.agent.errors import AgentNameConflictError, AgentNotFoundError, AgentVersionNotFoundError +from services.agent.errors import ( + AgentNameConflictError, + AgentNotFoundError, + AgentVersionNotFoundError, + InvalidComposerConfigError, +) from services.entities.agent_entities import ( AgentSoulConfig, ComposerCandidatesResponse, @@ -99,6 +106,21 @@ 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.SAVE_TO_CURRENT_VERSION, ComposerSaveStrategy.SAVE_AS_NEW_VERSION) + ): + 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( @@ -220,6 +242,9 @@ 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, @@ -252,8 +277,18 @@ class AgentComposerService: return state @classmethod - def collect_validation_findings(cls, *, tenant_id: str, payload: ComposerSavePayload) -> dict[str, Any]: - """ENG-617 soft findings, with DB-backed dataset existence for placeholders.""" + def collect_validation_findings( + cls, + *, + tenant_id: str, + 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. + """ from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions mentioned_ids: set[str] = set() @@ -266,7 +301,242 @@ class AgentComposerService: existing_dataset_ids: set[str] | None = None if mentioned_ids: existing_dataset_ids = set(cls._dataset_rows(tenant_id=tenant_id, dataset_ids=sorted(mentioned_ids))) - return ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids) + 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) + ) + 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).""" + return db.session.scalar( + select(Agent.id) + .where( + Agent.tenant_id == tenant_id, + Agent.app_id == app_id, + Agent.scope == AgentScope.ROSTER, + Agent.status == AgentStatus.ACTIVE, + ) + .order_by(Agent.created_at.desc()) + .limit(1) + ) + + @classmethod + def resolve_workflow_node_agent_id(cls, *, tenant_id: str, app_id: str, node_id: str) -> str | None: + """The draft workflow node binding's agent id, if any (validate-endpoint context).""" + try: + workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id) + except ValueError: + return None + binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id) + 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( + cls, + *, + tenant_id: str, + agent_id: str, + agent_soul: AgentSoulConfig, + ) -> list[dict[str, str | None]]: + """Drive-backed refs whose keys have no row in the agent drive (ENG-623 §4.4). + + 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") + if not wanted_keys: + return [] + + existing_keys = set( + db.session.scalars( + select(AgentDriveFile.key).where( + AgentDriveFile.tenant_id == tenant_id, + AgentDriveFile.agent_id == agent_id, + AgentDriveFile.key.in_(sorted(wanted_keys)), + ) + ) + ) + findings: list[dict[str, str | None]] = [] + for key, (code, display) in wanted_keys.items(): + if key in existing_keys: + continue + kind = "skill" if code == "skill_ref_dangling" else "file" + findings.append( + { + "code": code, + "surface": "agent_soul", + "kind": kind, + "id": key, + "message": f"{code}: {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]: diff --git a/api/services/agent/skill_package_service.py b/api/services/agent/skill_package_service.py index 61bc6dd0ed..6452329904 100644 --- a/api/services/agent/skill_package_service.py +++ b/api/services/agent/skill_package_service.py @@ -70,6 +70,7 @@ class SkillManifest(BaseModel): "size": self.size, "hash": self.hash, "entry_path": self.entry_path, + "manifest_files": self.files, } ) diff --git a/api/services/agent/skill_standardize_service.py b/api/services/agent/skill_standardize_service.py index bd80d03168..71bb8daded 100644 --- a/api/services/agent/skill_standardize_service.py +++ b/api/services/agent/skill_standardize_service.py @@ -112,6 +112,8 @@ class SkillStandardizeService: "skill_md_key": skill_md_key, "full_archive_file_id": archive_tool_file.id, "full_archive_key": archive_key, + # ENG-371: zip member listing — strong signals (scripts/*.sh) for infer-tools. + "manifest_files": manifest.files, } ) return { diff --git a/api/services/agent/skill_tool_inference_service.py b/api/services/agent/skill_tool_inference_service.py new file mode 100644 index 0000000000..343ab370f8 --- /dev/null +++ b/api/services/agent/skill_tool_inference_service.py @@ -0,0 +1,215 @@ +"""Infer CLI tool + ENV suggestions from a standardized skill (ENG-371). + +Reads the skill's SKILL.md from the agent drive, asks the tenant's default +reasoning model once (a plain LLM call, never an agent run), and returns +*draft* suggestions only — nothing is persisted here. The frontend prefills +the TOOLS box (``inferred from `` badge) and the Pre-Authorize ENV +panel, and saving still goes through the composer's full shell/env/secret/ +dangerous-command validation, so inference opens no bypass. + +ENV suggestions carry only ``key`` + ``reason`` — the model never produces a +value; users fill those in themselves and the runtime injects ``$VAR`` only. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +import json_repair +from pydantic import BaseModel, Field, ValidationError +from sqlalchemy import select + +from core.errors.error import ProviderTokenNotInitError +from core.model_manager import ModelManager +from extensions.ext_database import db +from graphon.model_runtime.entities.message_entities import SystemPromptMessage, UserPromptMessage +from graphon.model_runtime.entities.model_entities import ModelType +from models.agent import Agent +from models.agent_config_entities import AgentSoulConfig +from services.agent_drive_service import AgentDriveError, AgentDriveService + +logger = logging.getLogger(__name__) + + +class SkillToolInferenceError(Exception): + """Stable-code error for the infer-tools endpoint.""" + + def __init__(self, code: str, message: str, *, status_code: int = 400) -> None: + self.code = code + self.message = message + self.status_code = status_code + super().__init__(message) + + +class EnvSuggestion(BaseModel): + key: str + reason: str = "" + secret_likely: bool = False + + +class CliToolSuggestion(BaseModel): + name: str + description: str = "" + command: str = "" + install_commands: list[str] = Field(default_factory=list) + env_suggestions: list[EnvSuggestion] = Field(default_factory=list) + inferred_from: str = "" + + +class SkillToolInferenceResult(BaseModel): + inferable: bool + cli_tools: list[CliToolSuggestion] = Field(default_factory=list) + reason: str | None = None + + +_SYSTEM_PROMPT = """\ +You analyze an agent skill document (SKILL.md) and infer which command-line \ +tools the skill depends on at runtime, so a user can pre-install them in the \ +agent's sandbox. + +Rules: +- Only suggest tools the document explicitly uses or clearly requires; never guess. +- For each tool give: name, a one-line reason-style description referencing the \ +document, the base command, and install commands for a Debian-based sandbox \ +(apt-get / pip / npm). +- If a step needs an environment variable (an API key, token, endpoint), add it \ +to env_suggestions with the variable key and the reason. NEVER produce a value. \ +Mark secret_likely=true for credentials. +- If the document describes no external command-line dependency, return \ +{"inferable": false, "cli_tools": [], "reason": ""}. + +Respond with JSON only, matching exactly: +{"inferable": bool, + "cli_tools": [{"name": str, "description": str, "command": str, + "install_commands": [str], "env_suggestions": + [{"key": str, "reason": str, "secret_likely": bool}]}], + "reason": str | null} +""" + + +class SkillToolInferenceService: + """Single-shot LLM inference over a drive-stored SKILL.md.""" + + def __init__(self, *, drive_service: AgentDriveService | None = None) -> None: + self._drive = drive_service or AgentDriveService() + + def infer(self, *, tenant_id: str, agent_id: str, slug: str) -> dict[str, Any]: + skill_md = self._load_skill_md(tenant_id=tenant_id, agent_id=agent_id, slug=slug) + manifest_files = self._manifest_files_from_soul(tenant_id=tenant_id, agent_id=agent_id, slug=slug) + + user_prompt = f"SKILL.md of skill '{slug}':\n\n{skill_md}" + if manifest_files: + listing = "\n".join(manifest_files[:200]) + user_prompt += f"\n\nFiles inside the skill package:\n{listing}" + + raw = self._invoke(tenant_id=tenant_id, user_prompt=user_prompt) + try: + result = self._parse(raw) + except (ValidationError, ValueError): + logger.warning("skill tool inference output unparsable, retrying once") + raw = self._invoke(tenant_id=tenant_id, user_prompt=user_prompt) + try: + result = self._parse(raw) + except (ValidationError, ValueError) as exc: + raise SkillToolInferenceError( + "inference_failed", + "inference_failed: the model output could not be parsed into tool suggestions.", + status_code=422, + ) from exc + + for tool in result.cli_tools: + tool.inferred_from = slug + return result.model_dump(mode="json") + + def _load_skill_md(self, *, tenant_id: str, agent_id: str, slug: str) -> str: + try: + preview = self._drive.preview(tenant_id=tenant_id, agent_id=agent_id, key=f"{slug}/SKILL.md") + except AgentDriveError as exc: + if exc.code == "drive_key_not_found": + raise SkillToolInferenceError( + "skill_not_found", f"skill_not_found: no drive entry for skill '{slug}'.", status_code=404 + ) from exc + raise SkillToolInferenceError(exc.code, exc.message, status_code=exc.status_code) from exc + if preview["binary"] or not preview["text"]: + raise SkillToolInferenceError( + "skill_not_found", f"skill_not_found: SKILL.md of '{slug}' is not readable text.", status_code=404 + ) + return str(preview["text"]) + + @staticmethod + def _manifest_files_from_soul(*, tenant_id: str, agent_id: str, slug: str) -> list[str]: + """The zip path listing standardize persisted onto the ref, if present. + + Degrades to an empty list (SKILL.md-only inference) for refs that + predate ``manifest_files``. + """ + 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 [] + from models.agent import AgentConfigSnapshot + + snapshot = db.session.scalar( + select(AgentConfigSnapshot).where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent_id, + AgentConfigSnapshot.id == agent.active_config_snapshot_id, + ) + ) + if snapshot is None: + return [] + soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) + for skill in soul.skills_files.skills: + ref_slug = (skill.skill_md_key or "").split("/", 1)[0] or (skill.path or "").strip("/") + if ref_slug != slug: + continue + files = skill.get("manifest_files") + if isinstance(files, list): + return [str(item) for item in files] + return [] + + @staticmethod + def _invoke(*, tenant_id: str, user_prompt: str) -> str: + try: + model_manager = ModelManager.for_tenant(tenant_id=tenant_id) + model_instance = model_manager.get_default_model_instance(tenant_id=tenant_id, model_type=ModelType.LLM) + except ProviderTokenNotInitError as exc: + raise SkillToolInferenceError( + "default_model_not_configured", + "default_model_not_configured: the workspace has no default reasoning model.", + status_code=400, + ) from exc + try: + response = model_instance.invoke_llm( + prompt_messages=[ + SystemPromptMessage(content=_SYSTEM_PROMPT), + UserPromptMessage(content=user_prompt), + ], + model_parameters={"temperature": 0.1}, + stream=False, + ) + except Exception as exc: + raise SkillToolInferenceError( + "inference_failed", f"inference_failed: model invocation failed: {exc}", status_code=422 + ) from exc + return response.message.get_text_content() + + @staticmethod + def _parse(raw: str) -> SkillToolInferenceResult: + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parsed = json_repair.loads(raw) + if not isinstance(parsed, dict): + raise ValueError("model output is not a JSON object") + return SkillToolInferenceResult.model_validate(parsed) + + +__all__ = [ + "CliToolSuggestion", + "EnvSuggestion", + "SkillToolInferenceError", + "SkillToolInferenceResult", + "SkillToolInferenceService", +] diff --git a/api/services/agent_drive_service.py b/api/services/agent_drive_service.py index 74eb4e8751..276f6339b8 100644 --- a/api/services/agent_drive_service.py +++ b/api/services/agent_drive_service.py @@ -131,6 +131,7 @@ class AgentDriveService: "mime_type": row.mime_type, "file_kind": row.file_kind.value, "file_id": row.file_id, + "created_at": int(row.created_at.timestamp()) if row.created_at else None, } if include_download_url: item["download_url"] = self._resolve_download_url( @@ -169,6 +170,52 @@ class AgentDriveService: self._delete_storage(storage_key) return committed + def delete( + self, + *, + tenant_id: str, + agent_id: str, + prefix: str | None = None, + key: str | None = None, + ) -> list[str]: + """Delete drive entries by exact ``key`` or by ``prefix`` (ENG-625 D5). + + Drive-owned values get their backing record + storage object cleaned via + the same ``_cleanup_value`` path commit-overwrite uses; shared values only + lose the KV row. Idempotent: deleting nothing returns ``[]``. + """ + if (prefix is None) == (key is None): + raise AgentDriveError("invalid_delete_scope", "delete requires exactly one of prefix or key") + removed_keys: list[str] = [] + pending_storage_deletes: list[str] = [] + with session_factory.create_session() as session: + self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) + stmt = select(AgentDriveFile).where( + AgentDriveFile.tenant_id == tenant_id, + AgentDriveFile.agent_id == agent_id, + ) + if key is not None: + stmt = stmt.where(AgentDriveFile.key == normalize_drive_key(key)) + else: + stmt = stmt.where(AgentDriveFile.key.startswith(normalize_drive_key(prefix or ""))) + rows = list(session.scalars(stmt)) + for row in rows: + if row.value_owned_by_drive: + self._cleanup_value( + session, + tenant_id=tenant_id, + file_kind=row.file_kind, + file_id=row.file_id, + exclude_row_id=row.id, + pending_storage_deletes=pending_storage_deletes, + ) + removed_keys.append(row.key) + session.delete(row) + session.commit() + for storage_key in pending_storage_deletes: + self._delete_storage(storage_key) + return removed_keys + def _commit_one( self, session: Session, @@ -338,7 +385,12 @@ class AgentDriveService: logger.warning("failed to delete drive storage object %s", storage_key, exc_info=True) @staticmethod - def _resolve_download_url(*, tenant_id: str, file_kind: AgentDriveFileKind, file_id: str) -> str | None: + def _resolve_download_url( + *, tenant_id: str, file_kind: AgentDriveFileKind, file_id: str, for_external: bool = False + ) -> str | None: + """Signed URL for a drive value. ``for_external`` selects the audience: + the inner manifest hands agents *internal* URLs, while the console + inspector must hand browsers *external* ones — never mix the two.""" if file_kind == AgentDriveFileKind.TOOL_FILE: mapping: dict[str, Any] = {"transfer_method": "tool_file", "tool_file_id": file_id} else: @@ -349,10 +401,86 @@ class AgentDriveService: # No FileAccessScope bound -> drive-owned: the builders still filter by # tenant_id, so resolution is tenant-scoped without user-level checks. file = file_factory.build_from_mapping(mapping=mapping, tenant_id=tenant_id, access_controller=controller) - return runtime.resolve_file_url(file=file, for_external=False) + return runtime.resolve_file_url(file=file, for_external=for_external) except ValueError: return None + # ── console drive inspector (ENG-624) ──────────────────────────────────── + + # SKILL.md is the primary preview use case; 64 KiB covers it with headroom + # while keeping the console payload bounded. + PREVIEW_MAX_BYTES = 64 * 1024 + + def _require_row(self, session: Session, *, tenant_id: str, agent_id: str, key: str) -> AgentDriveFile: + row = session.scalar( + select(AgentDriveFile).where( + AgentDriveFile.tenant_id == tenant_id, + AgentDriveFile.agent_id == agent_id, + AgentDriveFile.key == normalize_drive_key(key), + ) + ) + if row is None: + raise AgentDriveError("drive_key_not_found", "no drive entry for this key", status_code=404) + return row + + def _storage_key_for_row(self, session: Session, *, tenant_id: str, row: AgentDriveFile) -> str: + if row.file_kind == AgentDriveFileKind.TOOL_FILE: + tool_file = session.scalar( + select(ToolFile).where(ToolFile.id == row.file_id, ToolFile.tenant_id == tenant_id) + ) + if tool_file is None: + raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404) + return tool_file.file_key + upload_file = session.scalar( + select(UploadFile).where(UploadFile.id == row.file_id, UploadFile.tenant_id == tenant_id) + ) + if upload_file is None: + raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404) + return upload_file.key + + def preview(self, *, tenant_id: str, agent_id: str, key: str) -> dict[str, Any]: + """Truncated text preview of one drive value (binary-safe, never 500s on size).""" + with session_factory.create_session() as session: + self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) + row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key) + storage_key = self._storage_key_for_row(session, tenant_id=tenant_id, row=row) + size = row.size + + data = bytearray() + for chunk in storage.load_stream(storage_key): + data.extend(chunk) + if len(data) > self.PREVIEW_MAX_BYTES: + break + truncated = len(data) > self.PREVIEW_MAX_BYTES + sample = bytes(data[: self.PREVIEW_MAX_BYTES]) + # Same semantics as the sandbox read endpoint: NUL or undecodable -> binary. + if b"\x00" in sample: + return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None} + try: + text = sample.decode("utf-8") + except UnicodeDecodeError: + if truncated: + # A multi-byte char may sit on the cut point; retry without the tail. + try: + text = sample[:-3].decode("utf-8", errors="strict") + except UnicodeDecodeError: + return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None} + else: + return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None} + return {"key": row.key, "size": size, "truncated": truncated, "binary": False, "text": text} + + def download_url(self, *, tenant_id: str, agent_id: str, key: str) -> str: + """External signed URL for a browser download of one drive value.""" + with session_factory.create_session() as session: + self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) + row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key) + url = self._resolve_download_url( + tenant_id=tenant_id, file_kind=row.file_kind, file_id=row.file_id, for_external=True + ) + if url is None: + raise AgentDriveError("drive_key_not_found", "drive value cannot be resolved", status_code=404) + return url + __all__ = [ "AgentDriveError", diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index e4eca72820..d3a43f00e2 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -283,6 +283,10 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save( lambda **kwargs: _workflow_composer_response(save_options=[kwargs["payload"].save_strategy.value]), ) monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None) + monkeypatch.setattr( + composer_controller.AgentComposerService, "resolve_workflow_node_agent_id", lambda **kwargs: None + ) + monkeypatch.setattr(composer_controller.AgentComposerService, "resolve_bound_agent_id", lambda **kwargs: None) monkeypatch.setattr( composer_controller.AgentComposerService, "get_workflow_candidates", @@ -354,6 +358,10 @@ def test_agent_app_composer_get_put_validate_and_candidates( lambda **kwargs: _agent_app_composer_response(), ) monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None) + monkeypatch.setattr( + composer_controller.AgentComposerService, "resolve_workflow_node_agent_id", lambda **kwargs: None + ) + monkeypatch.setattr(composer_controller.AgentComposerService, "resolve_bound_agent_id", lambda **kwargs: None) monkeypatch.setattr( composer_controller.AgentComposerService, "get_agent_app_candidates", diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py b/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py new file mode 100644 index 0000000000..516c87989b --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py @@ -0,0 +1,110 @@ +"""Unit tests for the console agent drive inspector (ENG-624). + +Handlers are unwrapped past the login/app-model decorators and invoked inside a +bare Flask request context with the drive service mocked — covering agent +resolution, query handling, and error mapping, not auth. +""" + +from __future__ import annotations + +import inspect +from types import SimpleNamespace +from unittest.mock import patch + +from flask import Flask + +from controllers.console.app.agent_drive_inspector import ( + AgentDriveDownloadApi, + AgentDriveListApi, + AgentDrivePreviewApi, +) +from services.agent_drive_service import AgentDriveError + +_MOD = "controllers.console.app.agent_drive_inspector" +app = Flask(__name__) + + +def _raw(method): + return inspect.unwrap(method) + + +_APP = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id="agent-1") + + +def test_list_filters_value_pointers_out_of_console_payload(): + raw = _raw(AgentDriveListApi.get) + with app.test_request_context("/?prefix=pdf-toolkit/"): + with patch(f"{_MOD}.AgentDriveService") as drive: + drive.return_value.manifest.return_value = [ + { + "key": "pdf-toolkit/SKILL.md", + "size": 5, + "hash": "h", + "mime_type": "text/markdown", + "file_kind": "tool_file", + "file_id": "tf-1", + "created_at": 1718000000, + } + ] + body = raw(AgentDriveListApi(), _APP) + + assert body["items"][0]["key"] == "pdf-toolkit/SKILL.md" + assert "file_id" not in body["items"][0] + assert drive.return_value.manifest.call_args.kwargs["prefix"] == "pdf-toolkit/" + + +def test_list_resolves_workflow_node_binding_agent(): + raw = _raw(AgentDriveListApi.get) + with app.test_request_context("/?node_id=agent-node-1"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9" + drive.return_value.manifest.return_value = [] + raw(AgentDriveListApi(), _APP) + + assert drive.return_value.manifest.call_args.kwargs["agent_id"] == "wf-agent-9" + assert composer.resolve_workflow_node_agent_id.call_args.kwargs["node_id"] == "agent-node-1" + + +def test_list_400_when_no_agent_bound(): + raw = _raw(AgentDriveListApi.get) + app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None) + with app.test_request_context("/"): + body, status = raw(AgentDriveListApi(), app_without_agent) + assert status == 400 + assert body["code"] == "agent_not_bound" + + +def test_preview_passes_through_and_maps_errors(): + raw = _raw(AgentDrivePreviewApi.get) + with app.test_request_context("/?key=pdf-toolkit/SKILL.md"): + with patch(f"{_MOD}.AgentDriveService") as drive: + drive.return_value.preview.return_value = { + "key": "pdf-toolkit/SKILL.md", + "size": 5, + "truncated": False, + "binary": False, + "text": "# hi", + } + body = raw(AgentDrivePreviewApi(), _APP) + assert body["text"] == "# hi" + + with app.test_request_context("/?key=ghost/SKILL.md"): + with patch(f"{_MOD}.AgentDriveService") as drive: + drive.return_value.preview.side_effect = AgentDriveError( + "drive_key_not_found", "no drive entry", status_code=404 + ) + body, status = raw(AgentDrivePreviewApi(), _APP) + assert status == 404 + assert body["code"] == "drive_key_not_found" + + +def test_download_returns_signed_url_json(): + raw = _raw(AgentDriveDownloadApi.get) + with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"): + with patch(f"{_MOD}.AgentDriveService") as drive: + drive.return_value.download_url.return_value = "https://signed.example/zip" + body = raw(AgentDriveDownloadApi(), _APP) + assert body == {"url": "https://signed.example/zip"} diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_skills.py b/api/tests/unit_tests/controllers/console/app/test_agent_skills.py index 015ae040e6..21cc0f7790 100644 --- a/api/tests/unit_tests/controllers/console/app/test_agent_skills.py +++ b/api/tests/unit_tests/controllers/console/app/test_agent_skills.py @@ -32,7 +32,7 @@ def _file_ctx(*, files: dict[str, bytes] | None = None): _USER = SimpleNamespace(id="user-1") -_APP = SimpleNamespace(tenant_id="tenant-1", bound_agent_id="agent-1") +_APP = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id="agent-1") def test_upload_validates_and_returns_skill_ref(): @@ -89,11 +89,30 @@ def test_standardize_returns_result(): def test_standardize_no_bound_agent_is_400(): raw = _raw(AgentSkillStandardizeApi.post) - app_without_agent = SimpleNamespace(tenant_id="tenant-1", bound_agent_id=None) + app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None) with _file_ctx(files={"file": b"zip"}): body, status = raw(AgentSkillStandardizeApi(), _USER, app_without_agent) assert status == 400 - assert body["code"] == "no_bound_agent" + assert body["code"] == "agent_not_bound" + + +def test_standardize_resolves_workflow_node_agent(): + raw = _raw(AgentSkillStandardizeApi.post) + workflow_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None) + with app.test_request_context( + "/?node_id=agent-node-1", method="POST", data={"file": (io.BytesIO(b"zip"), "skill.zip")} + ): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.SkillStandardizeService") as svc, + ): + composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1" + svc.return_value.standardize.return_value = {"skill": {"path": "s"}, "manifest": {}} + body, status = raw(AgentSkillStandardizeApi(), _USER, workflow_app) + + assert status == 201 + assert body["skill"] == {"path": "s"} + assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "wf-agent-1" def test_standardize_maps_drive_error(): @@ -104,3 +123,239 @@ def test_standardize_maps_drive_error(): body, status = raw(AgentSkillStandardizeApi(), _USER, _APP) assert status == 404 assert body["code"] == "source_not_found" + + +# ── ENG-625: drive files commit + delete endpoints ──────────────────────────── + + +def _json_ctx(payload: dict | None = None, *, method: str = "POST", query_string: str = ""): + return app.test_request_context(f"/?{query_string}", method=method, json=payload or {}) + + +def test_files_commit_validates_upload_and_returns_drive_ref(): + from controllers.console.app.agent import AgentDriveFilesApi + + raw = _raw(AgentDriveFilesApi.post) + upload = SimpleNamespace(id="uf-1", name="sample qna.pdf") + with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}): + with ( + patch(f"{_MOD}.console_ns") as ns, + patch(f"{_MOD}.db") as db_mock, + patch(f"{_MOD}.AgentDriveService") as drive, + patch(f"{_MOD}.AgentComposerService") as composer, + ): + ns.payload = {"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"} + db_mock.session.scalar.return_value = upload + drive.return_value.commit.return_value = [ + {"key": "files/sample qna.pdf", "size": 5, "mime_type": "application/pdf"} + ] + composer.add_drive_file_ref.return_value = "ver-2" + body, status = raw(AgentDriveFilesApi(), _USER, _APP) + + assert status == 201 + assert body["file"]["drive_key"] == "files/sample qna.pdf" + assert body["file"]["file_id"] == "uf-1" + assert body["config_version_id"] == "ver-2" + item = drive.return_value.commit.call_args.kwargs["items"][0] + assert item.value_owned_by_drive is True + assert item.file_ref.kind == "upload_file" + file_ref = composer.add_drive_file_ref.call_args.kwargs["file_ref"] + assert file_ref.drive_key == "files/sample qna.pdf" + assert file_ref.name == "sample qna.pdf" + assert composer.add_drive_file_ref.call_args.kwargs["app_id"] == "app-1" + + +def test_files_commit_404_when_upload_not_in_tenant(): + from controllers.console.app.agent import AgentDriveFilesApi + + raw = _raw(AgentDriveFilesApi.post) + with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}): + with ( + patch(f"{_MOD}.console_ns") as ns, + patch(f"{_MOD}.db") as db_mock, + ): + ns.payload = {"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"} + db_mock.session.scalar.return_value = None + body, status = raw(AgentDriveFilesApi(), _USER, _APP) + assert status == 404 + assert body["code"] == "upload_file_not_found" + + +def test_files_commit_resolves_workflow_node_agent(): + from controllers.console.app.agent import AgentDriveFilesApi + + raw = _raw(AgentDriveFilesApi.post) + upload = SimpleNamespace(id="uf-1", name="sample.pdf") + workflow_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None) + with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}, query_string="node_id=agent-node-1"): + with ( + patch(f"{_MOD}.console_ns") as ns, + patch(f"{_MOD}.db") as db_mock, + patch(f"{_MOD}.AgentDriveService") as drive, + patch(f"{_MOD}.AgentComposerService") as composer, + ): + ns.payload = {"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"} + db_mock.session.scalar.return_value = upload + composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1" + drive.return_value.commit.return_value = [ + {"key": "files/sample.pdf", "size": 5, "mime_type": "application/pdf"} + ] + composer.add_drive_file_ref.return_value = "ver-2" + body, status = raw(AgentDriveFilesApi(), _USER, workflow_app) + + assert status == 201 + assert body["config_version_id"] == "ver-2" + assert drive.return_value.commit.call_args.kwargs["agent_id"] == "wf-agent-1" + assert composer.add_drive_file_ref.call_args.kwargs["node_id"] == "agent-node-1" + + +def test_files_delete_updates_soul_then_drive(): + from controllers.console.app.agent import AgentDriveFilesApi + + raw = _raw(AgentDriveFilesApi.delete) + calls: list[str] = [] + with _json_ctx(method="DELETE", query_string="key=files/sample.pdf"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + composer.remove_drive_refs.side_effect = lambda **kw: calls.append("soul") or "ver-2" + drive.return_value.delete.side_effect = lambda **kw: calls.append("drive") or ["files/sample.pdf"] + body = raw(AgentDriveFilesApi(), _USER, _APP) + + assert calls == ["soul", "drive"] # soul-first ordering + assert body == {"result": "success", "removed_keys": ["files/sample.pdf"], "config_version_id": "ver-2"} + assert composer.remove_drive_refs.call_args.kwargs["file_key"] == "files/sample.pdf" + assert composer.remove_drive_refs.call_args.kwargs["app_id"] == "app-1" + + +def test_files_delete_resolves_workflow_node_agent(): + from controllers.console.app.agent import AgentDriveFilesApi + + raw = _raw(AgentDriveFilesApi.delete) + workflow_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None) + with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=agent-node-1"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1" + composer.remove_drive_refs.return_value = "ver-2" + drive.return_value.delete.return_value = ["files/sample.pdf"] + body = raw(AgentDriveFilesApi(), _USER, workflow_app) + + assert body["config_version_id"] == "ver-2" + assert drive.return_value.delete.call_args.kwargs["agent_id"] == "wf-agent-1" + assert composer.remove_drive_refs.call_args.kwargs["node_id"] == "agent-node-1" + + +def test_files_delete_survives_drive_failure(): + from controllers.console.app.agent import AgentDriveFilesApi + + raw = _raw(AgentDriveFilesApi.delete) + with _json_ctx(method="DELETE", query_string="key=files/sample.pdf"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + composer.remove_drive_refs.return_value = "ver-2" + drive.return_value.delete.side_effect = RuntimeError("storage down") + body = raw(AgentDriveFilesApi(), _USER, _APP) + # soul already updated; drive cleanup is best-effort and retryable + assert body == {"result": "success", "removed_keys": [], "config_version_id": "ver-2"} + + +def test_skill_delete_uses_slug_prefix_and_is_idempotent(): + from controllers.console.app.agent import AgentSkillApi + + raw = _raw(AgentSkillApi.delete) + with _json_ctx(method="DELETE"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.AgentDriveService") as drive, + ): + composer.remove_drive_refs.return_value = None # ref already gone + drive.return_value.delete.return_value = [] + body = raw(AgentSkillApi(), _USER, _APP, "tender-analyzer") + + assert body == {"result": "success", "removed_keys": [], "config_version_id": None} + assert drive.return_value.delete.call_args.kwargs["prefix"] == "tender-analyzer/" + assert composer.remove_drive_refs.call_args.kwargs["skill_slug"] == "tender-analyzer" + assert composer.remove_drive_refs.call_args.kwargs["app_id"] == "app-1" + + +def test_skill_delete_rejects_path_like_slug(): + from controllers.console.app.agent import AgentSkillApi + + raw = _raw(AgentSkillApi.delete) + with _json_ctx(method="DELETE"): + body, status = raw(AgentSkillApi(), _USER, _APP, "a/b") + assert status == 400 + assert body["code"] == "drive_key_invalid" + + +# ── ENG-371: infer-tools endpoint ───────────────────────────────────────────── + + +def test_infer_tools_returns_draft_suggestions(): + from controllers.console.app.agent import AgentSkillInferToolsApi + + raw = _raw(AgentSkillInferToolsApi.post) + with _json_ctx(): + with patch(f"{_MOD}.SkillToolInferenceService") as svc: + svc.return_value.infer.return_value = { + "inferable": True, + "cli_tools": [{"name": "ffmpeg", "inferred_from": "audio-transcribe"}], + "reason": None, + } + body = raw(AgentSkillInferToolsApi(), _APP, "audio-transcribe") + + assert body["inferable"] is True + assert svc.return_value.infer.call_args.kwargs["slug"] == "audio-transcribe" + + +def test_infer_tools_resolves_workflow_node_agent(): + from controllers.console.app.agent import AgentSkillInferToolsApi + + raw = _raw(AgentSkillInferToolsApi.post) + workflow_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None) + with _json_ctx(query_string="node_id=agent-node-1"): + with ( + patch(f"{_MOD}.AgentComposerService") as composer, + patch(f"{_MOD}.SkillToolInferenceService") as svc, + ): + composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1" + svc.return_value.infer.return_value = {"inferable": False, "cli_tools": [], "reason": "none"} + body = raw(AgentSkillInferToolsApi(), workflow_app, "audio-transcribe") + + assert body["inferable"] is False + assert svc.return_value.infer.call_args.kwargs["agent_id"] == "wf-agent-1" + + +def test_infer_tools_maps_inference_errors(): + from controllers.console.app.agent import AgentSkillInferToolsApi + from services.agent.skill_tool_inference_service import SkillToolInferenceError + + raw = _raw(AgentSkillInferToolsApi.post) + with _json_ctx(): + with patch(f"{_MOD}.SkillToolInferenceService") as svc: + svc.return_value.infer.side_effect = SkillToolInferenceError( + "default_model_not_configured", "no model", status_code=400 + ) + body, status = raw(AgentSkillInferToolsApi(), _APP, "audio-transcribe") + assert status == 400 + assert body["code"] == "default_model_not_configured" + + +def test_infer_tools_rejects_path_like_slug_and_unbound_app(): + from controllers.console.app.agent import AgentSkillInferToolsApi + + raw = _raw(AgentSkillInferToolsApi.post) + with _json_ctx(): + body, status = raw(AgentSkillInferToolsApi(), _APP, "a/b") + assert (status, body["code"]) == (400, "drive_key_invalid") + + app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None) + with _json_ctx(): + body, status = raw(AgentSkillInferToolsApi(), app_without_agent, "x") + assert (status, body["code"]) == (400, "agent_not_bound") diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py index a7287d3f2b..83f9b697b7 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py @@ -186,3 +186,52 @@ class TestAgentAppRuntimeRequestBuilder: "dify_tool_names": [], "cli_tool_count": 1, } + + +# ── ENG-623: drive declaration on the Agent App surface ────────────────────── + + +def _soul_with_model_and_skill() -> AgentSoulConfig: + from models.agent_config_entities import AgentSkillRefConfig + + soul = _soul_with_model() + soul.skills_files.skills = [ + AgentSkillRefConfig.model_validate( + { + "id": "abc", + "name": "Tender Analyzer", + "description": "Parses RFPs.", + "skill_md_key": "tender-analyzer/SKILL.md", + } + ) + ] + return soul + + +class TestAgentAppDriveLayer: + def test_drive_layer_injected_when_flag_enabled(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + "core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True + ) + builder = AgentAppRuntimeRequestBuilder( + credentials_provider=_FakeCredentialsProvider(), + plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + ) + + result = builder.build(_ctx(_soul_with_model_and_skill())) + + drive = next(layer for layer in result.request.composition.layers if layer.name == "drive") + assert drive.type == "dify.drive" + assert drive.config.drive_ref == "agent-agent-1" + assert [skill.skill_md_key for skill in drive.config.skills] == ["tender-analyzer/SKILL.md"] + # injected right after execution_context, mirroring the workflow surface + names = [layer.name for layer in result.request.composition.layers] + assert names.index("drive") == names.index("execution_context") + 1 + + def test_no_drive_layer_when_flag_disabled(self): + builder = AgentAppRuntimeRequestBuilder( + credentials_provider=_FakeCredentialsProvider(), + plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + ) + result = builder.build(_ctx(_soul_with_model_and_skill())) + assert all(layer.name != "drive" for layer in result.request.composition.layers) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index 65571a9383..0fffb0617a 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -675,3 +675,124 @@ def test_mentions_expand_in_soul_and_job_prompts_without_token_leak(): # the value still rides the Workflow context block, not the job prompt assert "Previous result" in dumped["composition"]["layers"][2]["config"]["user"] assert "[§" not in json.dumps(dumped["composition"]["layers"][:3]) + + +# ── ENG-623: dify.drive declaration layer ───────────────────────────────────── + + +def _soul_with_drive_skill() -> AgentSoulConfig: + return AgentSoulConfig( + prompt={"system_prompt": "You are careful."}, + model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"), + skills_files={ + "skills": [ + { + "id": "abc123", + "name": "Tender Analyzer", + "description": "Parses RFPs.", + "skill_md_key": "tender-analyzer/SKILL.md", + "full_archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip", + }, + {"id": "legacy", "name": "Legacy Skill"}, # pre-standardization: no drive key + ], + "files": [ + {"name": "sample.pdf", "drive_key": "files/sample.pdf", "type": "application/pdf"}, + {"name": "plain-upload.pdf", "file_id": "upload-1"}, # not drive-backed + ], + }, + ) + + +def test_build_drive_layer_config_catalogs_only_drive_backed_refs(): + from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config + + config, warnings = build_drive_layer_config(_soul_with_drive_skill(), agent_id="agent-1") + + assert config is not None + assert config.drive_ref == "agent-agent-1" + assert [skill.skill_md_key for skill in config.skills] == ["tender-analyzer/SKILL.md"] + assert config.skills[0].archive_key == "tender-analyzer/.DIFY-SKILL-FULL.zip" + assert [file.key for file in config.files] == ["files/sample.pdf"] + assert [w["code"] for w in warnings] == ["skill_ref_dangling"] + assert "Legacy Skill" in warnings[0]["message"] + + +def test_build_drive_layer_config_skips_when_nothing_configured(): + from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config + + soul = AgentSoulConfig( + model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test") + ) + assert build_drive_layer_config(soul, agent_id="agent-1") == (None, []) + + +def test_build_drive_layer_config_requires_agent_identity(): + from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config + + config, warnings = build_drive_layer_config(_soul_with_drive_skill(), agent_id=None) + + assert config is None + assert [w["code"] for w in warnings] == ["skill_ref_dangling"] + + +def test_workflow_run_request_contains_drive_layer_when_flag_enabled(monkeypatch: pytest.MonkeyPatch): + """Contract test: locks the dify.drive composition shape against cross-package drift.""" + monkeypatch.setattr( + "core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True + ) + context = _context() + context.snapshot.config_snapshot = _soul_with_drive_skill() + + result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) + + dumped = result.request.model_dump(mode="json") + layer_names = [layer["name"] for layer in dumped["composition"]["layers"]] + assert "drive" in layer_names + # injected right after execution_context, before history/llm + assert layer_names.index("drive") == layer_names.index("execution_context") + 1 + drive = next(layer for layer in dumped["composition"]["layers"] if layer["name"] == "drive") + assert drive["type"] == "dify.drive" + assert drive["config"]["drive_ref"] == "agent-agent-1" + assert drive["config"]["skills"] == [ + { + "name": "Tender Analyzer", + "description": "Parses RFPs.", + "skill_md_key": "tender-analyzer/SKILL.md", + "archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip", + } + ] + assert drive["config"]["files"] == [ + {"name": "sample.pdf", "key": "files/sample.pdf", "size": None, "mime_type": "application/pdf"} + ] + # the dangling legacy ref degraded to a warning instead of failing the run + warnings = result.metadata["runtime_support"]["unsupported_runtime_warnings"] + assert any(w["code"] == "skill_ref_dangling" for w in warnings) + # the drive layer is non-sensitive and must survive into persistable specs + from dify_agent.protocol import extract_runtime_layer_specs + + specs = extract_runtime_layer_specs(result.request.composition) + assert any(spec.name == "drive" and spec.type == "dify.drive" for spec in specs) + + +def test_workflow_run_request_has_no_drive_layer_when_flag_disabled(): + context = _context() + context.snapshot.config_snapshot = _soul_with_drive_skill() + + result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) + + dumped = result.request.model_dump(mode="json") + assert all(layer["name"] != "drive" for layer in dumped["composition"]["layers"]) + warnings = result.metadata["runtime_support"]["unsupported_runtime_warnings"] + assert any(w["code"] == "drive_manifest_disabled" for w in warnings) + + +def test_build_drive_layer_config_all_refs_dangling_yields_no_config(): + from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config + + soul = AgentSoulConfig( + model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"), + skills_files={"skills": [{"id": "legacy", "name": "Legacy"}], "files": [{"name": "u.pdf", "file_id": "u1"}]}, + ) + config, warnings = build_drive_layer_config(soul, agent_id="agent-1") + assert config is None + assert [w["code"] for w in warnings] == ["skill_ref_dangling"] diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index b29aeccc8a..77d5049fec 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -14,7 +14,7 @@ from models.agent import ( WorkflowAgentBindingType, WorkflowAgentNodeBinding, ) -from models.agent_config_entities import WorkflowNodeJobConfig +from models.agent_config_entities import AgentFileRefConfig, WorkflowNodeJobConfig from models.workflow import Workflow from services.agent import composer_service, roster_service from services.agent.agent_soul_state import agent_soul_has_model @@ -1233,3 +1233,334 @@ def test_workspace_dify_tools_returns_provider_and_tool_granularities(monkeypatc } assert [entry["id"] for entry in entries[1:]] == ["duckduckgo/ddg_search", "duckduckgo/ddg_news"] assert {entry["granularity"] for entry in entries[1:]} == {"tool"} + + +# ── ENG-623 §4.4: drive-backed ref validation ──────────────────────────────── + + +def _drive_soul(**overrides): + from services.entities.agent_entities import AgentSoulConfig + + base = { + "skills_files": { + "skills": [ + {"id": "sk-1", "name": "Tender Analyzer", "skill_md_key": "tender-analyzer/SKILL.md"}, + ], + "files": [{"name": "sample.pdf", "drive_key": "files/sample.pdf"}], + }, + } + base.update(overrides) + return AgentSoulConfig.model_validate(base) + + +def _patch_drive_keys(monkeypatch, existing_keys): + import services.agent.composer_service as composer_service_module + + captured: dict[str, object] = {} + + def fake_scalars(stmt): + captured["stmt"] = stmt + return list(existing_keys) + + monkeypatch.setattr(composer_service_module.db, "session", type("S", (), {"scalars": staticmethod(fake_scalars)})()) + return captured + + +def test_drive_ref_findings_reports_missing_keys(monkeypatch): + _patch_drive_keys(monkeypatch, existing_keys=["tender-analyzer/SKILL.md"]) + + findings = AgentComposerService._drive_ref_findings( + tenant_id="tenant-1", agent_id="agent-1", agent_soul=_drive_soul() + ) + + assert [(f["code"], f["id"]) for f in findings] == [("file_ref_dangling", "files/sample.pdf")] + assert str(findings[0]["message"]).startswith("file_ref_dangling: ") + + +def test_drive_ref_findings_clean_when_all_keys_exist(monkeypatch): + _patch_drive_keys(monkeypatch, existing_keys=["tender-analyzer/SKILL.md", "files/sample.pdf"]) + + assert ( + AgentComposerService._drive_ref_findings(tenant_id="tenant-1", agent_id="agent-1", agent_soul=_drive_soul()) + == [] + ) + + +def test_drive_ref_findings_skips_refs_without_drive_keys(monkeypatch): + # No drive-backed ref at all -> no DB roundtrip, no findings. + soul = _drive_soul( + skills_files={"skills": [{"id": "legacy", "name": "Legacy"}], "files": [{"name": "u.pdf", "file_id": "u-1"}]} + ) + findings = AgentComposerService._drive_ref_findings(tenant_id="tenant-1", agent_id="agent-1", agent_soul=soul) + assert findings == [] + + +def test_require_drive_refs_resolved_raises_with_stable_code(monkeypatch): + from services.agent.errors import InvalidComposerConfigError + + _patch_drive_keys(monkeypatch, existing_keys=[]) + + with pytest.raises(InvalidComposerConfigError, match="skill_ref_dangling"): + AgentComposerService._require_drive_refs_resolved( + tenant_id="tenant-1", agent_id="agent-1", agent_soul=_drive_soul() + ) + + +def test_collect_validation_findings_appends_drive_findings_with_agent_context(monkeypatch): + from services.entities.agent_entities import ComposerSavePayload + + _patch_drive_keys(monkeypatch, existing_keys=[]) + payload = ComposerSavePayload.model_validate( + { + "variant": "agent_app", + "save_strategy": "save_to_current_version", + "agent_soul": _drive_soul().model_dump(mode="json"), + } + ) + + findings = AgentComposerService.collect_validation_findings( + tenant_id="tenant-1", payload=payload, agent_id="agent-1" + ) + + codes = {w["code"] for w in findings["warnings"]} + assert {"skill_ref_dangling", "file_ref_dangling"} <= codes + # without agent context the drive check is skipped entirely + findings_no_agent = AgentComposerService.collect_validation_findings(tenant_id="tenant-1", payload=payload) + assert all(w["code"] not in {"skill_ref_dangling", "file_ref_dangling"} for w in findings_no_agent["warnings"]) + + +# ── ENG-625 D5: soul-first ref removal ─────────────────────────────────────── + + +def _patch_remove_drive_refs_env(monkeypatch, *, soul_dict): + """Wire the classmethod's collaborators so soul editing + versioning is observable.""" + from types import SimpleNamespace + + import services.agent.composer_service as module + + agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="snap-1", updated_by=None) + snapshot = SimpleNamespace(id="snap-1", tenant_id="tenant-1", agent_id="agent-1", config_snapshot_dict=soul_dict) + committed: dict[str, object] = {} + + fake_session = SimpleNamespace(scalar=lambda stmt: agent, commit=lambda: committed.setdefault("committed", True)) + monkeypatch.setattr(module.db, "session", fake_session) + monkeypatch.setattr(AgentComposerService, "_require_version", classmethod(lambda cls, **kwargs: snapshot)) + + captured: dict[str, object] = {} + + def fake_update(cls, *, current_snapshot, account_id, agent_soul, operation, version_note): + captured["agent_soul"] = agent_soul + captured["version_note"] = version_note + return SimpleNamespace(id="snap-2") + + monkeypatch.setattr(AgentComposerService, "_update_current_version", classmethod(fake_update)) + return agent, captured, committed + + +def test_remove_drive_refs_drops_skill_by_slug_and_versions(monkeypatch): + soul_dict = { + "skills_files": { + "skills": [ + {"id": "sk-1", "name": "Tender Analyzer", "skill_md_key": "tender-analyzer/SKILL.md"}, + {"id": "sk-2", "name": "Other", "skill_md_key": "other-skill/SKILL.md"}, + ], + "files": [], + } + } + agent, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict) + + version_id = AgentComposerService.remove_drive_refs( + tenant_id="tenant-1", agent_id="agent-1", account_id="acc-1", skill_slug="tender-analyzer" + ) + + assert version_id == "snap-2" + assert agent.active_config_snapshot_id == "snap-2" + kept = [s.skill_md_key for s in captured["agent_soul"].skills_files.skills] + assert kept == ["other-skill/SKILL.md"] + assert "Tender Analyzer" in str(captured["version_note"]) + assert committed.get("committed") is True + + +def test_remove_drive_refs_is_noop_when_ref_absent(monkeypatch): + soul_dict = {"skills_files": {"skills": [], "files": []}} + agent, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict) + + assert ( + AgentComposerService.remove_drive_refs( + tenant_id="tenant-1", agent_id="agent-1", account_id="acc-1", file_key="files/none.pdf" + ) + is None + ) + assert "agent_soul" not in captured + assert committed == {} + + +def test_remove_drive_refs_drops_file_by_key(monkeypatch): + soul_dict = { + "skills_files": { + "skills": [], + "files": [ + {"name": "keep.pdf", "drive_key": "files/keep.pdf"}, + {"name": "drop.pdf", "drive_key": "files/drop.pdf"}, + ], + } + } + _, captured, _ = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict) + + version_id = AgentComposerService.remove_drive_refs( + tenant_id="tenant-1", agent_id="agent-1", account_id="acc-1", file_key="files/drop.pdf" + ) + + assert version_id == "snap-2" + assert [f.drive_key for f in captured["agent_soul"].skills_files.files] == ["files/keep.pdf"] + + +def test_add_drive_file_ref_adds_or_replaces_file_and_versions(monkeypatch): + soul_dict = { + "skills_files": { + "skills": [], + "files": [ + {"name": "old.pdf", "drive_key": "files/old.pdf"}, + {"name": "stale.pdf", "drive_key": "files/new.pdf"}, + ], + } + } + agent, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict) + + version_id = AgentComposerService.add_drive_file_ref( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="acc-1", + file_ref=AgentFileRefConfig(name="new.pdf", file_id="uf-1", drive_key="files/new.pdf", type="application/pdf"), + ) + + assert version_id == "snap-2" + assert agent.active_config_snapshot_id == "snap-2" + assert [f.drive_key for f in captured["agent_soul"].skills_files.files] == ["files/old.pdf", "files/new.pdf"] + assert captured["agent_soul"].skills_files.files[-1].name == "new.pdf" + assert "new.pdf" in str(captured["version_note"]) + assert committed.get("committed") is True + + +def test_add_drive_file_ref_syncs_workflow_binding_snapshot(monkeypatch): + binding = SimpleNamespace(agent_id="agent-1", current_snapshot_id="snap-1", updated_by=None) + _patch_remove_drive_refs_env(monkeypatch, soul_dict={"skills_files": {"skills": [], "files": []}}) + monkeypatch.setattr( + AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1")) + ) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding)) + + AgentComposerService.add_drive_file_ref( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="acc-1", + file_ref=AgentFileRefConfig(name="new.pdf", file_id="uf-1", drive_key="files/new.pdf"), + app_id="app-1", + node_id="agent-node-1", + ) + + assert binding.current_snapshot_id == "snap-2" + assert binding.updated_by == "acc-1" + + +def test_remove_drive_refs_requires_exactly_one_scope(): + with pytest.raises(ValueError): + AgentComposerService.remove_drive_refs(tenant_id="t", agent_id="a", account_id="u") + + +# ── ENG-623/625: resolver helpers + save-path drive guard ──────────────────── + + +def test_resolve_bound_agent_id_queries_active_roster_agent(monkeypatch): + from types import SimpleNamespace + + import services.agent.composer_service as module + + monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: "agent-9")) + assert AgentComposerService.resolve_bound_agent_id(tenant_id="t-1", app_id="app-1") == "agent-9" + + +def test_resolve_workflow_node_agent_id_degrades_without_workflow_or_binding(monkeypatch): + from types import SimpleNamespace + + def boom(cls, **kwargs): + raise ValueError("no draft workflow") + + monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", classmethod(boom)) + assert AgentComposerService.resolve_workflow_node_agent_id(tenant_id="t", app_id="a", node_id="n") is None + + monkeypatch.setattr( + AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1")) + ) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: None)) + assert AgentComposerService.resolve_workflow_node_agent_id(tenant_id="t", app_id="a", node_id="n") is None + + monkeypatch.setattr( + AgentComposerService, + "_get_workflow_binding", + classmethod(lambda cls, **kwargs: SimpleNamespace(agent_id="agent-7")), + ) + assert AgentComposerService.resolve_workflow_node_agent_id(tenant_id="t", app_id="a", node_id="n") == "agent-7" + + +def test_remove_drive_refs_returns_none_without_agent_or_snapshot(monkeypatch): + from types import SimpleNamespace + + import services.agent.composer_service as module + + monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: None)) + assert AgentComposerService.remove_drive_refs(tenant_id="t", agent_id="a", account_id="u", skill_slug="s") is None + + agent_without_snapshot = SimpleNamespace(id="a", active_config_snapshot_id=None) + monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: agent_without_snapshot)) + assert AgentComposerService.remove_drive_refs(tenant_id="t", agent_id="a", account_id="u", skill_slug="s") is None + + +def test_save_workflow_composer_guards_drive_refs_for_existing_agent_strategies(monkeypatch): + from types import SimpleNamespace + + from services.entities.agent_entities import ComposerSavePayload + + payload = ComposerSavePayload.model_validate( + { + "variant": "workflow", + "save_strategy": "save_to_current_version", + "agent_soul": _drive_soul().model_dump(mode="json"), + "soul_lock": {"locked": False}, + } + ) + monkeypatch.setattr( + AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1")) + ) + monkeypatch.setattr( + AgentComposerService, + "_get_workflow_binding", + classmethod(lambda cls, **kwargs: SimpleNamespace(agent_id="agent-1")), + ) + guarded: dict[str, str] = {} + + def fake_guard(cls, *, tenant_id, agent_id, agent_soul): + guarded["agent_id"] = agent_id + raise InvalidComposerConfigError("skill_ref_dangling: boom") + + from services.agent.errors import InvalidComposerConfigError + + monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fake_guard)) + + with pytest.raises(InvalidComposerConfigError, match="skill_ref_dangling"): + AgentComposerService.save_workflow_composer( + tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload + ) + assert guarded["agent_id"] == "agent-1" + + +def test_remove_drive_refs_noop_when_skill_slug_unmatched(monkeypatch): + soul_dict = {"skills_files": {"skills": [{"name": "Other", "skill_md_key": "other/SKILL.md"}], "files": []}} + _, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict) + assert ( + AgentComposerService.remove_drive_refs( + tenant_id="t-1", agent_id="agent-1", account_id="acc-1", skill_slug="ghost" + ) + is None + ) + assert committed == {} diff --git a/api/tests/unit_tests/services/agent/test_skill_standardize_service.py b/api/tests/unit_tests/services/agent/test_skill_standardize_service.py index 8a99719dd3..128a6c4280 100644 --- a/api/tests/unit_tests/services/agent/test_skill_standardize_service.py +++ b/api/tests/unit_tests/services/agent/test_skill_standardize_service.py @@ -75,3 +75,6 @@ def test_standardize_creates_two_drive_owned_toolfiles_and_commits(): assert skill["full_archive_file_id"] == "zip-tool-file" assert skill["skill_md_file_id"] == "md-tool-file" assert skill["skill_md_key"] == "pdf-toolkit/SKILL.md" + # ENG-371: zip member listing persisted for infer-tools signals + assert "SKILL.md" in skill["manifest_files"] + assert "scripts/run.py" in skill["manifest_files"] diff --git a/api/tests/unit_tests/services/agent/test_skill_tool_inference_service.py b/api/tests/unit_tests/services/agent/test_skill_tool_inference_service.py new file mode 100644 index 0000000000..cd708127ae --- /dev/null +++ b/api/tests/unit_tests/services/agent/test_skill_tool_inference_service.py @@ -0,0 +1,225 @@ +"""Unit tests for skill → CLI tool inference (ENG-371).""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from services.agent.skill_tool_inference_service import ( + SkillToolInferenceError, + SkillToolInferenceService, +) +from services.agent_drive_service import AgentDriveError + +_MOD = "services.agent.skill_tool_inference_service" + +_SKILL_MD_PREVIEW = { + "key": "audio-transcribe/SKILL.md", + "size": 100, + "truncated": False, + "binary": False, + "text": "# Audio Transcribe\nStep 2 runs ffmpeg, step 3 calls the whisper API.", +} + + +def _service(preview=_SKILL_MD_PREVIEW): + drive = MagicMock() + drive.preview.return_value = preview + return SkillToolInferenceService(drive_service=drive), drive + + +def _patch_soul_files(monkeypatch, files): + monkeypatch.setattr(SkillToolInferenceService, "_manifest_files_from_soul", staticmethod(lambda **kwargs: files)) + + +def test_infer_returns_suggestions_with_inferred_from(monkeypatch): + service, drive = _service() + _patch_soul_files(monkeypatch, ["SKILL.md", "scripts/transcribe.sh"]) + raw = ( + '{"inferable": true, "reason": null, "cli_tools": [{"name": "ffmpeg",' + ' "description": "transcoding for step 2", "command": "ffmpeg",' + ' "install_commands": ["apt-get install -y ffmpeg"],' + ' "env_suggestions": [{"key": "OPENAI_API_KEY", "reason": "whisper call", "secret_likely": true}]}]}' + ) + with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)): + result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe") + + assert result["inferable"] is True + tool = result["cli_tools"][0] + assert tool["name"] == "ffmpeg" + assert tool["inferred_from"] == "audio-transcribe" + assert tool["env_suggestions"] == [{"key": "OPENAI_API_KEY", "reason": "whisper call", "secret_likely": True}] + drive.preview.assert_called_once_with(tenant_id="t-1", agent_id="a-1", key="audio-transcribe/SKILL.md") + + +def test_infer_threads_manifest_files_into_the_prompt(monkeypatch): + service, _ = _service() + _patch_soul_files(monkeypatch, ["scripts/run.sh"]) + captured: dict[str, str] = {} + + def fake_invoke(*, tenant_id, user_prompt): + captured["prompt"] = user_prompt + return '{"inferable": false, "cli_tools": [], "reason": "none"}' + + with patch.object(SkillToolInferenceService, "_invoke", staticmethod(fake_invoke)): + service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe") + + assert "scripts/run.sh" in captured["prompt"] + assert "ffmpeg" in captured["prompt"] # SKILL.md body present + + +def test_infer_not_inferable_passes_reason_through(monkeypatch): + service, _ = _service() + _patch_soul_files(monkeypatch, []) + raw = '{"inferable": false, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"}' + with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)): + result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe") + assert result == {"inferable": False, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"} + + +def test_infer_retries_once_then_422(monkeypatch): + service, _ = _service() + _patch_soul_files(monkeypatch, []) + calls: list[int] = [] + + def bad_invoke(**kwargs): + calls.append(1) + return "not json at all ][" + + with patch.object(SkillToolInferenceService, "_invoke", staticmethod(bad_invoke)): + with pytest.raises(SkillToolInferenceError) as exc_info: + service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe") + + assert len(calls) == 2 # one retry + assert exc_info.value.code == "inference_failed" + assert exc_info.value.status_code == 422 + + +def test_infer_repairs_slightly_malformed_json(monkeypatch): + service, _ = _service() + _patch_soul_files(monkeypatch, []) + raw = 'Here you go: {"inferable": true, "cli_tools": [], "reason": null,}' + with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)): + result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe") + assert result["inferable"] is True + + +def test_missing_skill_maps_to_404(): + drive = MagicMock() + drive.preview.side_effect = AgentDriveError("drive_key_not_found", "nope", status_code=404) + service = SkillToolInferenceService(drive_service=drive) + + with pytest.raises(SkillToolInferenceError) as exc_info: + service.infer(tenant_id="t-1", agent_id="a-1", slug="ghost") + assert exc_info.value.code == "skill_not_found" + assert exc_info.value.status_code == 404 + + +def test_binary_skill_md_maps_to_404(): + service, _ = _service(preview={"key": "x/SKILL.md", "size": 1, "truncated": False, "binary": True, "text": None}) + with pytest.raises(SkillToolInferenceError) as exc_info: + service.infer(tenant_id="t-1", agent_id="a-1", slug="x") + assert exc_info.value.code == "skill_not_found" + + +# ── real-path coverage: _invoke / _manifest_files_from_soul / passthrough ──── + + +def test_invoke_maps_missing_default_model_to_400(monkeypatch): + import services.agent.skill_tool_inference_service as module + from core.errors.error import ProviderTokenNotInitError + + fake_manager = MagicMock() + fake_manager.get_default_model_instance.side_effect = ProviderTokenNotInitError("no default") + monkeypatch.setattr(module.ModelManager, "for_tenant", classmethod(lambda cls, tenant_id: fake_manager)) + + with pytest.raises(SkillToolInferenceError) as exc_info: + SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x") + assert exc_info.value.code == "default_model_not_configured" + assert exc_info.value.status_code == 400 + + +def test_invoke_maps_model_failure_to_422_and_success_returns_text(monkeypatch): + import services.agent.skill_tool_inference_service as module + + fake_manager = MagicMock() + fake_instance = MagicMock() + fake_manager.get_default_model_instance.return_value = fake_instance + monkeypatch.setattr(module.ModelManager, "for_tenant", classmethod(lambda cls, tenant_id: fake_manager)) + + fake_instance.invoke_llm.side_effect = RuntimeError("provider down") + with pytest.raises(SkillToolInferenceError) as exc_info: + SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x") + assert exc_info.value.code == "inference_failed" + assert exc_info.value.status_code == 422 + + fake_instance.invoke_llm.side_effect = None + fake_instance.invoke_llm.return_value.message.get_text_content.return_value = '{"inferable": false}' + raw = SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x") + assert raw == '{"inferable": false}' + call = fake_instance.invoke_llm.call_args.kwargs + assert call["model_parameters"] == {"temperature": 0.1} + assert call["stream"] is False + + +def test_load_skill_md_passes_through_non_missing_drive_errors(): + drive = MagicMock() + drive.preview.side_effect = AgentDriveError("agent_not_found", "tenant mismatch", status_code=404) + service = SkillToolInferenceService(drive_service=drive) + + with pytest.raises(SkillToolInferenceError) as exc_info: + service.infer(tenant_id="t-1", agent_id="a-1", slug="x") + assert exc_info.value.code == "agent_not_found" + + +def _patch_inference_db(monkeypatch, *, agent, snapshot): + from types import SimpleNamespace + + import services.agent.skill_tool_inference_service as module + + results = iter([agent, snapshot]) + monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: next(results))) + + +def test_manifest_files_from_soul_reads_active_snapshot(monkeypatch): + from types import SimpleNamespace + + soul_dict = { + "skills_files": { + "skills": [ + {"name": "Other", "skill_md_key": "other/SKILL.md", "manifest_files": ["x.md"]}, + {"name": "Audio", "skill_md_key": "audio-transcribe/SKILL.md", "manifest_files": ["scripts/a.sh"]}, + ] + } + } + agent = SimpleNamespace(active_config_snapshot_id="snap-1") + snapshot = SimpleNamespace(config_snapshot_dict=soul_dict) + _patch_inference_db(monkeypatch, agent=agent, snapshot=snapshot) + + files = SkillToolInferenceService._manifest_files_from_soul( + tenant_id="t-1", agent_id="a-1", slug="audio-transcribe" + ) + assert files == ["scripts/a.sh"] + + +def test_manifest_files_from_soul_degrades_when_agent_or_snapshot_missing(monkeypatch): + _patch_inference_db(monkeypatch, agent=None, snapshot=None) + assert SkillToolInferenceService._manifest_files_from_soul(tenant_id="t", agent_id="a", slug="s") == [] + + from types import SimpleNamespace + + _patch_inference_db(monkeypatch, agent=SimpleNamespace(active_config_snapshot_id="snap-1"), snapshot=None) + assert SkillToolInferenceService._manifest_files_from_soul(tenant_id="t", agent_id="a", slug="s") == [] + + +def test_manifest_files_from_soul_empty_when_slug_not_in_soul(monkeypatch): + from types import SimpleNamespace + + soul_dict = {"skills_files": {"skills": [{"name": "Other", "skill_md_key": "other/SKILL.md"}]}} + _patch_inference_db( + monkeypatch, + agent=SimpleNamespace(active_config_snapshot_id="snap-1"), + snapshot=SimpleNamespace(config_snapshot_dict=soul_dict), + ) + assert SkillToolInferenceService._manifest_files_from_soul(tenant_id="t", agent_id="a", slug="ghost") == [] diff --git a/api/tests/unit_tests/services/test_agent_drive_service.py b/api/tests/unit_tests/services/test_agent_drive_service.py index 3ff9726668..9f8170bfd1 100644 --- a/api/tests/unit_tests/services/test_agent_drive_service.py +++ b/api/tests/unit_tests/services/test_agent_drive_service.py @@ -337,3 +337,166 @@ def test_manifest_download_url_none_when_unresolvable(): ): items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, include_download_url=True) assert items[0]["download_url"] is None + + +# ── ENG-625 D5: delete ──────────────────────────────────────────────────────── + + +def test_delete_by_key_cleans_drive_owned_value(): + tf = _seed_tool_file(name="doomed.txt") + _commit("files/doomed.txt", tf, owned=True) + + with patch("services.agent_drive_service.storage") as storage_mock: + removed = AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, key="files/doomed.txt") + storage_mock.delete.assert_called_once() + + assert removed == ["files/doomed.txt"] + with session_factory.create_session() as session: + assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is None + assert list(session.scalars(select(AgentDriveFile))) == [] + + +def test_delete_by_prefix_removes_all_skill_keys(): + md = _seed_tool_file(name="SKILL.md") + zf = _seed_tool_file(name="full.zip") + _commit("tender-analyzer/SKILL.md", md, owned=True) + _commit("tender-analyzer/.DIFY-SKILL-FULL.zip", zf, owned=True) + other = _seed_tool_file(name="other.txt") + _commit("files/other.txt", other, owned=True) + + with patch("services.agent_drive_service.storage"): + removed = AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, prefix="tender-analyzer/") + + assert sorted(removed) == ["tender-analyzer/.DIFY-SKILL-FULL.zip", "tender-analyzer/SKILL.md"] + with session_factory.create_session() as session: + # both skill ToolFiles physically removed, the unrelated file untouched + assert session.scalar(select(ToolFile).where(ToolFile.id == md)) is None + assert session.scalar(select(ToolFile).where(ToolFile.id == zf)) is None + assert session.scalar(select(ToolFile).where(ToolFile.id == other)) is not None + keys = [row.key for row in session.scalars(select(AgentDriveFile))] + assert keys == ["files/other.txt"] + + +def test_delete_is_idempotent(): + assert AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, key="files/never-there.txt") == [] + assert AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, prefix="ghost-skill/") == [] + + +def test_delete_requires_exactly_one_scope(): + with pytest.raises(AgentDriveError) as exc_info: + AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT) + assert exc_info.value.code == "invalid_delete_scope" + with pytest.raises(AgentDriveError): + AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, prefix="a/", key="a/b") + + +def test_delete_keeps_shared_value_records(): + tf = _seed_tool_file(name="shared.txt") + _commit("files/shared.txt", tf, owned=False) + + with patch("services.agent_drive_service.storage") as storage_mock: + removed = AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, key="files/shared.txt") + storage_mock.delete.assert_not_called() + + assert removed == ["files/shared.txt"] + with session_factory.create_session() as session: + # only the KV row dropped; the shared ToolFile survives + assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is not None + + +def test_restandardize_same_slug_overwrites_both_keys_and_cleans_old_toolfiles(): + """ENG-625 §5.3 replacement semantics: re-standardizing a same-name skill + overwrites /SKILL.md and /.DIFY-SKILL-FULL.zip, physically + cleaning both old drive-owned ToolFiles.""" + old_md = _seed_tool_file(name="SKILL.md") + old_zip = _seed_tool_file(name="full-v1.zip") + _commit("pdf-toolkit/SKILL.md", old_md, owned=True) + _commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", old_zip, owned=True) + + new_md = _seed_tool_file(name="SKILL-v2.md") + new_zip = _seed_tool_file(name="full-v2.zip") + with patch("services.agent_drive_service.storage") as storage_mock: + _commit("pdf-toolkit/SKILL.md", new_md, owned=True) + _commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", new_zip, owned=True) + assert storage_mock.delete.call_count == 2 + + with session_factory.create_session() as session: + assert session.scalar(select(ToolFile).where(ToolFile.id == old_md)) is None + assert session.scalar(select(ToolFile).where(ToolFile.id == old_zip)) is None + rows = {row.key: row.file_id for row in session.scalars(select(AgentDriveFile))} + assert rows == { + "pdf-toolkit/SKILL.md": new_md, + "pdf-toolkit/.DIFY-SKILL-FULL.zip": new_zip, + } + + +# ── ENG-624: console drive inspector (service layer) ───────────────────────── + + +def test_preview_returns_text_with_truncation_flags(): + tf = _seed_tool_file(name="SKILL.md") + _commit("pdf-toolkit/SKILL.md", tf) + + with patch("services.agent_drive_service.storage") as storage_mock: + storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\nUse responsibly.\n"]) + result = AgentDriveService().preview(tenant_id=TENANT, agent_id=AGENT, key="pdf-toolkit/SKILL.md") + + assert result == { + "key": "pdf-toolkit/SKILL.md", + "size": 5, + "truncated": False, + "binary": False, + "text": "# PDF Toolkit\nUse responsibly.\n", + } + + +def test_preview_marks_binary_and_oversized_content(): + tf = _seed_tool_file(name="blob.bin") + _commit("files/blob.bin", tf) + + with patch("services.agent_drive_service.storage") as storage_mock: + storage_mock.load_stream.return_value = iter([b"\x00\x01\x02"]) + binary = AgentDriveService().preview(tenant_id=TENANT, agent_id=AGENT, key="files/blob.bin") + assert binary["binary"] is True + assert binary["text"] is None + + with patch("services.agent_drive_service.storage") as storage_mock: + storage_mock.load_stream.return_value = iter([b"x" * (AgentDriveService.PREVIEW_MAX_BYTES + 10)]) + oversized = AgentDriveService().preview(tenant_id=TENANT, agent_id=AGENT, key="files/blob.bin") + assert oversized["truncated"] is True + assert oversized["binary"] is False + assert len(oversized["text"]) == AgentDriveService.PREVIEW_MAX_BYTES + + +def test_preview_unknown_key_is_404(): + with pytest.raises(AgentDriveError) as exc_info: + AgentDriveService().preview(tenant_id=TENANT, agent_id=AGENT, key="ghost/SKILL.md") + assert exc_info.value.code == "drive_key_not_found" + assert exc_info.value.status_code == 404 + + +def test_preview_rejects_cross_tenant_agent(): + with pytest.raises(AgentDriveError) as exc_info: + AgentDriveService().preview( + tenant_id="99999999-9999-9999-9999-999999999999", agent_id=AGENT, key="pdf-toolkit/SKILL.md" + ) + assert exc_info.value.code == "agent_not_found" + + +def test_download_url_signs_external_audience(): + tf = _seed_tool_file(name="full.zip") + _commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", tf) + + with patch.object(AgentDriveService, "_resolve_download_url", return_value="https://signed.example/x") as resolver: + url = AgentDriveService().download_url(tenant_id=TENANT, agent_id=AGENT, key="pdf-toolkit/.DIFY-SKILL-FULL.zip") + + assert url == "https://signed.example/x" + # console downloads are for browsers: external signing, never the internal URL + assert resolver.call_args.kwargs["for_external"] is True + + +def test_manifest_items_carry_created_at_for_inspector(): + tf = _seed_tool_file() + _commit("files/x.txt", tf) + items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT) + assert items[0]["created_at"] is None or isinstance(items[0]["created_at"], int) diff --git a/dify-agent/src/dify_agent/layers/drive/__init__.py b/dify-agent/src/dify_agent/layers/drive/__init__.py new file mode 100644 index 0000000000..38ef6e6666 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/drive/__init__.py @@ -0,0 +1,19 @@ +"""Client-safe exports for the Dify drive declaration layer DTOs. + +The layer implementation lives in the sibling ``layer`` module. Keep this +package root import-safe for client code that only builds run requests. +""" + +from dify_agent.layers.drive.configs import ( + DIFY_DRIVE_LAYER_TYPE_ID, + DifyDriveFileConfig, + DifyDriveLayerConfig, + DifyDriveSkillConfig, +) + +__all__ = [ + "DIFY_DRIVE_LAYER_TYPE_ID", + "DifyDriveFileConfig", + "DifyDriveLayerConfig", + "DifyDriveSkillConfig", +] diff --git a/dify-agent/src/dify_agent/layers/drive/configs.py b/dify-agent/src/dify_agent/layers/drive/configs.py new file mode 100644 index 0000000000..d07dbcb7cf --- /dev/null +++ b/dify-agent/src/dify_agent/layers/drive/configs.py @@ -0,0 +1,67 @@ +"""Client-safe DTOs for the Dify drive declaration layer. + +The drive layer is a config-only manifest of the Skills & Files an agent has +in its drive. It is an index, never the content: each entry carries only a +display name, a model-facing description, and the drive key needed to fetch +the real bytes through the back proxy (``GET /inner/api/drive// +manifest`` → internal download URL). Inlining SKILL.md bodies here would break +the PRD's dynamic-loading principle and bloat every run request. + +The API backend catalogs and writes this config; the Agent backend consumes it +(ENG-387: pull via back proxy, lazy-load SKILL.md, materialize files). +""" + +from typing import Final + +from pydantic import BaseModel, ConfigDict, Field + +from agenton.layers import LayerConfig + + +DIFY_DRIVE_LAYER_TYPE_ID: Final[str] = "dify.drive" + + +class DifyDriveSkillConfig(BaseModel): + """Runtime declaration of one standardized skill — an index, not content.""" + + model_config = ConfigDict(extra="forbid") + + name: str + # The model judges from this description whether the skill is worth loading. + description: str + # "/SKILL.md" — the canonical entry document in the drive. + skill_md_key: str + # "/.DIFY-SKILL-FULL.zip" — full archive for restoring the complete skill. + archive_key: str | None = None + + +class DifyDriveFileConfig(BaseModel): + """Runtime declaration of one plain drive file.""" + + model_config = ConfigDict(extra="forbid") + + name: str + # "files/" — the drive key of the file value. + key: str + size: int | None = None + mime_type: str | None = None + + +class DifyDriveLayerConfig(LayerConfig): + """Config-only declaration layer: API writes the catalog, the agent pulls + the listed entries through the back proxy using ``drive_ref``.""" + + # "agent-" — storage addressing, deliberately explicit instead of + # derived from execution context so a shared (non-agent-bound) drive stays + # possible later. + drive_ref: str + skills: list[DifyDriveSkillConfig] = Field(default_factory=list) + files: list[DifyDriveFileConfig] = Field(default_factory=list) + + +__all__ = [ + "DIFY_DRIVE_LAYER_TYPE_ID", + "DifyDriveFileConfig", + "DifyDriveLayerConfig", + "DifyDriveSkillConfig", +] diff --git a/dify-agent/src/dify_agent/layers/drive/layer.py b/dify-agent/src/dify_agent/layers/drive/layer.py new file mode 100644 index 0000000000..3d5efb23d4 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/drive/layer.py @@ -0,0 +1,34 @@ +"""Inert Dify drive declaration layer. + +Registering this layer makes ``dify.drive`` a known composition type id so a +run that carries the declaration never fails as "unknown layer type", even +before the consumption work (ENG-387) lands. It deliberately contributes no +prompt and no tools: a model that can see skill names but cannot read SKILL.md +would only hallucinate. The skills prompt (including the "pull SKILL.md via +drive" guidance) ships together with the consumption implementation. +""" + +from dataclasses import dataclass +from typing import ClassVar + +from typing_extensions import Self, override + +from agenton.layers import EmptyRuntimeState, NoLayerDeps, PlainLayer +from dify_agent.layers.drive.configs import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig + + +@dataclass(slots=True) +class DifyDriveLayer(PlainLayer[NoLayerDeps, DifyDriveLayerConfig, EmptyRuntimeState]): + """Config-only carrier of the drive Skills & Files manifest.""" + + type_id: ClassVar[str] = DIFY_DRIVE_LAYER_TYPE_ID + + config: DifyDriveLayerConfig + + @classmethod + @override + def from_config(cls, config: DifyDriveLayerConfig) -> Self: + return cls(config=config) + + +__all__ = ["DifyDriveLayer"] diff --git a/dify-agent/src/dify_agent/runtime/compositor_factory.py b/dify-agent/src/dify_agent/runtime/compositor_factory.py index 959a1329ac..81bfcd48e2 100644 --- a/dify-agent/src/dify_agent/runtime/compositor_factory.py +++ b/dify-agent/src/dify_agent/runtime/compositor_factory.py @@ -6,6 +6,7 @@ state-free Dify structured output layer, the optional Dify ask-human layer, the Dify execution-context layer, the stateful Dify shell layer, and the Dify plugin business-layer family: +- ``dify.drive`` for the inert Skills & Files drive declaration, - ``dify.execution_context`` for shared tenant/user/run daemon context, - ``dify.shell`` for shellctl-backed shell job control, - ``dify.plugin.llm`` for plugin-backed model selection, and @@ -37,6 +38,7 @@ from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec from dify_agent.layers.ask_human.layer import DifyAskHumanLayer from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer +from dify_agent.layers.drive.layer import DifyDriveLayer from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer from dify_agent.layers.output.output_layer import DifyOutputLayer @@ -83,6 +85,10 @@ def create_default_layer_providers( LayerProvider.from_layer_type(PydanticAIHistoryLayer), LayerProvider.from_layer_type(DifyOutputLayer), LayerProvider.from_layer_type(DifyAskHumanLayer), + # Inert declaration layer: makes ``dify.drive`` a known type id so runs + # carrying the Skills & Files manifest never fail before the consumption + # work (ENG-387) lands. Deliberately contributes no prompt and no tools. + LayerProvider.from_layer_type(DifyDriveLayer), LayerProvider.from_factory( layer_type=DifyExecutionContextLayer, create=lambda config: DifyExecutionContextLayer.from_config_with_settings( diff --git a/dify-agent/tests/local/dify_agent/layers/drive/__init__.py b/dify-agent/tests/local/dify_agent/layers/drive/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/dify-agent/tests/local/dify_agent/layers/drive/test_configs.py b/dify-agent/tests/local/dify_agent/layers/drive/test_configs.py new file mode 100644 index 0000000000..3052827c5b --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/drive/test_configs.py @@ -0,0 +1,58 @@ +"""Contract tests for the dify.drive declaration layer (ENG-623).""" + +import pytest +from pydantic import ValidationError + +from dify_agent.layers.drive import ( + DIFY_DRIVE_LAYER_TYPE_ID, + DifyDriveFileConfig, + DifyDriveLayerConfig, + DifyDriveSkillConfig, +) +from dify_agent.layers.drive.layer import DifyDriveLayer +from dify_agent.runtime.compositor_factory import create_default_layer_providers + + +def test_type_id_is_frozen_contract() -> None: + assert DIFY_DRIVE_LAYER_TYPE_ID == "dify.drive" + assert DifyDriveLayer.type_id == DIFY_DRIVE_LAYER_TYPE_ID + + +def test_layer_config_round_trips_manifest_entries() -> None: + config = DifyDriveLayerConfig.model_validate( + { + "drive_ref": "agent-019e9112", + "skills": [ + { + "name": "Tender Analyzer", + "description": "Parses RFP documents step by step.", + "skill_md_key": "tender-analyzer/SKILL.md", + "archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip", + } + ], + "files": [{"name": "sample.pdf", "key": "files/sample.pdf", "size": 1024, "mime_type": "application/pdf"}], + } + ) + + dumped = config.model_dump(mode="json") + assert dumped["drive_ref"] == "agent-019e9112" + assert dumped["skills"][0]["skill_md_key"] == "tender-analyzer/SKILL.md" + assert dumped["files"][0]["key"] == "files/sample.pdf" + # the declaration is an index only — there is no field that could carry file content + assert "content" not in DifyDriveSkillConfig.model_fields + assert "content" not in DifyDriveFileConfig.model_fields + + +def test_layer_config_rejects_unknown_fields() -> None: + with pytest.raises(ValidationError): + DifyDriveLayerConfig.model_validate({"drive_ref": "agent-1", "skill_md_body": "# inline content"}) + + +def test_inert_layer_is_registered_and_constructible_from_config() -> None: + providers = create_default_layer_providers() + provider = next(p for p in providers if p.type_id == DIFY_DRIVE_LAYER_TYPE_ID) + + layer = provider.create_layer({"drive_ref": "agent-1", "skills": [], "files": []}) + + assert isinstance(layer, DifyDriveLayer) + assert layer.config.drive_ref == "agent-1" diff --git a/dify-agent/tests/local/dify_agent/test_client_safe_exports.py b/dify-agent/tests/local/dify_agent/test_client_safe_exports.py index 4c64e6209a..30f430521a 100644 --- a/dify-agent/tests/local/dify_agent/test_client_safe_exports.py +++ b/dify-agent/tests/local/dify_agent/test_client_safe_exports.py @@ -72,6 +72,7 @@ def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Pat agent_stub_protocol_module = importlib.import_module("dify_agent.agent_stub.protocol") agent_stub_cli_main_module = importlib.import_module("dify_agent.agent_stub.cli.main") shell_module = importlib.import_module("dify_agent.layers.shell") + drive_module = importlib.import_module("dify_agent.layers.drive") execution_context_module = importlib.import_module("dify_agent.layers.execution_context") plugin_module = importlib.import_module("dify_agent.layers.dify_plugin") ask_human_module = importlib.import_module("dify_agent.layers.ask_human") @@ -91,6 +92,7 @@ def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Pat assert agent_stub_protocol_module.AgentStubConnectRequest is not None assert agent_stub_cli_main_module.main is not None assert shell_module.DifyShellLayerConfig is not None + assert drive_module.DifyDriveLayerConfig is not None assert execution_context_module.DifyExecutionContextLayerConfig is not None assert plugin_module.DifyPluginLLMLayerConfig is not None assert ask_human_module.DifyAskHumanLayerConfig is not None diff --git a/dify-agent/tests/local/dify_agent/test_import_boundaries.py b/dify-agent/tests/local/dify_agent/test_import_boundaries.py index b1e0207873..0ac1d77615 100644 --- a/dify-agent/tests/local/dify_agent/test_import_boundaries.py +++ b/dify-agent/tests/local/dify_agent/test_import_boundaries.py @@ -79,6 +79,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> blocked_imports=[ "anthropic", "dify_agent.adapters.llm", + "dify_agent.layers.drive.layer", "dify_agent.layers.execution_context.layer", "dify_agent.layers.ask_human.layer", "dify_agent.layers.dify_plugin.llm_layer", @@ -98,6 +99,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> ], imports=[ "dify_agent.protocol", + "dify_agent.layers.drive", "dify_agent.layers.execution_context", "dify_agent.layers.ask_human", "dify_agent.layers.dify_plugin", @@ -106,6 +108,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> ], assertions=[ "assert hasattr(dify_agent_protocol, 'PydanticAIStreamRunEvent')", + "assert dify_agent_layers_drive.__all__ == ['DIFY_DRIVE_LAYER_TYPE_ID', 'DifyDriveFileConfig', 'DifyDriveLayerConfig', 'DifyDriveSkillConfig']", "assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextAgentMode', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig', 'DifyExecutionContextUserFrom']", "assert dify_agent_layers_ask_human.__all__ == ['AskHumanAction', 'AskHumanActionStyle', 'AskHumanField', 'AskHumanFieldType', 'AskHumanFileField', 'AskHumanFileListField', 'AskHumanParagraphField', 'AskHumanResultStatus', 'AskHumanSelectField', 'AskHumanSelectOption', 'AskHumanSelectedAction', 'AskHumanToolArgs', 'AskHumanToolResult', 'AskHumanUrgency', 'DEFAULT_ASK_HUMAN_TOOL_DESCRIPTION', 'DIFY_ASK_HUMAN_LAYER_TYPE_ID', 'DifyAskHumanLayerConfig']", "assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginToolCredentialType', 'DifyPluginToolConfig', 'DifyPluginToolOption', 'DifyPluginToolParameter', 'DifyPluginToolParameterForm', 'DifyPluginToolParameterType', 'DifyPluginToolsLayerConfig', 'DifyPluginToolValue']", diff --git a/packages/contracts/generated/api/console/agents/types.gen.ts b/packages/contracts/generated/api/console/agents/types.gen.ts index 620a91cd02..b40c31bb6a 100644 --- a/packages/contracts/generated/api/console/agents/types.gen.ts +++ b/packages/contracts/generated/api/console/agents/types.gen.ts @@ -389,6 +389,7 @@ export type AgentSandboxProviderConfig = { } export type AgentFileRefConfig = { + drive_key?: string | null file_id?: string | null id?: string | null name?: string | null @@ -405,9 +406,14 @@ export type AgentFileRefConfig = { export type AgentSkillRefConfig = { description?: string | null file_id?: string | null + full_archive_file_id?: string | null + full_archive_key?: string | null id?: string | null + manifest_files?: Array | null name?: string | null path?: string | null + skill_md_file_id?: string | null + skill_md_key?: string | null [key: string]: unknown } @@ -423,6 +429,7 @@ export type AgentCliToolConfig = { enabled?: boolean env?: AgentCliToolEnvConfig id?: string | null + inferred_from?: string | null install?: string | null install_command?: string | null install_commands?: Array diff --git a/packages/contracts/generated/api/console/agents/zod.gen.ts b/packages/contracts/generated/api/console/agents/zod.gen.ts index e1bfc0a021..d3fdf018c3 100644 --- a/packages/contracts/generated/api/console/agents/zod.gen.ts +++ b/packages/contracts/generated/api/console/agents/zod.gen.ts @@ -386,6 +386,7 @@ export const zAgentSoulSandboxConfig = z.object({ * AgentFileRefConfig */ export const zAgentFileRefConfig = z.object({ + drive_key: z.string().max(512).nullish(), file_id: z.string().max(255).nullish(), id: z.string().max(255).nullish(), name: z.string().max(255).nullish(), @@ -404,9 +405,14 @@ export const zAgentFileRefConfig = z.object({ export const zAgentSkillRefConfig = z.object({ description: z.string().nullish(), file_id: z.string().max(255).nullish(), + full_archive_file_id: z.string().max(255).nullish(), + full_archive_key: z.string().max(512).nullish(), id: z.string().max(255).nullish(), + manifest_files: z.array(z.string()).nullish(), name: z.string().max(255).nullish(), path: z.string().nullish(), + skill_md_file_id: z.string().max(255).nullish(), + skill_md_key: z.string().max(512).nullish(), }) /** @@ -544,6 +550,7 @@ export const zAgentCliToolConfig = z.object({ enabled: z.boolean().optional().default(true), env: zAgentCliToolEnvConfig.optional(), id: z.string().max(255).nullish(), + inferred_from: z.string().max(255).nullish(), install: z.string().nullish(), install_command: z.string().nullish(), install_commands: z.array(z.string()).optional(), diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index fec93596c7..0d4d0ab775 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -4,6 +4,12 @@ import { oc } from '@orpc/contract' import * as z from 'zod' import { + zDeleteAppsByAppIdAgentFilesPath, + zDeleteAppsByAppIdAgentFilesQuery, + zDeleteAppsByAppIdAgentFilesResponse, + zDeleteAppsByAppIdAgentSkillsBySlugPath, + zDeleteAppsByAppIdAgentSkillsBySlugQuery, + zDeleteAppsByAppIdAgentSkillsBySlugResponse, zDeleteAppsByAppIdAnnotationsByAnnotationIdPath, zDeleteAppsByAppIdAnnotationsByAnnotationIdResponse, zDeleteAppsByAppIdAnnotationsPath, @@ -41,6 +47,15 @@ import { zGetAppsByAppIdAgentComposerCandidatesResponse, zGetAppsByAppIdAgentComposerPath, zGetAppsByAppIdAgentComposerResponse, + zGetAppsByAppIdAgentDriveFilesDownloadPath, + zGetAppsByAppIdAgentDriveFilesDownloadQuery, + zGetAppsByAppIdAgentDriveFilesDownloadResponse, + zGetAppsByAppIdAgentDriveFilesPath, + zGetAppsByAppIdAgentDriveFilesPreviewPath, + zGetAppsByAppIdAgentDriveFilesPreviewQuery, + zGetAppsByAppIdAgentDriveFilesPreviewResponse, + zGetAppsByAppIdAgentDriveFilesQuery, + zGetAppsByAppIdAgentDriveFilesResponse, zGetAppsByAppIdAgentLogsPath, zGetAppsByAppIdAgentLogsQuery, zGetAppsByAppIdAgentLogsResponse, @@ -263,10 +278,18 @@ import { zPostAppsByAppIdAgentFeaturesBody, zPostAppsByAppIdAgentFeaturesPath, zPostAppsByAppIdAgentFeaturesResponse, + zPostAppsByAppIdAgentFilesBody, + zPostAppsByAppIdAgentFilesPath, + zPostAppsByAppIdAgentFilesQuery, + zPostAppsByAppIdAgentFilesResponse, zPostAppsByAppIdAgentSandboxFilesUploadBody, zPostAppsByAppIdAgentSandboxFilesUploadPath, zPostAppsByAppIdAgentSandboxFilesUploadResponse, + zPostAppsByAppIdAgentSkillsBySlugInferToolsPath, + zPostAppsByAppIdAgentSkillsBySlugInferToolsQuery, + zPostAppsByAppIdAgentSkillsBySlugInferToolsResponse, zPostAppsByAppIdAgentSkillsStandardizePath, + zPostAppsByAppIdAgentSkillsStandardizeQuery, zPostAppsByAppIdAgentSkillsStandardizeResponse, zPostAppsByAppIdAgentSkillsUploadPath, zPostAppsByAppIdAgentSkillsUploadResponse, @@ -950,12 +973,141 @@ export const agentSandbox = { files, } +/** + * Time-limited external signed URL for one drive value (no streaming proxy) + */ +export const get9 = oc + .route({ + description: 'Time-limited external signed URL for one drive value (no streaming proxy)', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentDriveFilesDownload', + path: '/apps/{app_id}/agent/drive/files/download', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAppsByAppIdAgentDriveFilesDownloadPath, + query: zGetAppsByAppIdAgentDriveFilesDownloadQuery, + }), + ) + .output(zGetAppsByAppIdAgentDriveFilesDownloadResponse) + +export const download = { + get: get9, +} + +/** + * Truncated text preview of one drive value (binary-safe; SKILL.md is the main case) + */ +export const get10 = oc + .route({ + description: + 'Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentDriveFilesPreview', + path: '/apps/{app_id}/agent/drive/files/preview', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAppsByAppIdAgentDriveFilesPreviewPath, + query: zGetAppsByAppIdAgentDriveFilesPreviewQuery, + }), + ) + .output(zGetAppsByAppIdAgentDriveFilesPreviewResponse) + +export const preview2 = { + get: get10, +} + +/** + * List agent drive entries (read-only inspector; one endpoint for both tabs) + */ +export const get11 = oc + .route({ + description: 'List agent drive entries (read-only inspector; one endpoint for both tabs)', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdAgentDriveFiles', + path: '/apps/{app_id}/agent/drive/files', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAppsByAppIdAgentDriveFilesPath, + query: zGetAppsByAppIdAgentDriveFilesQuery.optional(), + }), + ) + .output(zGetAppsByAppIdAgentDriveFilesResponse) + +export const files2 = { + get: get11, + download, + preview: preview2, +} + +export const drive = { + files: files2, +} + +/** + * Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5) + */ +export const delete_ = oc + .route({ + description: 'Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5)', + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAppsByAppIdAgentFiles', + path: '/apps/{app_id}/agent/files', + tags: ['console'], + }) + .input( + z.object({ + params: zDeleteAppsByAppIdAgentFilesPath, + query: zDeleteAppsByAppIdAgentFilesQuery, + }), + ) + .output(zDeleteAppsByAppIdAgentFilesResponse) + +/** + * ADD FILE: commit one uploaded file into the bound agent's drive + * + * Commit an uploaded file into the agent drive under files/ (ENG-625 D3) + */ +export const post12 = oc + .route({ + description: 'Commit an uploaded file into the agent drive under files/ (ENG-625 D3)', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdAgentFiles', + path: '/apps/{app_id}/agent/files', + successStatus: 201, + summary: 'ADD FILE: commit one uploaded file into the bound agent\'s drive', + tags: ['console'], + }) + .input( + z.object({ + body: zPostAppsByAppIdAgentFilesBody, + params: zPostAppsByAppIdAgentFilesPath, + query: zPostAppsByAppIdAgentFilesQuery.optional(), + }), + ) + .output(zPostAppsByAppIdAgentFilesResponse) + +export const files3 = { + delete: delete_, + post: post12, +} + /** * Get agent logs * * Get agent execution logs for an application */ -export const get9 = oc +export const get12 = oc .route({ description: 'Get agent execution logs for an application', inputStructure: 'detailed', @@ -969,7 +1121,7 @@ export const get9 = oc .output(zGetAppsByAppIdAgentLogsResponse) export const logs = { - get: get9, + get: get12, } /** @@ -977,7 +1129,7 @@ export const logs = { * * Validate + standardize a Skill into the agent drive (ENG-594) */ -export const post12 = oc +export const post13 = oc .route({ description: 'Validate + standardize a Skill into the agent drive (ENG-594)', inputStructure: 'detailed', @@ -988,11 +1140,16 @@ export const post12 = oc summary: 'Upload a Skill, validate it, and standardize it into the app agent\'s drive', tags: ['console'], }) - .input(z.object({ params: zPostAppsByAppIdAgentSkillsStandardizePath })) + .input( + z.object({ + params: zPostAppsByAppIdAgentSkillsStandardizePath, + query: zPostAppsByAppIdAgentSkillsStandardizeQuery.optional(), + }), + ) .output(zPostAppsByAppIdAgentSkillsStandardizeResponse) export const standardize = { - post: post12, + post: post13, } /** @@ -1002,7 +1159,7 @@ export const standardize = { * Returns a validated skill ref (to bind into the Agent soul config on save) * plus its manifest. Standardizing into the agent drive is ENG-594. */ -export const post13 = oc +export const post14 = oc .route({ description: 'Upload + validate a Skill package (.zip/.skill) and extract its manifest\nReturns a validated skill ref (to bind into the Agent soul config on save)\nplus its manifest. Standardizing into the agent drive is ENG-594.', @@ -1018,15 +1175,73 @@ export const post13 = oc .output(zPostAppsByAppIdAgentSkillsUploadResponse) export const upload2 = { - post: post13, + post: post14, +} + +/** + * Suggest CLI tools/env for a skill + * + * Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371) + * Saving still goes through composer validation. + */ +export const post15 = oc + .route({ + description: + 'Infer CLI tool + ENV suggestions from a standardized skill\'s SKILL.md (draft only, ENG-371)\nSaving still goes through composer validation.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdAgentSkillsBySlugInferTools', + path: '/apps/{app_id}/agent/skills/{slug}/infer-tools', + summary: 'Suggest CLI tools/env for a skill', + tags: ['console'], + }) + .input( + z.object({ + params: zPostAppsByAppIdAgentSkillsBySlugInferToolsPath, + query: zPostAppsByAppIdAgentSkillsBySlugInferToolsQuery.optional(), + }), + ) + .output(zPostAppsByAppIdAgentSkillsBySlugInferToolsResponse) + +export const inferTools = { + post: post15, +} + +/** + * Delete a standardized skill: soul ref first, then the / drive prefix (ENG-625 D5) + */ +export const delete2 = oc + .route({ + description: + 'Delete a standardized skill: soul ref first, then the / drive prefix (ENG-625 D5)', + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAppsByAppIdAgentSkillsBySlug', + path: '/apps/{app_id}/agent/skills/{slug}', + tags: ['console'], + }) + .input( + z.object({ + params: zDeleteAppsByAppIdAgentSkillsBySlugPath, + query: zDeleteAppsByAppIdAgentSkillsBySlugQuery.optional(), + }), + ) + .output(zDeleteAppsByAppIdAgentSkillsBySlugResponse) + +export const bySlug = { + delete: delete2, + inferTools, } export const skills = { standardize, upload: upload2, + bySlug, } export const agent = { + drive, + files: files3, logs, skills, } @@ -1034,7 +1249,7 @@ export const agent = { /** * Get status of annotation reply action job */ -export const get10 = oc +export const get13 = oc .route({ description: 'Get status of annotation reply action job', inputStructure: 'detailed', @@ -1047,7 +1262,7 @@ export const get10 = oc .output(zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse) export const byJobId = { - get: get10, + get: get13, } export const status = { @@ -1057,7 +1272,7 @@ export const status = { /** * Enable or disable annotation reply for an app */ -export const post14 = oc +export const post16 = oc .route({ description: 'Enable or disable annotation reply for an app', inputStructure: 'detailed', @@ -1075,7 +1290,7 @@ export const post14 = oc .output(zPostAppsByAppIdAnnotationReplyByActionResponse) export const byAction = { - post: post14, + post: post16, status, } @@ -1086,7 +1301,7 @@ export const annotationReply = { /** * Get annotation settings for an app */ -export const get11 = oc +export const get14 = oc .route({ description: 'Get annotation settings for an app', inputStructure: 'detailed', @@ -1099,13 +1314,13 @@ export const get11 = oc .output(zGetAppsByAppIdAnnotationSettingResponse) export const annotationSetting = { - get: get11, + get: get14, } /** * Update annotation settings for an app */ -export const post15 = oc +export const post17 = oc .route({ description: 'Update annotation settings for an app', inputStructure: 'detailed', @@ -1123,7 +1338,7 @@ export const post15 = oc .output(zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse) export const byAnnotationSettingId = { - post: post15, + post: post17, } export const annotationSettings = { @@ -1133,7 +1348,7 @@ export const annotationSettings = { /** * Batch import annotations from CSV file with rate limiting and security checks */ -export const post16 = oc +export const post18 = oc .route({ description: 'Batch import annotations from CSV file with rate limiting and security checks', inputStructure: 'detailed', @@ -1146,13 +1361,13 @@ export const post16 = oc .output(zPostAppsByAppIdAnnotationsBatchImportResponse) export const batchImport = { - post: post16, + post: post18, } /** * Get status of batch import job */ -export const get12 = oc +export const get15 = oc .route({ description: 'Get status of batch import job', inputStructure: 'detailed', @@ -1165,7 +1380,7 @@ export const get12 = oc .output(zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse) export const byJobId2 = { - get: get12, + get: get15, } export const batchImportStatus = { @@ -1175,7 +1390,7 @@ export const batchImportStatus = { /** * Get count of message annotations for the app */ -export const get13 = oc +export const get16 = oc .route({ description: 'Get count of message annotations for the app', inputStructure: 'detailed', @@ -1188,13 +1403,13 @@ export const get13 = oc .output(zGetAppsByAppIdAnnotationsCountResponse) export const count2 = { - get: get13, + get: get16, } /** * Export all annotations for an app with CSV injection protection */ -export const get14 = oc +export const get17 = oc .route({ description: 'Export all annotations for an app with CSV injection protection', inputStructure: 'detailed', @@ -1207,13 +1422,13 @@ export const get14 = oc .output(zGetAppsByAppIdAnnotationsExportResponse) export const export_ = { - get: get14, + get: get17, } /** * Get hit histories for an annotation */ -export const get15 = oc +export const get18 = oc .route({ description: 'Get hit histories for an annotation', inputStructure: 'detailed', @@ -1231,10 +1446,10 @@ export const get15 = oc .output(zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponse) export const hitHistories = { - get: get15, + get: get18, } -export const delete_ = oc +export const delete3 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -1249,7 +1464,7 @@ export const delete_ = oc /** * Update or delete an annotation */ -export const post17 = oc +export const post19 = oc .route({ description: 'Update or delete an annotation', inputStructure: 'detailed', @@ -1267,12 +1482,12 @@ export const post17 = oc .output(zPostAppsByAppIdAnnotationsByAnnotationIdResponse) export const byAnnotationId = { - delete: delete_, - post: post17, + delete: delete3, + post: post19, hitHistories, } -export const delete2 = oc +export const delete4 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -1287,7 +1502,7 @@ export const delete2 = oc /** * Get annotations for an app with pagination */ -export const get16 = oc +export const get19 = oc .route({ description: 'Get annotations for an app with pagination', inputStructure: 'detailed', @@ -1307,7 +1522,7 @@ export const get16 = oc /** * Create a new annotation for an app */ -export const post18 = oc +export const post20 = oc .route({ description: 'Create a new annotation for an app', inputStructure: 'detailed', @@ -1323,9 +1538,9 @@ export const post18 = oc .output(zPostAppsByAppIdAnnotationsResponse) export const annotations = { - delete: delete2, - get: get16, - post: post18, + delete: delete4, + get: get19, + post: post20, batchImport, batchImportStatus, count: count2, @@ -1336,7 +1551,7 @@ export const annotations = { /** * Enable or disable app API */ -export const post19 = oc +export const post21 = oc .route({ description: 'Enable or disable app API', inputStructure: 'detailed', @@ -1349,13 +1564,13 @@ export const post19 = oc .output(zPostAppsByAppIdApiEnableResponse) export const apiEnable = { - post: post19, + post: post21, } /** * Transcript audio to text for chat messages */ -export const post20 = oc +export const post22 = oc .route({ description: 'Transcript audio to text for chat messages', inputStructure: 'detailed', @@ -1368,13 +1583,13 @@ export const post20 = oc .output(zPostAppsByAppIdAudioToTextResponse) export const audioToText = { - post: post20, + post: post22, } /** * Delete a chat conversation */ -export const delete3 = oc +export const delete5 = oc .route({ description: 'Delete a chat conversation', inputStructure: 'detailed', @@ -1390,7 +1605,7 @@ export const delete3 = oc /** * Get chat conversation details */ -export const get17 = oc +export const get20 = oc .route({ description: 'Get chat conversation details', inputStructure: 'detailed', @@ -1403,14 +1618,14 @@ export const get17 = oc .output(zGetAppsByAppIdChatConversationsByConversationIdResponse) export const byConversationId = { - delete: delete3, - get: get17, + delete: delete5, + get: get20, } /** * Get chat conversations with pagination, filtering and summary */ -export const get18 = oc +export const get21 = oc .route({ description: 'Get chat conversations with pagination, filtering and summary', inputStructure: 'detailed', @@ -1428,14 +1643,14 @@ export const get18 = oc .output(zGetAppsByAppIdChatConversationsResponse) export const chatConversations = { - get: get18, + get: get21, byConversationId, } /** * Get suggested questions for a message */ -export const get19 = oc +export const get22 = oc .route({ description: 'Get suggested questions for a message', inputStructure: 'detailed', @@ -1448,7 +1663,7 @@ export const get19 = oc .output(zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponse) export const suggestedQuestions = { - get: get19, + get: get22, } export const byMessageId = { @@ -1458,7 +1673,7 @@ export const byMessageId = { /** * Stop a running chat message generation */ -export const post21 = oc +export const post23 = oc .route({ description: 'Stop a running chat message generation', inputStructure: 'detailed', @@ -1471,7 +1686,7 @@ export const post21 = oc .output(zPostAppsByAppIdChatMessagesByTaskIdStopResponse) export const stop = { - post: post21, + post: post23, } export const byTaskId = { @@ -1481,7 +1696,7 @@ export const byTaskId = { /** * Get chat messages for a conversation with pagination */ -export const get20 = oc +export const get23 = oc .route({ description: 'Get chat messages for a conversation with pagination', inputStructure: 'detailed', @@ -1496,7 +1711,7 @@ export const get20 = oc .output(zGetAppsByAppIdChatMessagesResponse) export const chatMessages = { - get: get20, + get: get23, byMessageId, byTaskId, } @@ -1504,7 +1719,7 @@ export const chatMessages = { /** * Delete a completion conversation */ -export const delete4 = oc +export const delete6 = oc .route({ description: 'Delete a completion conversation', inputStructure: 'detailed', @@ -1520,7 +1735,7 @@ export const delete4 = oc /** * Get completion conversation details with messages */ -export const get21 = oc +export const get24 = oc .route({ description: 'Get completion conversation details with messages', inputStructure: 'detailed', @@ -1533,14 +1748,14 @@ export const get21 = oc .output(zGetAppsByAppIdCompletionConversationsByConversationIdResponse) export const byConversationId2 = { - delete: delete4, - get: get21, + delete: delete6, + get: get24, } /** * Get completion conversations with pagination and filtering */ -export const get22 = oc +export const get25 = oc .route({ description: 'Get completion conversations with pagination and filtering', inputStructure: 'detailed', @@ -1558,14 +1773,14 @@ export const get22 = oc .output(zGetAppsByAppIdCompletionConversationsResponse) export const completionConversations = { - get: get22, + get: get25, byConversationId: byConversationId2, } /** * Stop a running completion message generation */ -export const post22 = oc +export const post24 = oc .route({ description: 'Stop a running completion message generation', inputStructure: 'detailed', @@ -1578,7 +1793,7 @@ export const post22 = oc .output(zPostAppsByAppIdCompletionMessagesByTaskIdStopResponse) export const stop2 = { - post: post22, + post: post24, } export const byTaskId2 = { @@ -1588,7 +1803,7 @@ export const byTaskId2 = { /** * Generate completion message for debugging */ -export const post23 = oc +export const post25 = oc .route({ description: 'Generate completion message for debugging', inputStructure: 'detailed', @@ -1606,14 +1821,14 @@ export const post23 = oc .output(zPostAppsByAppIdCompletionMessagesResponse) export const completionMessages = { - post: post23, + post: post25, byTaskId: byTaskId2, } /** * Get conversation variables for an application */ -export const get23 = oc +export const get26 = oc .route({ description: 'Get conversation variables for an application', inputStructure: 'detailed', @@ -1631,7 +1846,7 @@ export const get23 = oc .output(zGetAppsByAppIdConversationVariablesResponse) export const conversationVariables = { - get: get23, + get: get26, } /** @@ -1641,7 +1856,7 @@ export const conversationVariables = { * Convert expert mode of chatbot app to workflow mode * Convert Completion App to Workflow App */ -export const post24 = oc +export const post26 = oc .route({ description: 'Convert application to workflow mode\nConvert expert mode of chatbot app to workflow mode\nConvert Completion App to Workflow App', @@ -1661,7 +1876,7 @@ export const post24 = oc .output(zPostAppsByAppIdConvertToWorkflowResponse) export const convertToWorkflow = { - post: post24, + post: post26, } /** @@ -1669,7 +1884,7 @@ export const convertToWorkflow = { * * Create a copy of an existing application */ -export const post25 = oc +export const post27 = oc .route({ description: 'Create a copy of an existing application', inputStructure: 'detailed', @@ -1684,7 +1899,7 @@ export const post25 = oc .output(zPostAppsByAppIdCopyResponse) export const copy = { - post: post25, + post: post27, } /** @@ -1692,7 +1907,7 @@ export const copy = { * * Export application configuration as DSL */ -export const get24 = oc +export const get27 = oc .route({ description: 'Export application configuration as DSL', inputStructure: 'detailed', @@ -1708,13 +1923,13 @@ export const get24 = oc .output(zGetAppsByAppIdExportResponse) export const export2 = { - get: get24, + get: get27, } /** * Export user feedback data for Google Sheets */ -export const get25 = oc +export const get28 = oc .route({ description: 'Export user feedback data for Google Sheets', inputStructure: 'detailed', @@ -1732,13 +1947,13 @@ export const get25 = oc .output(zGetAppsByAppIdFeedbacksExportResponse) export const export3 = { - get: get25, + get: get28, } /** * Create or update message feedback (like/dislike) */ -export const post26 = oc +export const post28 = oc .route({ description: 'Create or update message feedback (like/dislike)', inputStructure: 'detailed', @@ -1751,14 +1966,14 @@ export const post26 = oc .output(zPostAppsByAppIdFeedbacksResponse) export const feedbacks = { - post: post26, + post: post28, export: export3, } /** * Update application icon */ -export const post27 = oc +export const post29 = oc .route({ description: 'Update application icon', inputStructure: 'detailed', @@ -1771,13 +1986,13 @@ export const post27 = oc .output(zPostAppsByAppIdIconResponse) export const icon = { - post: post27, + post: post29, } /** * Get message details by ID */ -export const get26 = oc +export const get29 = oc .route({ description: 'Get message details by ID', inputStructure: 'detailed', @@ -1790,7 +2005,7 @@ export const get26 = oc .output(zGetAppsByAppIdMessagesByMessageIdResponse) export const byMessageId2 = { - get: get26, + get: get29, } export const messages = { @@ -1802,7 +2017,7 @@ export const messages = { * * Update application model configuration */ -export const post28 = oc +export const post30 = oc .route({ description: 'Update application model configuration', inputStructure: 'detailed', @@ -1818,13 +2033,13 @@ export const post28 = oc .output(zPostAppsByAppIdModelConfigResponse) export const modelConfig = { - post: post28, + post: post30, } /** * Check if app name is available */ -export const post29 = oc +export const post31 = oc .route({ description: 'Check if app name is available', inputStructure: 'detailed', @@ -1837,13 +2052,13 @@ export const post29 = oc .output(zPostAppsByAppIdNameResponse) export const name = { - post: post29, + post: post31, } /** * Publish app to Creators Platform */ -export const post30 = oc +export const post32 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1856,13 +2071,13 @@ export const post30 = oc .output(zPostAppsByAppIdPublishToCreatorsPlatformResponse) export const publishToCreatorsPlatform = { - post: post30, + post: post32, } /** * Get MCP server configuration for an application */ -export const get27 = oc +export const get30 = oc .route({ description: 'Get MCP server configuration for an application', inputStructure: 'detailed', @@ -1877,7 +2092,7 @@ export const get27 = oc /** * Create MCP server configuration for an application */ -export const post31 = oc +export const post33 = oc .route({ description: 'Create MCP server configuration for an application', inputStructure: 'detailed', @@ -1906,15 +2121,15 @@ export const put2 = oc .output(zPutAppsByAppIdServerResponse) export const server = { - get: get27, - post: post31, + get: get30, + post: post33, put: put2, } /** * Reset access token for application site */ -export const post32 = oc +export const post34 = oc .route({ description: 'Reset access token for application site', inputStructure: 'detailed', @@ -1927,13 +2142,13 @@ export const post32 = oc .output(zPostAppsByAppIdSiteAccessTokenResetResponse) export const accessTokenReset = { - post: post32, + post: post34, } /** * Update application site configuration */ -export const post33 = oc +export const post35 = oc .route({ description: 'Update application site configuration', inputStructure: 'detailed', @@ -1946,14 +2161,14 @@ export const post33 = oc .output(zPostAppsByAppIdSiteResponse) export const site = { - post: post33, + post: post35, accessTokenReset, } /** * Enable or disable app site */ -export const post34 = oc +export const post36 = oc .route({ description: 'Enable or disable app site', inputStructure: 'detailed', @@ -1966,13 +2181,13 @@ export const post34 = oc .output(zPostAppsByAppIdSiteEnableResponse) export const siteEnable = { - post: post34, + post: post36, } /** * Get average response time statistics for an application */ -export const get28 = oc +export const get31 = oc .route({ description: 'Get average response time statistics for an application', inputStructure: 'detailed', @@ -1990,13 +2205,13 @@ export const get28 = oc .output(zGetAppsByAppIdStatisticsAverageResponseTimeResponse) export const averageResponseTime = { - get: get28, + get: get31, } /** * Get average session interaction statistics for an application */ -export const get29 = oc +export const get32 = oc .route({ description: 'Get average session interaction statistics for an application', inputStructure: 'detailed', @@ -2014,13 +2229,13 @@ export const get29 = oc .output(zGetAppsByAppIdStatisticsAverageSessionInteractionsResponse) export const averageSessionInteractions = { - get: get29, + get: get32, } /** * Get daily conversation statistics for an application */ -export const get30 = oc +export const get33 = oc .route({ description: 'Get daily conversation statistics for an application', inputStructure: 'detailed', @@ -2038,13 +2253,13 @@ export const get30 = oc .output(zGetAppsByAppIdStatisticsDailyConversationsResponse) export const dailyConversations = { - get: get30, + get: get33, } /** * Get daily terminal/end-user statistics for an application */ -export const get31 = oc +export const get34 = oc .route({ description: 'Get daily terminal/end-user statistics for an application', inputStructure: 'detailed', @@ -2062,13 +2277,13 @@ export const get31 = oc .output(zGetAppsByAppIdStatisticsDailyEndUsersResponse) export const dailyEndUsers = { - get: get31, + get: get34, } /** * Get daily message statistics for an application */ -export const get32 = oc +export const get35 = oc .route({ description: 'Get daily message statistics for an application', inputStructure: 'detailed', @@ -2086,13 +2301,13 @@ export const get32 = oc .output(zGetAppsByAppIdStatisticsDailyMessagesResponse) export const dailyMessages = { - get: get32, + get: get35, } /** * Get daily token cost statistics for an application */ -export const get33 = oc +export const get36 = oc .route({ description: 'Get daily token cost statistics for an application', inputStructure: 'detailed', @@ -2110,13 +2325,13 @@ export const get33 = oc .output(zGetAppsByAppIdStatisticsTokenCostsResponse) export const tokenCosts = { - get: get33, + get: get36, } /** * Get tokens per second statistics for an application */ -export const get34 = oc +export const get37 = oc .route({ description: 'Get tokens per second statistics for an application', inputStructure: 'detailed', @@ -2134,13 +2349,13 @@ export const get34 = oc .output(zGetAppsByAppIdStatisticsTokensPerSecondResponse) export const tokensPerSecond = { - get: get34, + get: get37, } /** * Get user satisfaction rate statistics for an application */ -export const get35 = oc +export const get38 = oc .route({ description: 'Get user satisfaction rate statistics for an application', inputStructure: 'detailed', @@ -2158,7 +2373,7 @@ export const get35 = oc .output(zGetAppsByAppIdStatisticsUserSatisfactionRateResponse) export const userSatisfactionRate = { - get: get35, + get: get38, } export const statistics = { @@ -2175,7 +2390,7 @@ export const statistics = { /** * Get available TTS voices for a specific language */ -export const get36 = oc +export const get39 = oc .route({ description: 'Get available TTS voices for a specific language', inputStructure: 'detailed', @@ -2193,13 +2408,13 @@ export const get36 = oc .output(zGetAppsByAppIdTextToAudioVoicesResponse) export const voices = { - get: get36, + get: get39, } /** * Convert text to speech for chat messages */ -export const post35 = oc +export const post37 = oc .route({ description: 'Convert text to speech for chat messages', inputStructure: 'detailed', @@ -2214,7 +2429,7 @@ export const post35 = oc .output(zPostAppsByAppIdTextToAudioResponse) export const textToAudio = { - post: post35, + post: post37, voices, } @@ -2223,7 +2438,7 @@ export const textToAudio = { * * Get app tracing configuration */ -export const get37 = oc +export const get40 = oc .route({ description: 'Get app tracing configuration', inputStructure: 'detailed', @@ -2239,7 +2454,7 @@ export const get37 = oc /** * Update app tracing configuration */ -export const post36 = oc +export const post38 = oc .route({ description: 'Update app tracing configuration', inputStructure: 'detailed', @@ -2252,8 +2467,8 @@ export const post36 = oc .output(zPostAppsByAppIdTraceResponse) export const trace = { - get: get37, - post: post36, + get: get40, + post: post38, } /** @@ -2261,7 +2476,7 @@ export const trace = { * * Delete an existing tracing configuration for an application */ -export const delete5 = oc +export const delete7 = oc .route({ description: 'Delete an existing tracing configuration for an application', inputStructure: 'detailed', @@ -2283,7 +2498,7 @@ export const delete5 = oc /** * Get tracing configuration for an application */ -export const get38 = oc +export const get41 = oc .route({ description: 'Get tracing configuration for an application', inputStructure: 'detailed', @@ -2322,7 +2537,7 @@ export const patch = oc * * Create a new tracing configuration for an application */ -export const post37 = oc +export const post39 = oc .route({ description: 'Create a new tracing configuration for an application', inputStructure: 'detailed', @@ -2339,16 +2554,16 @@ export const post37 = oc .output(zPostAppsByAppIdTraceConfigResponse) export const traceConfig = { - delete: delete5, - get: get38, + delete: delete7, + get: get41, patch, - post: post37, + post: post39, } /** * Update app trigger (enable/disable) */ -export const post38 = oc +export const post40 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2366,13 +2581,13 @@ export const post38 = oc .output(zPostAppsByAppIdTriggerEnableResponse) export const triggerEnable = { - post: post38, + post: post40, } /** * Get app triggers list */ -export const get39 = oc +export const get42 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2385,7 +2600,7 @@ export const get39 = oc .output(zGetAppsByAppIdTriggersResponse) export const triggers = { - get: get39, + get: get42, } /** @@ -2393,7 +2608,7 @@ export const triggers = { * * Get workflow application execution logs */ -export const get40 = oc +export const get43 = oc .route({ description: 'Get workflow application execution logs', inputStructure: 'detailed', @@ -2412,7 +2627,7 @@ export const get40 = oc .output(zGetAppsByAppIdWorkflowAppLogsResponse) export const workflowAppLogs = { - get: get40, + get: get43, } /** @@ -2420,7 +2635,7 @@ export const workflowAppLogs = { * * Get workflow archived execution logs */ -export const get41 = oc +export const get44 = oc .route({ description: 'Get workflow archived execution logs', inputStructure: 'detailed', @@ -2439,7 +2654,7 @@ export const get41 = oc .output(zGetAppsByAppIdWorkflowArchivedLogsResponse) export const workflowArchivedLogs = { - get: get41, + get: get44, } /** @@ -2447,7 +2662,7 @@ export const workflowArchivedLogs = { * * Get workflow runs count statistics */ -export const get42 = oc +export const get45 = oc .route({ description: 'Get workflow runs count statistics', inputStructure: 'detailed', @@ -2466,7 +2681,7 @@ export const get42 = oc .output(zGetAppsByAppIdWorkflowRunsCountResponse) export const count3 = { - get: get42, + get: get45, } /** @@ -2474,7 +2689,7 @@ export const count3 = { * * Stop running workflow task */ -export const post39 = oc +export const post41 = oc .route({ description: 'Stop running workflow task', inputStructure: 'detailed', @@ -2488,7 +2703,7 @@ export const post39 = oc .output(zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse) export const stop3 = { - post: post39, + post: post41, } export const byTaskId3 = { @@ -2502,7 +2717,7 @@ export const tasks = { /** * Generate a download URL for an archived workflow run. */ -export const get43 = oc +export const get46 = oc .route({ description: 'Generate a download URL for an archived workflow run.', inputStructure: 'detailed', @@ -2515,7 +2730,7 @@ export const get43 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdExportResponse) export const export4 = { - get: get43, + get: get46, } /** @@ -2523,7 +2738,7 @@ export const export4 = { * * Get workflow run node execution list */ -export const get44 = oc +export const get47 = oc .route({ description: 'Get workflow run node execution list', inputStructure: 'detailed', @@ -2537,7 +2752,7 @@ export const get44 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse) export const nodeExecutions = { - get: get44, + get: get47, } /** @@ -2545,7 +2760,7 @@ export const nodeExecutions = { * * Get workflow run detail */ -export const get45 = oc +export const get48 = oc .route({ description: 'Get workflow run detail', inputStructure: 'detailed', @@ -2559,7 +2774,7 @@ export const get45 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdResponse) export const byRunId = { - get: get45, + get: get48, export: export4, nodeExecutions, } @@ -2567,7 +2782,7 @@ export const byRunId = { /** * Read a text/binary preview file in a workflow Agent node sandbox */ -export const get46 = oc +export const get49 = oc .route({ description: 'Read a text/binary preview file in a workflow Agent node sandbox', inputStructure: 'detailed', @@ -2585,13 +2800,13 @@ export const get46 = oc .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse) export const read2 = { - get: get46, + get: get49, } /** * Upload one workflow Agent sandbox file as a Dify ToolFile mapping */ -export const post40 = oc +export const post42 = oc .route({ description: 'Upload one workflow Agent sandbox file as a Dify ToolFile mapping', inputStructure: 'detailed', @@ -2609,13 +2824,13 @@ export const post40 = oc .output(zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse) export const upload3 = { - post: post40, + post: post42, } /** * List a directory in a workflow Agent node sandbox */ -export const get47 = oc +export const get50 = oc .route({ description: 'List a directory in a workflow Agent node sandbox', inputStructure: 'detailed', @@ -2633,14 +2848,14 @@ export const get47 = oc ) .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse) -export const files2 = { - get: get47, +export const files4 = { + get: get50, read: read2, upload: upload3, } export const sandbox = { - files: files2, + files: files4, } export const byNodeId4 = { @@ -2660,7 +2875,7 @@ export const byWorkflowRunId = { * * Get workflow run list */ -export const get48 = oc +export const get51 = oc .route({ description: 'Get workflow run list', inputStructure: 'detailed', @@ -2679,7 +2894,7 @@ export const get48 = oc .output(zGetAppsByAppIdWorkflowRunsResponse) export const workflowRuns2 = { - get: get48, + get: get51, count: count3, tasks, byRunId, @@ -2691,7 +2906,7 @@ export const workflowRuns2 = { * * Get all users in current tenant for mentions */ -export const get49 = oc +export const get52 = oc .route({ description: 'Get all users in current tenant for mentions', inputStructure: 'detailed', @@ -2705,7 +2920,7 @@ export const get49 = oc .output(zGetAppsByAppIdWorkflowCommentsMentionUsersResponse) export const mentionUsers = { - get: get49, + get: get52, } /** @@ -2713,7 +2928,7 @@ export const mentionUsers = { * * Delete a comment reply */ -export const delete6 = oc +export const delete8 = oc .route({ description: 'Delete a comment reply', inputStructure: 'detailed', @@ -2751,7 +2966,7 @@ export const put3 = oc .output(zPutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponse) export const byReplyId = { - delete: delete6, + delete: delete8, put: put3, } @@ -2760,7 +2975,7 @@ export const byReplyId = { * * Add a reply to a workflow comment */ -export const post41 = oc +export const post43 = oc .route({ description: 'Add a reply to a workflow comment', inputStructure: 'detailed', @@ -2780,7 +2995,7 @@ export const post41 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponse) export const replies = { - post: post41, + post: post43, byReplyId, } @@ -2789,7 +3004,7 @@ export const replies = { * * Resolve a workflow comment */ -export const post42 = oc +export const post44 = oc .route({ description: 'Resolve a workflow comment', inputStructure: 'detailed', @@ -2803,7 +3018,7 @@ export const post42 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse) export const resolve = { - post: post42, + post: post44, } /** @@ -2811,7 +3026,7 @@ export const resolve = { * * Delete a workflow comment */ -export const delete7 = oc +export const delete9 = oc .route({ description: 'Delete a workflow comment', inputStructure: 'detailed', @@ -2830,7 +3045,7 @@ export const delete7 = oc * * Get a specific workflow comment */ -export const get50 = oc +export const get53 = oc .route({ description: 'Get a specific workflow comment', inputStructure: 'detailed', @@ -2867,8 +3082,8 @@ export const put4 = oc .output(zPutAppsByAppIdWorkflowCommentsByCommentIdResponse) export const byCommentId = { - delete: delete7, - get: get50, + delete: delete9, + get: get53, put: put4, replies, resolve, @@ -2879,7 +3094,7 @@ export const byCommentId = { * * Get all comments for a workflow */ -export const get51 = oc +export const get54 = oc .route({ description: 'Get all comments for a workflow', inputStructure: 'detailed', @@ -2897,7 +3112,7 @@ export const get51 = oc * * Create a new workflow comment */ -export const post43 = oc +export const post45 = oc .route({ description: 'Create a new workflow comment', inputStructure: 'detailed', @@ -2917,8 +3132,8 @@ export const post43 = oc .output(zPostAppsByAppIdWorkflowCommentsResponse) export const comments = { - get: get51, - post: post43, + get: get54, + post: post45, mentionUsers, byCommentId, } @@ -2926,7 +3141,7 @@ export const comments = { /** * Get workflow average app interaction statistics */ -export const get52 = oc +export const get55 = oc .route({ description: 'Get workflow average app interaction statistics', inputStructure: 'detailed', @@ -2944,13 +3159,13 @@ export const get52 = oc .output(zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse) export const averageAppInteractions = { - get: get52, + get: get55, } /** * Get workflow daily runs statistics */ -export const get53 = oc +export const get56 = oc .route({ description: 'Get workflow daily runs statistics', inputStructure: 'detailed', @@ -2968,13 +3183,13 @@ export const get53 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyConversationsResponse) export const dailyConversations2 = { - get: get53, + get: get56, } /** * Get workflow daily terminals statistics */ -export const get54 = oc +export const get57 = oc .route({ description: 'Get workflow daily terminals statistics', inputStructure: 'detailed', @@ -2992,13 +3207,13 @@ export const get54 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse) export const dailyTerminals = { - get: get54, + get: get57, } /** * Get workflow daily token cost statistics */ -export const get55 = oc +export const get58 = oc .route({ description: 'Get workflow daily token cost statistics', inputStructure: 'detailed', @@ -3016,7 +3231,7 @@ export const get55 = oc .output(zGetAppsByAppIdWorkflowStatisticsTokenCostsResponse) export const tokenCosts2 = { - get: get55, + get: get58, } export const statistics2 = { @@ -3036,7 +3251,7 @@ export const workflow = { * * Get default block configuration by type */ -export const get56 = oc +export const get59 = oc .route({ description: 'Get default block configuration by type', inputStructure: 'detailed', @@ -3055,7 +3270,7 @@ export const get56 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse) export const byBlockType = { - get: get56, + get: get59, } /** @@ -3063,7 +3278,7 @@ export const byBlockType = { * * Get default block configurations for workflow */ -export const get57 = oc +export const get60 = oc .route({ description: 'Get default block configurations for workflow', inputStructure: 'detailed', @@ -3077,14 +3292,14 @@ export const get57 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse) export const defaultWorkflowBlockConfigs = { - get: get57, + get: get60, byBlockType, } /** * Get conversation variables for workflow */ -export const get58 = oc +export const get61 = oc .route({ description: 'Get conversation variables for workflow', inputStructure: 'detailed', @@ -3099,7 +3314,7 @@ export const get58 = oc /** * Update conversation variables for workflow draft */ -export const post44 = oc +export const post46 = oc .route({ description: 'Update conversation variables for workflow draft', inputStructure: 'detailed', @@ -3117,8 +3332,8 @@ export const post44 = oc .output(zPostAppsByAppIdWorkflowsDraftConversationVariablesResponse) export const conversationVariables2 = { - get: get58, - post: post44, + get: get61, + post: post46, } /** @@ -3126,7 +3341,7 @@ export const conversationVariables2 = { * * Get environment variables for workflow */ -export const get59 = oc +export const get62 = oc .route({ description: 'Get environment variables for workflow', inputStructure: 'detailed', @@ -3142,7 +3357,7 @@ export const get59 = oc /** * Update environment variables for workflow draft */ -export const post45 = oc +export const post47 = oc .route({ description: 'Update environment variables for workflow draft', inputStructure: 'detailed', @@ -3160,14 +3375,14 @@ export const post45 = oc .output(zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse) export const environmentVariables = { - get: get59, - post: post45, + get: get62, + post: post47, } /** * Update draft workflow features */ -export const post46 = oc +export const post48 = oc .route({ description: 'Update draft workflow features', inputStructure: 'detailed', @@ -3185,7 +3400,7 @@ export const post46 = oc .output(zPostAppsByAppIdWorkflowsDraftFeaturesResponse) export const features = { - post: post46, + post: post48, } /** @@ -3193,7 +3408,7 @@ export const features = { * * Test human input delivery for workflow */ -export const post47 = oc +export const post49 = oc .route({ description: 'Test human input delivery for workflow', inputStructure: 'detailed', @@ -3212,7 +3427,7 @@ export const post47 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse) export const deliveryTest = { - post: post47, + post: post49, } /** @@ -3220,7 +3435,7 @@ export const deliveryTest = { * * Get human input form preview for workflow */ -export const post48 = oc +export const post50 = oc .route({ description: 'Get human input form preview for workflow', inputStructure: 'detailed', @@ -3238,8 +3453,8 @@ export const post48 = oc ) .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse) -export const preview2 = { - post: post48, +export const preview3 = { + post: post50, } /** @@ -3247,7 +3462,7 @@ export const preview2 = { * * Submit human input form preview for workflow */ -export const post49 = oc +export const post51 = oc .route({ description: 'Submit human input form preview for workflow', inputStructure: 'detailed', @@ -3266,11 +3481,11 @@ export const post49 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse) export const run5 = { - post: post49, + post: post51, } export const form2 = { - preview: preview2, + preview: preview3, run: run5, } @@ -3292,7 +3507,7 @@ export const humanInput2 = { * * Run draft workflow iteration node */ -export const post50 = oc +export const post52 = oc .route({ description: 'Run draft workflow iteration node', inputStructure: 'detailed', @@ -3311,7 +3526,7 @@ export const post50 = oc .output(zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse) export const run6 = { - post: post50, + post: post52, } export const byNodeId6 = { @@ -3331,7 +3546,7 @@ export const iteration2 = { * * Run draft workflow loop node */ -export const post51 = oc +export const post53 = oc .route({ description: 'Run draft workflow loop node', inputStructure: 'detailed', @@ -3350,7 +3565,7 @@ export const post51 = oc .output(zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse) export const run7 = { - post: post51, + post: post53, } export const byNodeId7 = { @@ -3365,7 +3580,7 @@ export const loop2 = { nodes: nodes6, } -export const get60 = oc +export const get63 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3379,10 +3594,10 @@ export const get60 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse) export const candidates2 = { - get: get60, + get: get63, } -export const post52 = oc +export const post54 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3399,10 +3614,10 @@ export const post52 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse) export const impact = { - post: post52, + post: post54, } -export const post53 = oc +export const post55 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3419,10 +3634,10 @@ export const post53 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse) export const saveToRoster = { - post: post53, + post: post55, } -export const post54 = oc +export const post56 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3439,10 +3654,10 @@ export const post54 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse) export const validate2 = { - post: post54, + post: post56, } -export const get61 = oc +export const get64 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3470,7 +3685,7 @@ export const put5 = oc .output(zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse) export const agentComposer2 = { - get: get61, + get: get64, put: put5, candidates: candidates2, impact, @@ -3481,7 +3696,7 @@ export const agentComposer2 = { /** * Get last run result for draft workflow node */ -export const get62 = oc +export const get65 = oc .route({ description: 'Get last run result for draft workflow node', inputStructure: 'detailed', @@ -3494,7 +3709,7 @@ export const get62 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse) export const lastRun = { - get: get62, + get: get65, } /** @@ -3502,7 +3717,7 @@ export const lastRun = { * * Run draft workflow node */ -export const post55 = oc +export const post57 = oc .route({ description: 'Run draft workflow node', inputStructure: 'detailed', @@ -3521,7 +3736,7 @@ export const post55 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse) export const run8 = { - post: post55, + post: post57, } /** @@ -3529,7 +3744,7 @@ export const run8 = { * * Poll for trigger events and execute single node when event arrives */ -export const post56 = oc +export const post58 = oc .route({ description: 'Poll for trigger events and execute single node when event arrives', inputStructure: 'detailed', @@ -3543,7 +3758,7 @@ export const post56 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse) export const run9 = { - post: post56, + post: post58, } export const trigger = { @@ -3553,7 +3768,7 @@ export const trigger = { /** * Delete all variables for a specific node */ -export const delete8 = oc +export const delete10 = oc .route({ description: 'Delete all variables for a specific node', inputStructure: 'detailed', @@ -3569,7 +3784,7 @@ export const delete8 = oc /** * Get variables for a specific node */ -export const get63 = oc +export const get66 = oc .route({ description: 'Get variables for a specific node', inputStructure: 'detailed', @@ -3582,8 +3797,8 @@ export const get63 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponse) export const variables = { - delete: delete8, - get: get63, + delete: delete10, + get: get66, } export const byNodeId8 = { @@ -3603,7 +3818,7 @@ export const nodes7 = { * * Run draft workflow */ -export const post57 = oc +export const post59 = oc .route({ description: 'Run draft workflow', inputStructure: 'detailed', @@ -3622,13 +3837,13 @@ export const post57 = oc .output(zPostAppsByAppIdWorkflowsDraftRunResponse) export const run10 = { - post: post57, + post: post59, } /** * Server-Sent Events stream of inspector deltas for a draft workflow run. */ -export const get64 = oc +export const get67 = oc .route({ description: 'Server-Sent Events stream of inspector deltas for a draft workflow run.', inputStructure: 'detailed', @@ -3641,13 +3856,13 @@ export const get64 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse) export const events = { - get: get64, + get: get67, } /** * Full value for one declared output, including signed download URL for files. */ -export const get65 = oc +export const get68 = oc .route({ description: 'Full value for one declared output, including signed download URL for files.', inputStructure: 'detailed', @@ -3663,18 +3878,18 @@ export const get65 = oc ) .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse) -export const preview3 = { - get: get65, +export const preview4 = { + get: get68, } export const byOutputName = { - preview: preview3, + preview: preview4, } /** * One node's declared outputs for a draft workflow run. */ -export const get66 = oc +export const get69 = oc .route({ description: 'One node\'s declared outputs for a draft workflow run.', inputStructure: 'detailed', @@ -3687,14 +3902,14 @@ export const get66 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponse) export const byNodeId9 = { - get: get66, + get: get69, byOutputName, } /** * Snapshot of every node's declared outputs for a draft workflow run. */ -export const get67 = oc +export const get70 = oc .route({ description: 'Snapshot of every node\'s declared outputs for a draft workflow run.', inputStructure: 'detailed', @@ -3707,7 +3922,7 @@ export const get67 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponse) export const nodeOutputs = { - get: get67, + get: get70, events, byNodeId: byNodeId9, } @@ -3723,7 +3938,7 @@ export const runs = { /** * Get system variables for workflow */ -export const get68 = oc +export const get71 = oc .route({ description: 'Get system variables for workflow', inputStructure: 'detailed', @@ -3736,7 +3951,7 @@ export const get68 = oc .output(zGetAppsByAppIdWorkflowsDraftSystemVariablesResponse) export const systemVariables = { - get: get68, + get: get71, } /** @@ -3744,7 +3959,7 @@ export const systemVariables = { * * Poll for trigger events and execute full workflow when event arrives */ -export const post58 = oc +export const post60 = oc .route({ description: 'Poll for trigger events and execute full workflow when event arrives', inputStructure: 'detailed', @@ -3763,7 +3978,7 @@ export const post58 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunResponse) export const run11 = { - post: post58, + post: post60, } /** @@ -3771,7 +3986,7 @@ export const run11 = { * * Full workflow debug when the start node is a trigger */ -export const post59 = oc +export const post61 = oc .route({ description: 'Full workflow debug when the start node is a trigger', inputStructure: 'detailed', @@ -3790,7 +4005,7 @@ export const post59 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse) export const runAll = { - post: post59, + post: post61, } export const trigger2 = { @@ -3820,7 +4035,7 @@ export const reset = { /** * Delete a workflow variable */ -export const delete9 = oc +export const delete11 = oc .route({ description: 'Delete a workflow variable', inputStructure: 'detailed', @@ -3836,7 +4051,7 @@ export const delete9 = oc /** * Get a specific workflow variable */ -export const get69 = oc +export const get72 = oc .route({ description: 'Get a specific workflow variable', inputStructure: 'detailed', @@ -3869,8 +4084,8 @@ export const patch2 = oc .output(zPatchAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse) export const byVariableId = { - delete: delete9, - get: get69, + delete: delete11, + get: get72, patch: patch2, reset, } @@ -3878,7 +4093,7 @@ export const byVariableId = { /** * Delete all draft workflow variables */ -export const delete10 = oc +export const delete12 = oc .route({ description: 'Delete all draft workflow variables', inputStructure: 'detailed', @@ -3896,7 +4111,7 @@ export const delete10 = oc * * Get draft workflow variables */ -export const get70 = oc +export const get73 = oc .route({ description: 'Get draft workflow variables', inputStructure: 'detailed', @@ -3915,8 +4130,8 @@ export const get70 = oc .output(zGetAppsByAppIdWorkflowsDraftVariablesResponse) export const variables2 = { - delete: delete10, - get: get70, + delete: delete12, + get: get73, byVariableId, } @@ -3925,7 +4140,7 @@ export const variables2 = { * * Get draft workflow for an application */ -export const get71 = oc +export const get74 = oc .route({ description: 'Get draft workflow for an application', inputStructure: 'detailed', @@ -3943,7 +4158,7 @@ export const get71 = oc * * Sync draft workflow configuration */ -export const post60 = oc +export const post62 = oc .route({ description: 'Sync draft workflow configuration', inputStructure: 'detailed', @@ -3962,8 +4177,8 @@ export const post60 = oc .output(zPostAppsByAppIdWorkflowsDraftResponse) export const draft2 = { - get: get71, - post: post60, + get: get74, + post: post62, conversationVariables: conversationVariables2, environmentVariables, features, @@ -3983,7 +4198,7 @@ export const draft2 = { * * Get published workflow for an application */ -export const get72 = oc +export const get75 = oc .route({ description: 'Get published workflow for an application', inputStructure: 'detailed', @@ -3999,7 +4214,7 @@ export const get72 = oc /** * Publish workflow */ -export const post61 = oc +export const post63 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4017,14 +4232,14 @@ export const post61 = oc .output(zPostAppsByAppIdWorkflowsPublishResponse) export const publish = { - get: get72, - post: post61, + get: get75, + post: post63, } /** * Server-Sent Events stream of inspector deltas for a published workflow run. */ -export const get73 = oc +export const get76 = oc .route({ description: 'Server-Sent Events stream of inspector deltas for a published workflow run.', inputStructure: 'detailed', @@ -4037,13 +4252,13 @@ export const get73 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse) export const events2 = { - get: get73, + get: get76, } /** * Full value for one declared output of a published run. */ -export const get74 = oc +export const get77 = oc .route({ description: 'Full value for one declared output of a published run.', inputStructure: 'detailed', @@ -4063,18 +4278,18 @@ export const get74 = oc zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse, ) -export const preview4 = { - get: get74, +export const preview5 = { + get: get77, } export const byOutputName2 = { - preview: preview4, + preview: preview5, } /** * One node's declared outputs for a published workflow run. */ -export const get75 = oc +export const get78 = oc .route({ description: 'One node\'s declared outputs for a published workflow run.', inputStructure: 'detailed', @@ -4087,14 +4302,14 @@ export const get75 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponse) export const byNodeId10 = { - get: get75, + get: get78, byOutputName: byOutputName2, } /** * Snapshot of every node's declared outputs for a published workflow run. */ -export const get76 = oc +export const get79 = oc .route({ description: 'Snapshot of every node\'s declared outputs for a published workflow run.', inputStructure: 'detailed', @@ -4107,7 +4322,7 @@ export const get76 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponse) export const nodeOutputs2 = { - get: get76, + get: get79, events: events2, byNodeId: byNodeId10, } @@ -4127,7 +4342,7 @@ export const published = { /** * Get webhook trigger for a node */ -export const get77 = oc +export const get80 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4145,7 +4360,7 @@ export const get77 = oc .output(zGetAppsByAppIdWorkflowsTriggersWebhookResponse) export const webhook = { - get: get77, + get: get80, } export const triggers2 = { @@ -4155,7 +4370,7 @@ export const triggers2 = { /** * Restore a published workflow version into the draft workflow */ -export const post62 = oc +export const post64 = oc .route({ description: 'Restore a published workflow version into the draft workflow', inputStructure: 'detailed', @@ -4168,13 +4383,13 @@ export const post62 = oc .output(zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse) export const restore = { - post: post62, + post: post64, } /** * Delete workflow */ -export const delete11 = oc +export const delete13 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -4211,7 +4426,7 @@ export const patch3 = oc .output(zPatchAppsByAppIdWorkflowsByWorkflowIdResponse) export const byWorkflowId = { - delete: delete11, + delete: delete13, patch: patch3, restore, } @@ -4221,7 +4436,7 @@ export const byWorkflowId = { * * Get all published workflows for an application */ -export const get78 = oc +export const get81 = oc .route({ description: 'Get all published workflows for an application', inputStructure: 'detailed', @@ -4240,7 +4455,7 @@ export const get78 = oc .output(zGetAppsByAppIdWorkflowsResponse) export const workflows3 = { - get: get78, + get: get81, defaultWorkflowBlockConfigs, draft: draft2, publish, @@ -4254,7 +4469,7 @@ export const workflows3 = { * * Delete application */ -export const delete12 = oc +export const delete14 = oc .route({ description: 'Delete application', inputStructure: 'detailed', @@ -4273,7 +4488,7 @@ export const delete12 = oc * * Get application details */ -export const get79 = oc +export const get82 = oc .route({ description: 'Get application details', inputStructure: 'detailed', @@ -4305,8 +4520,8 @@ export const put7 = oc .output(zPutAppsByAppIdResponse) export const byAppId2 = { - delete: delete12, - get: get79, + delete: delete14, + get: get82, put: put7, advancedChat, agentComposer, @@ -4355,7 +4570,7 @@ export const byAppId2 = { * * Delete an API key for an app */ -export const delete13 = oc +export const delete15 = oc .route({ description: 'Delete an API key for an app', inputStructure: 'detailed', @@ -4370,7 +4585,7 @@ export const delete13 = oc .output(zDeleteAppsByResourceIdApiKeysByApiKeyIdResponse) export const byApiKeyId = { - delete: delete13, + delete: delete15, } /** @@ -4378,7 +4593,7 @@ export const byApiKeyId = { * * Get all API keys for an app */ -export const get80 = oc +export const get83 = oc .route({ description: 'Get all API keys for an app', inputStructure: 'detailed', @@ -4396,7 +4611,7 @@ export const get80 = oc * * Create a new API key for an app */ -export const post63 = oc +export const post65 = oc .route({ description: 'Create a new API key for an app', inputStructure: 'detailed', @@ -4411,8 +4626,8 @@ export const post63 = oc .output(zPostAppsByResourceIdApiKeysResponse) export const apiKeys = { - get: get80, - post: post63, + get: get83, + post: post65, byApiKeyId, } @@ -4423,7 +4638,7 @@ export const byResourceId = { /** * Refresh MCP server configuration and regenerate server code */ -export const get81 = oc +export const get84 = oc .route({ description: 'Refresh MCP server configuration and regenerate server code', inputStructure: 'detailed', @@ -4436,7 +4651,7 @@ export const get81 = oc .output(zGetAppsByServerIdServerRefreshResponse) export const refresh = { - get: get81, + get: get84, } export const server2 = { @@ -4452,7 +4667,7 @@ export const byServerId = { * * Get list of applications with pagination and filtering */ -export const get82 = oc +export const get85 = oc .route({ description: 'Get list of applications with pagination and filtering', inputStructure: 'detailed', @@ -4470,7 +4685,7 @@ export const get82 = oc * * Create a new application */ -export const post64 = oc +export const post66 = oc .route({ description: 'Create a new application', inputStructure: 'detailed', @@ -4485,8 +4700,8 @@ export const post64 = oc .output(zPostAppsResponse) export const apps = { - get: get82, - post: post64, + get: get85, + post: post66, imports, workflows, byAppId: byAppId2, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 3e9a51822c..e5ab8f4a86 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -272,6 +272,37 @@ export type SandboxUploadResponse = { path: string } +export type AgentDriveListResponse = { + items?: Array +} + +export type AgentDriveDownloadResponse = { + url: string +} + +export type AgentDrivePreviewResponse = { + binary: boolean + key: string + size?: number | null + text?: string | null + truncated: boolean +} + +export type AgentDriveDeleteResponse = { + config_version_id?: string | null + removed_keys?: Array + result: string +} + +export type AgentDriveFilePayload = { + upload_file_id: string +} + +export type AgentDriveFileCommitResponse = { + config_version_id?: string | null + file: AgentDriveFileResponse +} + export type AgentLogResponse = { files?: Array iterations: Array @@ -279,11 +310,19 @@ export type AgentLogResponse = { } export type AgentSkillStandardizeResponse = { - [key: string]: unknown + manifest: SkillManifest + skill: AgentSkillRefConfig } export type AgentSkillUploadResponse = { - [key: string]: unknown + manifest: SkillManifest + skill: AgentSkillRefConfig +} + +export type SkillToolInferenceResult = { + cli_tools?: Array + inferable: boolean + reason?: string | null } export type AnnotationReplyPayload = { @@ -1405,6 +1444,23 @@ export type SandboxToolFileResponse = { transfer_method?: 'tool_file' } +export type AgentDriveItemResponse = { + created_at?: number | null + file_kind: string + hash?: string | null + key: string + mime_type?: string | null + size?: number | null +} + +export type AgentDriveFileResponse = { + drive_key: string + file_id: string + mime_type?: string | null + name: string + size?: number | null +} + export type AgentIterationLogResponse = { created_at: string files?: Array @@ -1426,6 +1482,38 @@ export type AgentLogMetaResponse = { total_tokens: number } +export type SkillManifest = { + description: string + entry_path: string + files: Array + hash: string + name: string + size: number +} + +export type AgentSkillRefConfig = { + description?: string | null + file_id?: string | null + full_archive_file_id?: string | null + full_archive_key?: string | null + id?: string | null + manifest_files?: Array | null + name?: string | null + path?: string | null + skill_md_file_id?: string | null + skill_md_key?: string | null + [key: string]: unknown +} + +export type CliToolSuggestion = { + command?: string + description?: string + env_suggestions?: Array + inferred_from?: string + install_commands?: Array + name: string +} + export type AnnotationEmbeddingModelResponse = { embedding_model_name?: string | null embedding_provider_name?: string | null @@ -2018,6 +2106,7 @@ export type AgentCliToolConfig = { enabled?: boolean env?: AgentCliToolEnvConfig id?: string | null + inferred_from?: string | null install?: string | null install_command?: string | null install_commands?: Array @@ -2057,14 +2146,20 @@ export type AgentKnowledgeDatasetConfig = { export type AgentComposerSkillCandidateResponse = { description?: string | null file_id?: string | null + full_archive_file_id?: string | null + full_archive_key?: string | null id?: string | null kind?: 'skill' + manifest_files?: Array | null name?: string | null path?: string | null + skill_md_file_id?: string | null + skill_md_key?: string | null [key: string]: unknown } export type AgentComposerFileCandidateResponse = { + drive_key?: string | null file_id?: string | null id?: string | null kind?: 'file' @@ -2105,6 +2200,12 @@ export type AgentToolCallResponse = { } } +export type EnvSuggestion = { + key: string + reason?: string + secret_likely?: boolean +} + export type SimpleModelConfig = { model_dict?: JsonValue | null pre_prompt?: string | null @@ -2302,6 +2403,7 @@ export type AgentSandboxProviderConfig = { } export type AgentFileRefConfig = { + drive_key?: string | null file_id?: string | null id?: string | null name?: string | null @@ -2315,15 +2417,6 @@ export type AgentFileRefConfig = { [key: string]: unknown } -export type AgentSkillRefConfig = { - description?: string | null - file_id?: string | null - id?: string | null - name?: string | null - path?: string | null - [key: string]: unknown -} - export type AgentSoulDifyToolConfig = { credential_ref?: AgentSoulDifyToolCredentialRef | null credential_type?: 'api-key' | 'oauth2' | 'unauthorized' @@ -3045,6 +3138,100 @@ export type PostAppsByAppIdAgentSandboxFilesUploadResponses = { export type PostAppsByAppIdAgentSandboxFilesUploadResponse = PostAppsByAppIdAgentSandboxFilesUploadResponses[keyof PostAppsByAppIdAgentSandboxFilesUploadResponses] +export type GetAppsByAppIdAgentDriveFilesData = { + body?: never + path: { + app_id: string + } + query?: { + node_id?: string + prefix?: string + } + url: '/apps/{app_id}/agent/drive/files' +} + +export type GetAppsByAppIdAgentDriveFilesResponses = { + 200: AgentDriveListResponse +} + +export type GetAppsByAppIdAgentDriveFilesResponse + = GetAppsByAppIdAgentDriveFilesResponses[keyof GetAppsByAppIdAgentDriveFilesResponses] + +export type GetAppsByAppIdAgentDriveFilesDownloadData = { + body?: never + path: { + app_id: string + } + query: { + key: string + node_id?: string + } + url: '/apps/{app_id}/agent/drive/files/download' +} + +export type GetAppsByAppIdAgentDriveFilesDownloadResponses = { + 200: AgentDriveDownloadResponse +} + +export type GetAppsByAppIdAgentDriveFilesDownloadResponse + = GetAppsByAppIdAgentDriveFilesDownloadResponses[keyof GetAppsByAppIdAgentDriveFilesDownloadResponses] + +export type GetAppsByAppIdAgentDriveFilesPreviewData = { + body?: never + path: { + app_id: string + } + query: { + key: string + node_id?: string + } + url: '/apps/{app_id}/agent/drive/files/preview' +} + +export type GetAppsByAppIdAgentDriveFilesPreviewResponses = { + 200: AgentDrivePreviewResponse +} + +export type GetAppsByAppIdAgentDriveFilesPreviewResponse + = GetAppsByAppIdAgentDriveFilesPreviewResponses[keyof GetAppsByAppIdAgentDriveFilesPreviewResponses] + +export type DeleteAppsByAppIdAgentFilesData = { + body?: never + path: { + app_id: string + } + query: { + key: string + node_id?: string + } + url: '/apps/{app_id}/agent/files' +} + +export type DeleteAppsByAppIdAgentFilesResponses = { + 200: AgentDriveDeleteResponse +} + +export type DeleteAppsByAppIdAgentFilesResponse + = DeleteAppsByAppIdAgentFilesResponses[keyof DeleteAppsByAppIdAgentFilesResponses] + +export type PostAppsByAppIdAgentFilesData = { + body: AgentDriveFilePayload + path: { + app_id: string + } + query?: { + node_id?: string + } + url: '/apps/{app_id}/agent/files' +} + +export type PostAppsByAppIdAgentFilesResponses = { + 201: AgentDriveFileCommitResponse +} + +export type PostAppsByAppIdAgentFilesResponse + = PostAppsByAppIdAgentFilesResponses[keyof PostAppsByAppIdAgentFilesResponses] + export type GetAppsByAppIdAgentLogsData = { body?: never path: { @@ -3073,7 +3260,9 @@ export type PostAppsByAppIdAgentSkillsStandardizeData = { path: { app_id: string } - query?: never + query?: { + node_id?: string + } url: '/apps/{app_id}/agent/skills/standardize' } @@ -3108,6 +3297,44 @@ export type PostAppsByAppIdAgentSkillsUploadResponses = { export type PostAppsByAppIdAgentSkillsUploadResponse = PostAppsByAppIdAgentSkillsUploadResponses[keyof PostAppsByAppIdAgentSkillsUploadResponses] +export type DeleteAppsByAppIdAgentSkillsBySlugData = { + body?: never + path: { + app_id: string + slug: string + } + query?: { + node_id?: string + } + url: '/apps/{app_id}/agent/skills/{slug}' +} + +export type DeleteAppsByAppIdAgentSkillsBySlugResponses = { + 200: AgentDriveDeleteResponse +} + +export type DeleteAppsByAppIdAgentSkillsBySlugResponse + = DeleteAppsByAppIdAgentSkillsBySlugResponses[keyof DeleteAppsByAppIdAgentSkillsBySlugResponses] + +export type PostAppsByAppIdAgentSkillsBySlugInferToolsData = { + body?: never + path: { + app_id: string + slug: string + } + query?: { + node_id?: string + } + url: '/apps/{app_id}/agent/skills/{slug}/infer-tools' +} + +export type PostAppsByAppIdAgentSkillsBySlugInferToolsResponses = { + 200: SkillToolInferenceResult +} + +export type PostAppsByAppIdAgentSkillsBySlugInferToolsResponse + = PostAppsByAppIdAgentSkillsBySlugInferToolsResponses[keyof PostAppsByAppIdAgentSkillsBySlugInferToolsResponses] + export type PostAppsByAppIdAnnotationReplyByActionData = { body: AnnotationReplyPayload path: { diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index be612550ad..4e559a6629 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -125,14 +125,38 @@ export const zAgentSandboxUploadPayload = z.object({ }) /** - * AgentSkillStandardizeResponse + * AgentDriveDownloadResponse */ -export const zAgentSkillStandardizeResponse = z.record(z.string(), z.unknown()) +export const zAgentDriveDownloadResponse = z.object({ + url: z.string(), +}) /** - * AgentSkillUploadResponse + * AgentDrivePreviewResponse */ -export const zAgentSkillUploadResponse = z.record(z.string(), z.unknown()) +export const zAgentDrivePreviewResponse = z.object({ + binary: z.boolean(), + key: z.string(), + size: z.int().nullish(), + text: z.string().nullish(), + truncated: z.boolean(), +}) + +/** + * AgentDriveDeleteResponse + */ +export const zAgentDriveDeleteResponse = z.object({ + config_version_id: z.string().nullish(), + removed_keys: z.array(z.string()).optional(), + result: z.string(), +}) + +/** + * AgentDriveFilePayload + */ +export const zAgentDriveFilePayload = z.object({ + upload_file_id: z.string(), +}) /** * AnnotationReplyPayload @@ -1053,6 +1077,44 @@ export const zSandboxUploadResponse = z.object({ path: z.string(), }) +/** + * AgentDriveItemResponse + */ +export const zAgentDriveItemResponse = z.object({ + created_at: z.int().nullish(), + file_kind: z.string(), + hash: z.string().nullish(), + key: z.string(), + mime_type: z.string().nullish(), + size: z.int().nullish(), +}) + +/** + * AgentDriveListResponse + */ +export const zAgentDriveListResponse = z.object({ + items: z.array(zAgentDriveItemResponse).optional(), +}) + +/** + * AgentDriveFileResponse + */ +export const zAgentDriveFileResponse = z.object({ + drive_key: z.string(), + file_id: z.string(), + mime_type: z.string().nullish(), + name: z.string(), + size: z.int().nullish(), +}) + +/** + * AgentDriveFileCommitResponse + */ +export const zAgentDriveFileCommitResponse = z.object({ + config_version_id: z.string().nullish(), + file: zAgentDriveFileResponse, +}) + /** * AgentLogMetaResponse */ @@ -1066,6 +1128,52 @@ export const zAgentLogMetaResponse = z.object({ total_tokens: z.int(), }) +/** + * SkillManifest + * + * Validated metadata extracted from a Skill package. + */ +export const zSkillManifest = z.object({ + description: z.string(), + entry_path: z.string(), + files: z.array(z.string()), + hash: z.string(), + name: z.string(), + size: z.int(), +}) + +/** + * AgentSkillRefConfig + */ +export const zAgentSkillRefConfig = z.object({ + description: z.string().nullish(), + file_id: z.string().max(255).nullish(), + full_archive_file_id: z.string().max(255).nullish(), + full_archive_key: z.string().max(512).nullish(), + id: z.string().max(255).nullish(), + manifest_files: z.array(z.string()).nullish(), + name: z.string().max(255).nullish(), + path: z.string().nullish(), + skill_md_file_id: z.string().max(255).nullish(), + skill_md_key: z.string().max(512).nullish(), +}) + +/** + * AgentSkillStandardizeResponse + */ +export const zAgentSkillStandardizeResponse = z.object({ + manifest: zSkillManifest, + skill: zAgentSkillRefConfig, +}) + +/** + * AgentSkillUploadResponse + */ +export const zAgentSkillUploadResponse = z.object({ + manifest: zSkillManifest, + skill: zAgentSkillRefConfig, +}) + /** * AnnotationEmbeddingModelResponse */ @@ -2207,16 +2315,22 @@ export const zAgentKnowledgeDatasetConfig = z.object({ export const zAgentComposerSkillCandidateResponse = z.object({ description: z.string().nullish(), file_id: z.string().max(255).nullish(), + full_archive_file_id: z.string().max(255).nullish(), + full_archive_key: z.string().max(512).nullish(), id: z.string().max(255).nullish(), kind: z.literal('skill').optional().default('skill'), + manifest_files: z.array(z.string()).nullish(), name: z.string().max(255).nullish(), path: z.string().nullish(), + skill_md_file_id: z.string().max(255).nullish(), + skill_md_key: z.string().max(512).nullish(), }) /** * AgentComposerFileCandidateResponse */ export const zAgentComposerFileCandidateResponse = z.object({ + drive_key: z.string().max(512).nullish(), file_id: z.string().max(255).nullish(), id: z.string().max(255).nullish(), kind: z.literal('file').optional().default('file'), @@ -2266,6 +2380,36 @@ export const zAgentLogResponse = z.object({ meta: zAgentLogMetaResponse, }) +/** + * EnvSuggestion + */ +export const zEnvSuggestion = z.object({ + key: z.string(), + reason: z.string().optional().default(''), + secret_likely: z.boolean().optional().default(false), +}) + +/** + * CliToolSuggestion + */ +export const zCliToolSuggestion = z.object({ + command: z.string().optional().default(''), + description: z.string().optional().default(''), + env_suggestions: z.array(zEnvSuggestion).optional(), + inferred_from: z.string().optional().default(''), + install_commands: z.array(z.string()).optional(), + name: z.string(), +}) + +/** + * SkillToolInferenceResult + */ +export const zSkillToolInferenceResult = z.object({ + cli_tools: z.array(zCliToolSuggestion).optional(), + inferable: z.boolean(), + reason: z.string().nullish(), +}) + /** * SimpleModelConfig */ @@ -2659,6 +2803,7 @@ export const zAgentSoulSandboxConfig = z.object({ * AgentFileRefConfig */ export const zAgentFileRefConfig = z.object({ + drive_key: z.string().max(512).nullish(), file_id: z.string().max(255).nullish(), id: z.string().max(255).nullish(), name: z.string().max(255).nullish(), @@ -2671,25 +2816,6 @@ export const zAgentFileRefConfig = z.object({ url: z.string().nullish(), }) -/** - * WorkflowNodeJobMetadata - */ -export const zWorkflowNodeJobMetadata = z.object({ - agent_soul: z.record(z.string(), z.unknown()).nullish(), - file_refs: z.array(zAgentFileRefConfig).nullish(), -}) - -/** - * AgentSkillRefConfig - */ -export const zAgentSkillRefConfig = z.object({ - description: z.string().nullish(), - file_id: z.string().max(255).nullish(), - id: z.string().max(255).nullish(), - name: z.string().max(255).nullish(), - path: z.string().nullish(), -}) - /** * AgentSoulSkillsFilesConfig */ @@ -2698,6 +2824,14 @@ export const zAgentSoulSkillsFilesConfig = z.object({ skills: z.array(zAgentSkillRefConfig).optional(), }) +/** + * WorkflowNodeJobMetadata + */ +export const zWorkflowNodeJobMetadata = z.object({ + agent_soul: z.record(z.string(), z.unknown()).nullish(), + file_refs: z.array(zAgentFileRefConfig).nullish(), +}) + /** * AgentCliToolAuthorizationStatus * @@ -2783,6 +2917,7 @@ export const zAgentCliToolConfig = z.object({ enabled: z.boolean().optional().default(true), env: zAgentCliToolEnvConfig.optional(), id: z.string().max(255).nullish(), + inferred_from: z.string().max(255).nullish(), install: z.string().nullish(), install_command: z.string().nullish(), install_commands: z.array(z.string()).optional(), @@ -3766,6 +3901,77 @@ export const zPostAppsByAppIdAgentSandboxFilesUploadPath = z.object({ */ export const zPostAppsByAppIdAgentSandboxFilesUploadResponse = zSandboxUploadResponse +export const zGetAppsByAppIdAgentDriveFilesPath = z.object({ + app_id: z.string(), +}) + +export const zGetAppsByAppIdAgentDriveFilesQuery = z.object({ + node_id: z.string().optional(), + prefix: z.string().optional().default(''), +}) + +/** + * Drive entries + */ +export const zGetAppsByAppIdAgentDriveFilesResponse = zAgentDriveListResponse + +export const zGetAppsByAppIdAgentDriveFilesDownloadPath = z.object({ + app_id: z.string(), +}) + +export const zGetAppsByAppIdAgentDriveFilesDownloadQuery = z.object({ + key: z.string().min(1), + node_id: z.string().optional(), +}) + +/** + * Signed URL + */ +export const zGetAppsByAppIdAgentDriveFilesDownloadResponse = zAgentDriveDownloadResponse + +export const zGetAppsByAppIdAgentDriveFilesPreviewPath = z.object({ + app_id: z.string(), +}) + +export const zGetAppsByAppIdAgentDriveFilesPreviewQuery = z.object({ + key: z.string().min(1), + node_id: z.string().optional(), +}) + +/** + * Preview + */ +export const zGetAppsByAppIdAgentDriveFilesPreviewResponse = zAgentDrivePreviewResponse + +export const zDeleteAppsByAppIdAgentFilesPath = z.object({ + app_id: z.string(), +}) + +export const zDeleteAppsByAppIdAgentFilesQuery = z.object({ + key: z.string().min(1), + node_id: z.string().optional(), +}) + +/** + * File removed + */ +export const zDeleteAppsByAppIdAgentFilesResponse = zAgentDriveDeleteResponse + +export const zPostAppsByAppIdAgentFilesBody = zAgentDriveFilePayload + +export const zPostAppsByAppIdAgentFilesPath = z.object({ + app_id: z.string(), +}) + +export const zPostAppsByAppIdAgentFilesQuery = z.object({ + node_id: z.string().optional(), +}) + +/** + * File committed into the agent drive + */ +export const zPostAppsByAppIdAgentFilesResponse = zAgentDriveFileCommitResponse + export const zGetAppsByAppIdAgentLogsPath = z.object({ app_id: z.string(), }) @@ -3784,6 +3990,10 @@ export const zPostAppsByAppIdAgentSkillsStandardizePath = z.object({ app_id: z.string(), }) +export const zPostAppsByAppIdAgentSkillsStandardizeQuery = z.object({ + node_id: z.string().optional(), +}) + /** * Skill standardized into drive */ @@ -3798,6 +4008,34 @@ export const zPostAppsByAppIdAgentSkillsUploadPath = z.object({ */ export const zPostAppsByAppIdAgentSkillsUploadResponse = zAgentSkillUploadResponse +export const zDeleteAppsByAppIdAgentSkillsBySlugPath = z.object({ + app_id: z.string(), + slug: z.string(), +}) + +export const zDeleteAppsByAppIdAgentSkillsBySlugQuery = z.object({ + node_id: z.string().optional(), +}) + +/** + * Skill removed + */ +export const zDeleteAppsByAppIdAgentSkillsBySlugResponse = zAgentDriveDeleteResponse + +export const zPostAppsByAppIdAgentSkillsBySlugInferToolsPath = z.object({ + app_id: z.string(), + slug: z.string(), +}) + +export const zPostAppsByAppIdAgentSkillsBySlugInferToolsQuery = z.object({ + node_id: z.string().optional(), +}) + +/** + * Inference result (draft suggestions, nothing persisted) + */ +export const zPostAppsByAppIdAgentSkillsBySlugInferToolsResponse = zSkillToolInferenceResult + export const zPostAppsByAppIdAnnotationReplyByActionBody = zAnnotationReplyPayload export const zPostAppsByAppIdAnnotationReplyByActionPath = z.object({