From a80bba2c35dd0a4bcccb938c8f9698df10c494dd Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Tue, 9 Jun 2026 12:01:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20Agent=20Files=20/=20agent=20Clou?= =?UTF-8?q?d=20storage=20=E2=80=94=20api=20backend=20(ENG-589)=20(#37172)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/app/agent.py | 85 +++- api/controllers/inner_api/__init__.py | 2 + .../inner_api/plugin/agent_drive.py | 80 ++++ api/controllers/inner_api/plugin/plugin.py | 32 ++ .../apps/agent_app/runtime_request_builder.py | 11 +- api/core/plugin/entities/request.py | 14 + api/core/workflow/node_factory.py | 12 +- .../workflow/nodes/agent_v2/agent_node.py | 2 + .../nodes/agent_v2/file_tenant_validator.py | 32 +- .../workflow/nodes/agent_v2/output_adapter.py | 114 ++++-- .../nodes/agent_v2/output_file_rebacker.py | 63 +++ .../nodes/agent_v2/output_type_checker.py | 6 +- .../nodes/agent_v2/runtime_request_builder.py | 15 +- ...7d_add_agent_drive_files_agent_drive_kv.py | 45 +++ api/models/__init__.py | 4 + api/models/agent.py | 47 +++ api/openapi/markdown/console-swagger.md | 50 +++ api/services/agent/skill_package_service.py | 212 ++++++++++ .../agent/skill_standardize_service.py | 123 ++++++ api/services/agent_drive_service.py | 364 ++++++++++++++++++ api/services/agent_file_request_service.py | 93 +++++ .../console/app/test_agent_skills.py | 106 +++++ .../inner_api/plugin/test_agent_drive.py | 95 +++++ .../app/apps/agent_app/test_app_runner.py | 10 +- .../agent_app/test_runtime_request_builder.py | 8 +- .../agent_v2/test_file_tenant_validator.py | 33 +- .../nodes/agent_v2/test_output_adapter.py | 77 +++- .../agent_v2/test_output_file_rebacker.py | 73 ++++ .../agent_v2/test_output_type_checker.py | 14 + .../agent_v2/test_runtime_request_builder.py | 8 +- .../agent/test_skill_package_service.py | 136 +++++++ .../agent/test_skill_standardize_service.py | 77 ++++ .../services/test_agent_drive_service.py | 339 ++++++++++++++++ .../test_agent_file_request_service.py | 105 +++++ .../layers/execution_context/__init__.py | 4 + .../layers/execution_context/configs.py | 40 +- .../layers/execution_context/test_configs.py | 23 ++ .../generated/api/console/apps/orpc.gen.ts | 270 ++++++++----- .../generated/api/console/apps/types.gen.ts | 54 +++ .../generated/api/console/apps/zod.gen.ts | 18 + 40 files changed, 2730 insertions(+), 166 deletions(-) create mode 100644 api/controllers/inner_api/plugin/agent_drive.py create mode 100644 api/core/workflow/nodes/agent_v2/output_file_rebacker.py create mode 100644 api/migrations/versions/2026_06_08_1339-7bad07dc267d_add_agent_drive_files_agent_drive_kv.py create mode 100644 api/services/agent/skill_package_service.py create mode 100644 api/services/agent/skill_standardize_service.py create mode 100644 api/services/agent_drive_service.py create mode 100644 api/services/agent_file_request_service.py create mode 100644 api/tests/unit_tests/controllers/console/app/test_agent_skills.py create mode 100644 api/tests/unit_tests/controllers/inner_api/plugin/test_agent_drive.py create mode 100644 api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_file_rebacker.py create mode 100644 api/tests/unit_tests/services/agent/test_skill_package_service.py create mode 100644 api/tests/unit_tests/services/agent/test_skill_standardize_service.py create mode 100644 api/tests/unit_tests/services/test_agent_drive_service.py create mode 100644 api/tests/unit_tests/services/test_agent_file_request_service.py diff --git a/api/controllers/console/app/agent.py b/api/controllers/console/app/agent.py index 277c86ced3..2c3adb6a01 100644 --- a/api/controllers/console/app/agent.py +++ b/api/controllers/console/app/agent.py @@ -5,11 +5,17 @@ from pydantic import BaseModel, Field, field_validator from controllers.common.schema import 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 +from controllers.console.wraps import account_initialization_required, setup_required, with_current_user +from extensions.ext_database import db 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 services.agent.skill_standardize_service import SkillStandardizeService +from services.agent_drive_service import AgentDriveError from services.agent_service import AgentService +from services.file_service import FileService class AgentLogQuery(BaseModel): @@ -44,3 +50,80 @@ class AgentLogApi(Resource): args = AgentLogQuery.model_validate(request.args.to_dict(flat=True)) return AgentService.get_agent_logs(app_model, args.conversation_id, args.message_id) + + +@console_ns.route("/apps//agent/skills/upload") +class AgentSkillUploadApi(Resource): + @console_ns.doc("upload_agent_skill") + @console_ns.doc(description="Upload + validate a Skill package (.zip/.skill) and extract its manifest") + @console_ns.doc(params={"app_id": "Application ID"}) + @console_ns.response(201, "Skill validated") + @console_ns.response(400, "Invalid skill package") + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.AGENT]) + @with_current_user + def post(self, current_user: Account, app_model: App): + """Validate an uploaded Skill package and persist the archive. + + 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. + """ + if "file" not in request.files: + return {"code": "no_file", "message": "no skill file uploaded"}, 400 + if len(request.files) > 1: + return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400 + + upload = request.files["file"] + content = upload.stream.read() + try: + manifest = SkillPackageService().validate_and_extract(content=content, filename=upload.filename or "") + except SkillPackageError as exc: + return {"code": exc.code, "message": exc.message}, exc.status_code + + upload_file = FileService(db.engine).upload_file( + filename=upload.filename or "skill.zip", + content=content, + mimetype=upload.mimetype or "application/zip", + user=current_user, + ) + skill_ref = manifest.to_skill_ref(file_id=upload_file.id) + return {"skill": skill_ref.model_dump(exclude_none=True), "manifest": manifest.model_dump()}, 201 + + +@console_ns.route("/apps//agent/skills/standardize") +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.response(201, "Skill standardized into drive") + @console_ns.response(400, "Invalid skill package or no bound agent") + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.AGENT]) + @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 + if not agent_id: + return {"code": "no_bound_agent", "message": "app has no bound agent"}, 400 + if "file" not in request.files: + return {"code": "no_file", "message": "no skill file uploaded"}, 400 + if len(request.files) > 1: + return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400 + + upload = request.files["file"] + content = upload.stream.read() + try: + result = SkillStandardizeService().standardize( + content=content, + filename=upload.filename or "", + tenant_id=app_model.tenant_id, + user_id=current_user.id, + agent_id=agent_id, + ) + except (SkillPackageError, AgentDriveError) as exc: + return {"code": exc.code, "message": exc.message}, exc.status_code + return result, 201 diff --git a/api/controllers/inner_api/__init__.py b/api/controllers/inner_api/__init__.py index b38994f055..c782c93ffd 100644 --- a/api/controllers/inner_api/__init__.py +++ b/api/controllers/inner_api/__init__.py @@ -17,12 +17,14 @@ inner_api_ns = Namespace("inner_api", description="Internal API operations", pat from . import mail as _mail from .app import dsl as _app_dsl +from .plugin import agent_drive as _agent_drive from .plugin import plugin as _plugin from .workspace import workspace as _workspace api.add_namespace(inner_api_ns) __all__ = [ + "_agent_drive", "_app_dsl", "_mail", "_plugin", diff --git a/api/controllers/inner_api/plugin/agent_drive.py b/api/controllers/inner_api/plugin/agent_drive.py new file mode 100644 index 0000000000..a80caea3c5 --- /dev/null +++ b/api/controllers/inner_api/plugin/agent_drive.py @@ -0,0 +1,80 @@ +"""Inner API for the agent drive (agent 网盘) control plane — ENG-591. + +Two endpoints, called by the dify-agent server (not the sandbox) with the inner +API key. The drive ref is the URL segment ``agent-``; the path-like +file key travels in the query/body, never as a URL path segment (so its ``/`` +characters do not collide with routing). Drive-owned semantics: tenant scoped, +no user-level FileAccessScope. +""" + +from flask import request +from flask_restx import Resource +from pydantic import BaseModel, ValidationError + +from controllers.console.wraps import setup_required +from controllers.inner_api import inner_api_ns +from controllers.inner_api.wraps import plugin_inner_api_only +from services.agent_drive_service import ( + AgentDriveError, + AgentDriveService, + DriveCommitItem, + parse_agent_drive_ref, +) + + +class _CommitRequest(BaseModel): + tenant_id: str + user_id: str + items: list[DriveCommitItem] + + +def _error_response(exc: AgentDriveError) -> tuple[dict[str, str], int]: + return {"code": exc.code, "message": exc.message}, exc.status_code + + +@inner_api_ns.route("/drive//manifest") +class AgentDriveManifestApi(Resource): + @setup_required + @plugin_inner_api_only + @inner_api_ns.doc("agent_drive_manifest") + @inner_api_ns.doc(description="List an agent drive (optionally with download URLs)") + def get(self, drive_ref: str): + try: + agent_id = parse_agent_drive_ref(drive_ref) + tenant_id = (request.args.get("tenant_id") or "").strip() + if not tenant_id: + raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400) + include_download_url = (request.args.get("include_download_url") or "").lower() in ("1", "true", "yes") + items = AgentDriveService().manifest( + tenant_id=tenant_id, + agent_id=agent_id, + prefix=request.args.get("prefix", ""), + include_download_url=include_download_url, + ) + except AgentDriveError as exc: + return _error_response(exc) + return {"items": items} + + +@inner_api_ns.route("/drive//commit") +class AgentDriveCommitApi(Resource): + @setup_required + @plugin_inner_api_only + @inner_api_ns.doc("agent_drive_commit") + @inner_api_ns.doc(description="Commit a batch of file refs into an agent drive") + def post(self, drive_ref: str): + try: + agent_id = parse_agent_drive_ref(drive_ref) + try: + body = _CommitRequest.model_validate(request.get_json(silent=True) or {}) + except ValidationError as exc: + raise AgentDriveError("invalid_request", str(exc), status_code=400) from exc + items = AgentDriveService().commit( + tenant_id=body.tenant_id, + user_id=body.user_id, + agent_id=agent_id, + items=body.items, + ) + except AgentDriveError as exc: + return _error_response(exc) + return {"items": items} diff --git a/api/controllers/inner_api/plugin/plugin.py b/api/controllers/inner_api/plugin/plugin.py index 72cab3de73..f445cbecc6 100644 --- a/api/controllers/inner_api/plugin/plugin.py +++ b/api/controllers/inner_api/plugin/plugin.py @@ -25,6 +25,7 @@ from core.plugin.entities.request import ( RequestInvokeTextEmbedding, RequestInvokeTool, RequestInvokeTTS, + RequestRequestDownloadFile, RequestRequestUploadFile, ) from core.tools.entities.tool_entities import ToolProviderType @@ -33,6 +34,7 @@ from graphon.model_runtime.utils.encoders import jsonable_encoder from libs.helper import length_prefixed_response from models import Account, Tenant from models.model import EndUser +from services.agent_file_request_service import AgentFileDownloadRequestService, FileDownloadRequestError @inner_api_ns.route("/invoke/llm") @@ -429,6 +431,36 @@ class PluginUploadFileRequestApi(Resource): return BaseBackwardsInvocationResponse(data={"url": url}).model_dump() +@inner_api_ns.route("/download/file/request") +class PluginDownloadFileRequestApi(Resource): + @get_user_tenant + @setup_required + @plugin_inner_api_only + @plugin_data(payload_type=RequestRequestDownloadFile) + @inner_api_ns.doc("plugin_download_file_request") + @inner_api_ns.doc(description="Request a signed download URL for a workflow file ref") + @inner_api_ns.doc( + responses={ + 200: "Signed download URL generated successfully", + 400: "Invalid access context or file mapping", + 401: "Unauthorized - invalid API key", + 404: "File not accessible to the tenant/user", + } + ) + def post(self, user_model: Account | EndUser, tenant_model: Tenant, payload: RequestRequestDownloadFile): + try: + data = AgentFileDownloadRequestService.resolve( + tenant_id=tenant_model.id, + user_id=user_model.id, + user_from=payload.user_from, + invoke_from=payload.invoke_from, + file_mapping=payload.file, + ) + except FileDownloadRequestError as exc: + return BaseBackwardsInvocationResponse(error=exc.message).model_dump(), exc.status_code + return BaseBackwardsInvocationResponse(data=data).model_dump() + + @inner_api_ns.route("/fetch/app/info") class PluginFetchAppInfoApi(Resource): @get_user_tenant 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 ca269a9fe8..9d93161a98 100644 --- a/api/core/app/apps/agent_app/runtime_request_builder.py +++ b/api/core/app/apps/agent_app/runtime_request_builder.py @@ -13,7 +13,11 @@ from dataclasses import dataclass from typing import Any, Protocol, cast from agenton.compositor import CompositorSessionSnapshot -from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig +from dify_agent.layers.execution_context import ( + DifyExecutionContextInvokeFrom, + DifyExecutionContextLayerConfig, + DifyExecutionContextUserFrom, +) from dify_agent.protocol import CreateRunRequest from clients.agent_backend import ( @@ -126,7 +130,10 @@ class AgentAppRuntimeRequestBuilder: conversation_id=context.conversation_id, agent_id=context.agent_id, agent_config_version_id=context.agent_config_snapshot_id, - invoke_from="agent_app", + # Agent Files §1.3: real Dify access context + agent run mode. + user_from=cast(DifyExecutionContextUserFrom, context.dify_context.user_from.value), + invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value), + agent_mode="agent_app", ), agent_soul_prompt=agent_soul.prompt.system_prompt or None, user_prompt=context.user_query, diff --git a/api/core/plugin/entities/request.py b/api/core/plugin/entities/request.py index 1474883204..06b39c9fd5 100644 --- a/api/core/plugin/entities/request.py +++ b/api/core/plugin/entities/request.py @@ -231,6 +231,20 @@ class RequestRequestUploadFile(BaseModel): mimetype: str +class RequestRequestDownloadFile(BaseModel): + """Request a signed download URL for a workflow file ref (Agent Files §3.1.1). + + ``user_from`` / ``invoke_from`` are the flattened Dify file-access context (the + dify-agent server reads them from the execution context). ``file`` is a standard + file mapping: ``transfer_method`` plus ``reference`` (local_file / tool_file / + datasource_file) or ``url`` (remote_url). + """ + + user_from: str + invoke_from: str + file: Mapping[str, Any] + + class RequestFetchAppInfo(BaseModel): """ Request to fetch app info diff --git a/api/core/workflow/node_factory.py b/api/core/workflow/node_factory.py index ae578788ea..bf85fc1918 100644 --- a/api/core/workflow/node_factory.py +++ b/api/core/workflow/node_factory.py @@ -479,8 +479,9 @@ class DifyNodeFactory(NodeFactory): if issubclass(node_class, DifyAgentNode): from clients.agent_backend import AgentBackendRunEventAdapter, AgentBackendRunRequestBuilder from clients.agent_backend.factory import create_agent_backend_run_client - from core.workflow.nodes.agent_v2.file_tenant_validator import UploadFileTenantValidator + from core.workflow.nodes.agent_v2.file_tenant_validator import AgentOutputFileTenantValidator from core.workflow.nodes.agent_v2.output_failure_orchestrator import OutputFailureOrchestrator + from core.workflow.nodes.agent_v2.output_file_rebacker import reback_tool_file_output from core.workflow.nodes.agent_v2.output_type_checker import PerOutputTypeChecker from core.workflow.nodes.agent_v2.session_store import WorkflowAgentRuntimeSessionStore @@ -496,11 +497,12 @@ class DifyNodeFactory(NodeFactory): fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO, ), "event_adapter": AgentBackendRunEventAdapter(), - "output_adapter": WorkflowAgentOutputAdapter(), + # Agent Files §4.6: reback file outputs from the ToolFile row so + # downstream metadata is authoritative, not sandbox-provided. + "output_adapter": WorkflowAgentOutputAdapter(tool_file_rebacker=reback_tool_file_output), # Stage 4 §5/§7: per-output validation + failure orchestration. The - # tenant validator queries upload_files so it stays cheap when - # outputs contain no file refs. - "type_checker": PerOutputTypeChecker(file_validator=UploadFileTenantValidator()), + # tenant validator resolves ToolFile (canonical) + UploadFile refs. + "type_checker": PerOutputTypeChecker(file_validator=AgentOutputFileTenantValidator()), "failure_orchestrator": OutputFailureOrchestrator(), "session_store": WorkflowAgentRuntimeSessionStore(), } diff --git a/api/core/workflow/nodes/agent_v2/agent_node.py b/api/core/workflow/nodes/agent_v2/agent_node.py index cf5baf251a..d5c478c8ac 100644 --- a/api/core/workflow/nodes/agent_v2/agent_node.py +++ b/api/core/workflow/nodes/agent_v2/agent_node.py @@ -312,6 +312,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]): inputs=inputs, process_data=process_data, metadata=metadata, + tenant_id=dify_ctx.tenant_id, ) ) return @@ -342,6 +343,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]): inputs=inputs, process_data=process_data, metadata=metadata, + tenant_id=dify_ctx.tenant_id, ) ) return diff --git a/api/core/workflow/nodes/agent_v2/file_tenant_validator.py b/api/core/workflow/nodes/agent_v2/file_tenant_validator.py index 69b5297829..404d1036b6 100644 --- a/api/core/workflow/nodes/agent_v2/file_tenant_validator.py +++ b/api/core/workflow/nodes/agent_v2/file_tenant_validator.py @@ -1,13 +1,12 @@ """Tenant-scope validator for file refs produced by Agent backend outputs. -Stage 4 §5.3: every file output the Agent backend produces must resolve to an -``upload_files`` row that belongs to the current tenant; cross-tenant file -references must never be plumbed downstream. ``PerOutputTypeChecker`` accepts a -``FileTenantValidator`` Protocol so unit tests can stub the check without -hitting Postgres. - -This module supplies the production implementation that queries the -``upload_files`` table via SQLAlchemy. +Stage 4 §5.3 / Agent Files §4.6: every file output the Agent backend produces +must resolve to a file record owned by the current tenant; cross-tenant file +references must never be plumbed downstream. Agent runtime output files are +canonically ``ToolFile`` (referenced by a minimal ``{id}``), so this validator +checks ``tool_files`` first and falls back to ``upload_files`` for compatibility +with older/manual refs. ``PerOutputTypeChecker`` accepts a ``FileTenantValidator`` +Protocol so unit tests can stub the check without hitting Postgres. """ from __future__ import annotations @@ -19,10 +18,11 @@ from sqlalchemy.exc import DataError, SQLAlchemyError from core.db.session_factory import session_factory from models.model import UploadFile +from models.tools import ToolFile -class UploadFileTenantValidator: - """Production ``FileTenantValidator`` backed by the ``upload_files`` table. +class AgentOutputFileTenantValidator: + """Production ``FileTenantValidator`` backed by ``tool_files`` + ``upload_files``. Returns ``False`` (rejects the file) on any pathological input: empty file_id/tenant_id, non-UUID file_id format, DB errors. The Agent backend @@ -40,7 +40,15 @@ class UploadFileTenantValidator: return False try: with session_factory.create_session() as session: - owner_tenant_id = session.scalar(select(UploadFile.tenant_id).where(UploadFile.id == file_id)) + # Agent output files are canonically ToolFile; check it first. + tool_owner = session.scalar(select(ToolFile.tenant_id).where(ToolFile.id == file_id)) + if tool_owner is not None: + return tool_owner == tenant_id + upload_owner = session.scalar(select(UploadFile.tenant_id).where(UploadFile.id == file_id)) except (DataError, SQLAlchemyError): return False - return owner_tenant_id == tenant_id + return upload_owner == tenant_id + + +# Back-compat alias for callers/tests that imported the upload-only name. +UploadFileTenantValidator = AgentOutputFileTenantValidator diff --git a/api/core/workflow/nodes/agent_v2/output_adapter.py b/api/core/workflow/nodes/agent_v2/output_adapter.py index 0aecfec4a6..3204bb3051 100644 --- a/api/core/workflow/nodes/agent_v2/output_adapter.py +++ b/api/core/workflow/nodes/agent_v2/output_adapter.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from typing import Any from clients.agent_backend import ( @@ -21,6 +21,13 @@ from graphon.variables.segments import ArrayFileSegment, FileSegment class WorkflowAgentOutputAdapter: """Convert terminal Agent backend events into workflow node run results.""" + def __init__(self, *, tool_file_rebacker: Callable[..., File | None] | None = None) -> None: + # Agent Files §4.6: resolve a bare ToolFile id into a graphon File whose + # metadata comes from the ToolFile row (not the untrusted sandbox payload). + # Injected so unit tests can stub it without DB access; None keeps the + # legacy payload-only behaviour for non-file or rich-payload outputs. + self._tool_file_rebacker = tool_file_rebacker + def build_success_result( self, *, @@ -28,6 +35,7 @@ class WorkflowAgentOutputAdapter: inputs: dict[str, Any], process_data: dict[str, Any], metadata: dict[str, Any], + tenant_id: str | None = None, ) -> NodeRunResult: metadata = self._with_terminal_metadata(metadata, event, "succeeded") usage = self._usage_from_metadata(metadata) @@ -35,7 +43,7 @@ class WorkflowAgentOutputAdapter: status=WorkflowNodeExecutionStatus.SUCCEEDED, inputs=inputs, process_data=process_data, - outputs=self._normalize_outputs(event.output), + outputs=self._normalize_outputs(event.output, tenant_id=tenant_id), metadata=self._build_node_metadata(metadata=metadata, usage=usage), llm_usage=usage or LLMUsage.empty_usage(), ) @@ -101,49 +109,93 @@ class WorkflowAgentOutputAdapter: error_type="agent_backend_stream_error", ) - @classmethod - def _normalize_outputs(cls, output: Any) -> dict[str, Any]: + def _normalize_outputs(self, output: Any, *, tenant_id: str | None) -> dict[str, Any]: if isinstance(output, dict): - if cls._is_file_payload(output): - return {"file": cls._file_segment_from_payload(output)} - return {key: cls._normalize_output_value(value) for key, value in output.items()} + if self._is_file_payload(output): + file = self._file_from_payload(output, tenant_id=tenant_id) + if file is not None: + return {"file": FileSegment(value=file)} + return {key: self._normalize_output_value(value, tenant_id=tenant_id) for key, value in output.items()} if isinstance(output, str): return {"text": output} return {"result": output} - @classmethod - def _normalize_output_value(cls, value: Any) -> Any: + def _normalize_output_value(self, value: Any, *, tenant_id: str | None) -> Any: if isinstance(value, File | FileSegment | ArrayFileSegment): return value if isinstance(value, Mapping): - if cls._is_file_payload(value): - return cls._file_segment_from_payload(value) - return {key: cls._normalize_output_value(item) for key, item in value.items()} + if self._is_file_payload(value): + file = self._file_from_payload(value, tenant_id=tenant_id) + if file is not None: + return FileSegment(value=file) + # A bare ref that did not resolve to a tenant file: treat as a plain object. + return {key: self._normalize_output_value(item, tenant_id=tenant_id) for key, item in value.items()} if isinstance(value, list): - if value and all(isinstance(item, Mapping) and cls._is_file_payload(item) for item in value): - return ArrayFileSegment(value=[cls._file_from_payload(item) for item in value]) - return [cls._normalize_output_value(item) for item in value] + if value and all(isinstance(item, Mapping) and self._is_file_payload(item) for item in value): + files = [self._file_from_payload(item, tenant_id=tenant_id) for item in value] + if all(file is not None for file in files): + return ArrayFileSegment(value=[file for file in files if file is not None]) + return [self._normalize_output_value(item, tenant_id=tenant_id) for item in value] return value - @staticmethod - def _is_file_payload(value: Mapping[str, Any]) -> bool: - return any(value.get(key) for key in ("file_id", "upload_file_id", "tool_file_id", "url", "remote_url")) and ( - "filename" in value or "mime_type" in value or "url" in value or "remote_url" in value + # Keys a file-output ref may legitimately carry. A dict is treated as a file + # ref only if it has an id/url AND every key is one of these — so a bare + # ``{"id": "..."}`` (Agent Files §4.6 canonical) is recognized while ordinary + # business objects that merely contain an ``id`` field are not. + _FILE_FIELD_KEYS: frozenset[str] = frozenset( + { + "id", + "file_id", + "upload_file_id", + "tool_file_id", + "url", + "remote_url", + "filename", + "name", + "mime_type", + "mimetype", + "extension", + "size", + "type", + "file_type", + } + ) + + @classmethod + def _is_file_payload(cls, value: Mapping[str, Any]) -> bool: + has_ref = any( + isinstance(value.get(key), str) and value.get(key) + for key in ("id", "file_id", "upload_file_id", "tool_file_id", "url", "remote_url") ) + return has_ref and all(key in cls._FILE_FIELD_KEYS for key in value) - @classmethod - def _file_segment_from_payload(cls, value: Mapping[str, Any]) -> FileSegment: - return FileSegment(value=cls._file_from_payload(value)) + @staticmethod + def _is_rich_payload(value: Mapping[str, Any]) -> bool: + """The payload carries its own metadata, so it can build a File without DB reback.""" + return any(value.get(key) for key in ("filename", "name", "mime_type", "mimetype", "url", "remote_url")) - @classmethod - def _file_from_payload(cls, value: Mapping[str, Any]) -> File: - remote_url = cls._string_value(value.get("remote_url") or value.get("url")) - upload_file_id = cls._string_value(value.get("upload_file_id") or value.get("file_id")) - tool_file_id = cls._string_value(value.get("tool_file_id")) - filename = cls._string_value(value.get("filename") or value.get("name")) - mime_type = cls._string_value(value.get("mime_type") or value.get("mimetype")) - extension = cls._extension_from_payload(value, filename) - file_type = cls._file_type_from_payload(value, mime_type) + def _file_from_payload(self, value: Mapping[str, Any], *, tenant_id: str | None) -> File | None: + # Canonical Agent output file is a ToolFile referenced by ``id`` (or the + # ``tool_file_id`` alias). Reback its metadata authoritatively from the + # ToolFile row instead of trusting the sandbox payload. + tool_file_id = self._string_value(value.get("tool_file_id") or value.get("id")) + remote_url = self._string_value(value.get("remote_url") or value.get("url")) + upload_file_id = self._string_value(value.get("upload_file_id") or value.get("file_id")) + + if tool_file_id and self._tool_file_rebacker is not None and tenant_id: + rebacked = self._tool_file_rebacker(tenant_id=tenant_id, tool_file_id=tool_file_id) + if rebacked is not None: + return rebacked + + # No authoritative reback: only build a File from the payload when it + # actually carries file metadata; a bare unresolved id is not a file. + if not self._is_rich_payload(value): + return None + + filename = self._string_value(value.get("filename") or value.get("name")) + mime_type = self._string_value(value.get("mime_type") or value.get("mimetype")) + extension = self._extension_from_payload(value, filename) + file_type = self._file_type_from_payload(value, mime_type) size = value.get("size") if not isinstance(size, int): size = -1 diff --git a/api/core/workflow/nodes/agent_v2/output_file_rebacker.py b/api/core/workflow/nodes/agent_v2/output_file_rebacker.py new file mode 100644 index 0000000000..ac39583626 --- /dev/null +++ b/api/core/workflow/nodes/agent_v2/output_file_rebacker.py @@ -0,0 +1,63 @@ +"""Reback an Agent backend file output (a bare ``ToolFile`` id) into a graphon File. + +Agent Files §4.6: an agent run returns output files referenced only by id +(``{"id": ""}``). The authoritative ``filename`` / ``mime_type`` / +``extension`` / ``size`` come from the ``ToolFile`` row, never from the +(untrusted) sandbox payload. This module resolves a tenant-owned ToolFile id +into a full graphon ``File`` so downstream workflow consumers get correct, +trustworthy metadata. +""" + +from __future__ import annotations + +from mimetypes import guess_extension +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.exc import DataError, SQLAlchemyError + +from core.db.session_factory import session_factory +from core.workflow.file_reference import build_file_reference +from graphon.file import File, FileTransferMethod, get_file_type_by_mime_type +from models.tools import ToolFile + + +def reback_tool_file_output(*, tenant_id: str, tool_file_id: str) -> File | None: + """Build a graphon File from a ToolFile id owned by ``tenant_id``. + + Returns ``None`` when the id is empty/malformed or does not resolve to a + ToolFile owned by the tenant (the caller then treats the value as a plain + object rather than fabricating a file with empty metadata). + """ + if not tool_file_id or not tenant_id: + return None + try: + UUID(tool_file_id) + except (ValueError, TypeError): + return None + try: + with session_factory.create_session() as session: + tool_file = session.scalar( + select(ToolFile).where(ToolFile.id == tool_file_id, ToolFile.tenant_id == tenant_id) + ) + except (DataError, SQLAlchemyError): + return None + if tool_file is None: + return None + + mime_type = tool_file.mimetype or "" + extension = guess_extension(mime_type) or ".bin" + return File( + type=get_file_type_by_mime_type(mime_type), + transfer_method=FileTransferMethod.TOOL_FILE, + remote_url=None, + reference=build_file_reference(record_id=str(tool_file.id)), + related_id=tool_file.id, + filename=tool_file.name, + extension=extension, + mime_type=mime_type or None, + size=tool_file.size, + ) + + +__all__ = ["reback_tool_file_output"] diff --git a/api/core/workflow/nodes/agent_v2/output_type_checker.py b/api/core/workflow/nodes/agent_v2/output_type_checker.py index 50981f3faa..b31472c5a2 100644 --- a/api/core/workflow/nodes/agent_v2/output_type_checker.py +++ b/api/core/workflow/nodes/agent_v2/output_type_checker.py @@ -78,9 +78,9 @@ class FileTenantValidator(Protocol): def is_owned_by_tenant(self, *, file_id: str, tenant_id: str) -> bool: ... -# Recognized aliases the Agent backend (or pydantic-ai) may produce for the -# canonical file id field. The canonical spec form is ``file_id`` (§5.2). -_FILE_ID_KEYS: tuple[str, ...] = ("file_id", "upload_file_id", "tool_file_id") +# Recognized id fields in a file-output ref. Agent Files §4.6: the canonical +# minimal form is ``{"id": ""}``; the rest are accepted aliases. +_FILE_ID_KEYS: tuple[str, ...] = ("id", "file_id", "upload_file_id", "tool_file_id") class PerOutputTypeChecker: 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 b6b9cccd2a..4ddb096c8c 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -5,7 +5,11 @@ from dataclasses import dataclass from typing import Any, Literal, Protocol, assert_never, cast from agenton.compositor import CompositorSessionSnapshot -from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig +from dify_agent.layers.execution_context import ( + DifyExecutionContextInvokeFrom, + DifyExecutionContextLayerConfig, + DifyExecutionContextUserFrom, +) from dify_agent.layers.shell import ( DifyShellCliToolConfig, DifyShellEnvVarConfig, @@ -178,7 +182,12 @@ class WorkflowAgentRuntimeRequestBuilder: conversation_id=get_system_text(context.variable_pool, SystemVariableKey.CONVERSATION_ID), agent_id=context.agent.id, agent_config_version_id=context.snapshot.id, - invoke_from=self._agent_backend_invoke_from(context.dify_context.invoke_from), + # Agent Files §1.3: forward the real Dify access context + # (user_from + invoke_from) so downstream file/drive inner APIs + # can rebuild it; the agent run mode moves to agent_mode. + user_from=cast(DifyExecutionContextUserFrom, context.dify_context.user_from.value), + invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value), + agent_mode=self._agent_mode(context.dify_context.invoke_from), ), agent_soul_prompt=agent_soul.prompt.system_prompt or None, workflow_node_job_prompt=workflow_job_prompt, @@ -202,7 +211,7 @@ class WorkflowAgentRuntimeRequestBuilder: ) @staticmethod - def _agent_backend_invoke_from(invoke_from: InvokeFrom) -> Literal["workflow_run", "single_step"]: + def _agent_mode(invoke_from: InvokeFrom) -> Literal["workflow_run", "single_step"]: if invoke_from in {InvokeFrom.DEBUGGER, InvokeFrom.VALIDATION}: return "single_step" return "workflow_run" diff --git a/api/migrations/versions/2026_06_08_1339-7bad07dc267d_add_agent_drive_files_agent_drive_kv.py b/api/migrations/versions/2026_06_08_1339-7bad07dc267d_add_agent_drive_files_agent_drive_kv.py new file mode 100644 index 0000000000..c05ff006ed --- /dev/null +++ b/api/migrations/versions/2026_06_08_1339-7bad07dc267d_add_agent_drive_files_agent_drive_kv.py @@ -0,0 +1,45 @@ +"""add agent_drive_files (agent drive KV) + +Revision ID: 7bad07dc267d +Revises: 3df4dbcc1e21 +Create Date: 2026-06-08 13:39:15.150738 + +""" +from alembic import op +import models as models +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '7bad07dc267d' +down_revision = '3df4dbcc1e21' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + 'agent_drive_files', + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('agent_id', models.types.StringUUID(), nullable=False), + sa.Column('key', sa.String(length=512), nullable=False), + sa.Column('file_kind', sa.String(length=32), nullable=False), + sa.Column('file_id', models.types.StringUUID(), nullable=False), + sa.Column('value_owned_by_drive', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('size', sa.BigInteger(), nullable=True), + sa.Column('hash', sa.String(length=255), nullable=True), + sa.Column('mime_type', sa.String(length=255), nullable=True), + sa.Column('created_by', models.types.StringUUID(), nullable=True), + sa.Column('id', models.types.StringUUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='agent_drive_file_pkey'), + sa.UniqueConstraint('tenant_id', 'agent_id', 'key', name='agent_drive_file_scope_key_unique'), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('agent_drive_files') + # ### end Alembic commands ### diff --git a/api/models/__init__.py b/api/models/__init__.py index 4fdcda34ff..55bc642566 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -13,6 +13,8 @@ from .agent import ( AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot, + AgentDriveFile, + AgentDriveFileKind, AgentIconType, AgentKind, AgentRuntimeSession, @@ -153,6 +155,8 @@ __all__ = [ "AgentConfigRevision", "AgentConfigRevisionOperation", "AgentConfigSnapshot", + "AgentDriveFile", + "AgentDriveFileKind", "AgentIconType", "AgentKind", "AgentRuntimeSession", diff --git a/api/models/agent.py b/api/models/agent.py index b9bc162d31..8062ca6fe0 100644 --- a/api/models/agent.py +++ b/api/models/agent.py @@ -389,3 +389,50 @@ class AgentRuntimeSession(DefaultFieldsMixin, Base): # Back-compat alias for the shipped workflow lifecycle code (PR #36724). WorkflowAgentRuntimeSession = AgentRuntimeSession + + +class AgentDriveFileKind(StrEnum): + """Kind of existing file record an agent-drive KV entry points at.""" + + UPLOAD_FILE = "upload_file" + TOOL_FILE = "tool_file" + + +class AgentDriveFile(DefaultFieldsMixin, Base): + """Per-agent path-like KV index into existing file records (agent 网盘 / agent drive). + + A row maps a path-like ``key`` to a *pointer* (``file_kind`` + ``file_id``) at an + existing ``UploadFile`` / ``ToolFile`` — it never stores file bytes. Scope/ownership + is ``tenant_id -> agent-`` (the drive ref; no standalone ``drive_id`` this + phase). ``key`` is opaque/path-like and carries no directory, permission, or + parent-child semantics on the API side; it maps 1:1 to a sandbox-relative path when + synced. ``value_owned_by_drive`` gates physical cleanup: only drive-owned values + (created by the agent runtime or Skill standardization, not shared with other + business records) have their storage object + record deleted when the KV entry is + overwritten or removed; otherwise only the KV row is dropped. Lifecycle never relies + on ``UploadFile.used/used_by`` (not a reliable refcount). + """ + + __tablename__ = "agent_drive_files" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="agent_drive_file_pkey"), + UniqueConstraint("tenant_id", "agent_id", "key", name="agent_drive_file_scope_key_unique"), + ) + + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + # drive ref = agent-; this phase has no standalone drive_id. + agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + # path-like opaque key; not a filesystem (no dir/permission/parent semantics). + # Bounded at 512 so the (tenant_id, agent_id, key) unique index stays within + # MySQL's 3072-byte index limit (CHAR(36)*2 + VARCHAR(512) utf8mb4 = 2336). + key: Mapped[str] = mapped_column(String(512), nullable=False) + file_kind: Mapped[AgentDriveFileKind] = mapped_column(EnumText(AgentDriveFileKind, length=32), nullable=False) + # points at UploadFile.id / ToolFile.id (the value), never the bytes. + file_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + value_owned_by_drive: Mapped[bool] = mapped_column( + sa.Boolean, nullable=False, default=False, server_default=sa.text("false") + ) + size: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True) + hash: Mapped[str | None] = mapped_column(String(255), nullable=True) + mime_type: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True) diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index b6ae0d57a2..ad216c0d9c 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -1192,6 +1192,56 @@ Get agent execution logs for an application | 200 | Agent logs retrieved successfully | [ object ] | | 400 | Invalid request parameters | | +### /apps/{app_id}/agent/skills/standardize + +#### POST +##### Summary + +Upload a Skill, validate it, and standardize it into the app agent's drive + +##### Description + +Validate + standardize a Skill into the agent drive (ENG-594) + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 201 | Skill standardized into drive | +| 400 | Invalid skill package or no bound agent | + +### /apps/{app_id}/agent/skills/upload + +#### POST +##### Summary + +Validate an uploaded Skill package and persist the archive + +##### Description + +Upload + validate a Skill package (.zip/.skill) and extract its manifest +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. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 201 | Skill validated | +| 400 | Invalid skill package | + ### /apps/{app_id}/annotation-reply/{action} #### POST diff --git a/api/services/agent/skill_package_service.py b/api/services/agent/skill_package_service.py new file mode 100644 index 0000000000..61bc6dd0ed --- /dev/null +++ b/api/services/agent/skill_package_service.py @@ -0,0 +1,212 @@ +"""Validate + extract metadata from an uploaded Skill package (ENG-370). + +A Skill is a ``.zip`` / ``.skill`` archive that must contain a ``SKILL.md`` entry +file (Anthropic Skills convention: YAML frontmatter with ``name`` + ``description``, +followed by markdown instructions). This service validates the archive (extension, +size, zip integrity, zip-slip safety, SKILL.md presence/encoding/fields) and +extracts a manifest the API can bind to an Agent config version's skill list. + +It does NOT execute or load the skill — the agent backend owns execution. It also +does not (here) standardize the package into the agent drive; that is ENG-594 (S6), +which consumes the manifest produced here. +""" + +from __future__ import annotations + +import hashlib +import io +import posixpath +import re +import zipfile + +import yaml +from pydantic import BaseModel + +from models.agent_config_entities import AgentSkillRefConfig + +# Bounds — generous but finite so a hostile upload can't exhaust memory/disk. +_MAX_ARCHIVE_BYTES = 50 * 1024 * 1024 +_MAX_UNCOMPRESSED_BYTES = 200 * 1024 * 1024 +_MAX_SKILL_MD_BYTES = 1 * 1024 * 1024 +_MAX_ENTRIES = 5000 +_ALLOWED_EXTENSIONS = (".zip", ".skill") +_SKILL_MD_NAME = "SKILL.md" +_HEADING_RE = re.compile(r"^\s*#\s+(.+?)\s*$", re.MULTILINE) + + +class SkillPackageError(Exception): + """A skill-package validation failure mapped to an HTTP status by the controller.""" + + code: str + message: str + status_code: int + + def __init__(self, code: str, message: str, *, status_code: int = 400) -> None: + super().__init__(message) + self.code = code + self.message = message + self.status_code = status_code + + +class SkillManifest(BaseModel): + """Validated metadata extracted from a Skill package.""" + + name: str + description: str + entry_path: str # path of SKILL.md inside the archive + files: list[str] # all (safe) file paths inside the archive + size: int # total uncompressed bytes + hash: str # sha256 of the archive bytes + + def to_skill_ref(self, *, file_id: str, path: str | None = None) -> AgentSkillRefConfig: + """Build a config skill ref. ``path`` is the stable drive path (set by S6).""" + return AgentSkillRefConfig.model_validate( + { + "id": self.hash, + "name": self.name, + "description": self.description, + "file_id": file_id, + "path": path, + "size": self.size, + "hash": self.hash, + "entry_path": self.entry_path, + } + ) + + +class SkillPackageService: + """Validate Skill archives and extract their manifest.""" + + def validate_and_extract(self, *, content: bytes, filename: str) -> SkillManifest: + self._check_extension(filename) + if not content: + raise SkillPackageError("empty_archive", "skill archive is empty", status_code=400) + if len(content) > _MAX_ARCHIVE_BYTES: + raise SkillPackageError("archive_too_large", "skill archive exceeds size limit", status_code=400) + + try: + archive = zipfile.ZipFile(io.BytesIO(content)) + except zipfile.BadZipFile as exc: + raise SkillPackageError("invalid_archive", "skill archive is not a valid zip", status_code=400) from exc + + with archive: + infos = [info for info in archive.infolist() if not info.is_dir()] + if len(infos) > _MAX_ENTRIES: + raise SkillPackageError("too_many_entries", "skill archive has too many files", status_code=400) + + safe_paths: list[str] = [] + total_uncompressed = 0 + for info in infos: + safe_paths.append(self._safe_member_path(info.filename)) + total_uncompressed += max(info.file_size, 0) + if total_uncompressed > _MAX_UNCOMPRESSED_BYTES: + raise SkillPackageError( + "archive_too_large", "skill archive uncompressed size exceeds limit", status_code=400 + ) + + entry_path = self._find_skill_md(safe_paths) + skill_md = self._read_skill_md(archive, entry_path) + + name, description = self._parse_skill_md(skill_md) + return SkillManifest( + name=name, + description=description, + entry_path=entry_path, + files=sorted(safe_paths), + size=total_uncompressed, + hash=hashlib.sha256(content).hexdigest(), + ) + + def read_member_bytes(self, *, content: bytes, member_path: str) -> bytes: + """Read a single archive member's bytes (used by standardization, ENG-594).""" + try: + archive = zipfile.ZipFile(io.BytesIO(content)) + except zipfile.BadZipFile as exc: + raise SkillPackageError("invalid_archive", "skill archive is not a valid zip", status_code=400) from exc + with archive: + member = next( + (info for info in archive.infolist() if posixpath.normpath(info.filename) == member_path), + None, + ) + if member is None: + raise SkillPackageError("member_not_found", f"{member_path} not found in archive", status_code=400) + return archive.read(member) + + @staticmethod + def _check_extension(filename: str) -> None: + lowered = (filename or "").lower() + if not lowered.endswith(_ALLOWED_EXTENSIONS): + raise SkillPackageError( + "unsupported_extension", + f"skill must be one of {', '.join(_ALLOWED_EXTENSIONS)}", + status_code=400, + ) + + @staticmethod + def _safe_member_path(name: str) -> str: + """Reject zip-slip and normalize the archive member path.""" + if "\x00" in name or "\\" in name: + raise SkillPackageError("unsafe_path", "skill archive contains an unsafe path", status_code=400) + normalized = posixpath.normpath(name) + if normalized.startswith("/") or normalized == ".." or normalized.startswith("../"): + raise SkillPackageError("unsafe_path", "skill archive contains an unsafe path", status_code=400) + return normalized + + @staticmethod + def _find_skill_md(paths: list[str]) -> str: + candidates = [p for p in paths if posixpath.basename(p) == _SKILL_MD_NAME] + if not candidates: + raise SkillPackageError("missing_skill_md", "skill archive must contain a SKILL.md", status_code=400) + # Prefer the shallowest SKILL.md (skill root). + return min(candidates, key=lambda p: (p.count("/"), len(p))) + + @staticmethod + def _read_skill_md(archive: zipfile.ZipFile, entry_path: str) -> str: + # Look the member up by its original name (normpath may differ from the stored name). + member = next( + (info for info in archive.infolist() if posixpath.normpath(info.filename) == entry_path), + None, + ) + if member is None: + raise SkillPackageError("missing_skill_md", "skill archive must contain a SKILL.md", status_code=400) + if member.file_size > _MAX_SKILL_MD_BYTES: + raise SkillPackageError("skill_md_too_large", "SKILL.md exceeds size limit", status_code=400) + raw = archive.read(member) + try: + return raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise SkillPackageError("skill_md_not_utf8", "SKILL.md must be UTF-8 encoded", status_code=400) from exc + + @classmethod + def _parse_skill_md(cls, content: str) -> tuple[str, str]: + if not content.strip(): + raise SkillPackageError("empty_skill_md", "SKILL.md is empty", status_code=400) + frontmatter = cls._parse_frontmatter(content) + name = str(frontmatter.get("name") or "").strip() + description = str(frontmatter.get("description") or "").strip() + if not name: + heading = _HEADING_RE.search(content) + name = heading.group(1).strip() if heading else "" + if not name: + raise SkillPackageError( + "missing_skill_name", "SKILL.md must declare a name (frontmatter or top heading)", status_code=400 + ) + return name, description + + @staticmethod + def _parse_frontmatter(content: str) -> dict[str, object]: + if not content.startswith("---"): + return {} + parts = content.split("---", 2) + if len(parts) < 3: + return {} + try: + loaded = yaml.safe_load(parts[1]) + except yaml.YAMLError as exc: + raise SkillPackageError( + "invalid_frontmatter", "SKILL.md frontmatter is not valid YAML", status_code=400 + ) from exc + return loaded if isinstance(loaded, dict) else {} + + +__all__ = ["SkillManifest", "SkillPackageError", "SkillPackageService"] diff --git a/api/services/agent/skill_standardize_service.py b/api/services/agent/skill_standardize_service.py new file mode 100644 index 0000000000..bd80d03168 --- /dev/null +++ b/api/services/agent/skill_standardize_service.py @@ -0,0 +1,123 @@ +"""Standardize an uploaded Skill into the agent drive (ENG-594). + +A validated Skill package is normalized into two **drive-owned** objects committed +to the agent drive (Agent Files §5.4 / §4): + +* ``/SKILL.md`` — the canonical entry, the source of truth for loading. +* ``/.DIFY-SKILL-FULL.zip`` — the full archive, kept only to restore the + complete skill contents. + +Both are stored as ``ToolFile`` records and bound via ``AgentDriveService.commit`` +with ``value_owned_by_drive=True`` (the drive owns their lifecycle). The returned +skill ref records the stable drive paths + file ids (not just the raw upload id), +so the Composer can reload the bound skill list. +""" + +from __future__ import annotations + +import re +from typing import Any + +from core.tools.tool_file_manager import ToolFileManager +from models.agent_config_entities import AgentSkillRefConfig +from services.agent.skill_package_service import SkillPackageService +from services.agent_drive_service import AgentDriveService, DriveCommitItem, DriveFileRef + +_FULL_ARCHIVE_NAME = ".DIFY-SKILL-FULL.zip" +_SKILL_MD_NAME = "SKILL.md" +_SLUG_RE = re.compile(r"[^a-z0-9._-]+") + + +def slugify_skill_name(name: str) -> str: + slug = _SLUG_RE.sub("-", (name or "").strip().lower()).strip("-._") + return slug or "skill" + + +class SkillStandardizeService: + """Validate + standardize a Skill package into a per-agent drive.""" + + def __init__( + self, + *, + package_service: SkillPackageService | None = None, + drive_service: AgentDriveService | None = None, + tool_file_manager: ToolFileManager | None = None, + ) -> None: + self._package = package_service or SkillPackageService() + self._drive = drive_service or AgentDriveService() + self._tool_files = tool_file_manager or ToolFileManager() + + def standardize( + self, + *, + content: bytes, + filename: str, + tenant_id: str, + user_id: str, + agent_id: str, + ) -> dict[str, Any]: + manifest = self._package.validate_and_extract(content=content, filename=filename) + skill_md_bytes = self._package.read_member_bytes(content=content, member_path=manifest.entry_path) + slug = slugify_skill_name(manifest.name) + + # Two drive-owned ToolFiles: canonical SKILL.md + the full archive. + md_tool_file = self._tool_files.create_file_by_raw( + user_id=user_id, + tenant_id=tenant_id, + conversation_id=None, + file_binary=skill_md_bytes, + mimetype="text/markdown", + filename=_SKILL_MD_NAME, + ) + archive_tool_file = self._tool_files.create_file_by_raw( + user_id=user_id, + tenant_id=tenant_id, + conversation_id=None, + file_binary=content, + mimetype="application/zip", + filename=_FULL_ARCHIVE_NAME, + ) + + skill_md_key = f"{slug}/{_SKILL_MD_NAME}" + archive_key = f"{slug}/{_FULL_ARCHIVE_NAME}" + self._drive.commit( + tenant_id=tenant_id, + user_id=user_id, + agent_id=agent_id, + items=[ + DriveCommitItem( + key=skill_md_key, + file_ref=DriveFileRef(kind="tool_file", id=md_tool_file.id), + value_owned_by_drive=True, + ), + DriveCommitItem( + key=archive_key, + file_ref=DriveFileRef(kind="tool_file", id=archive_tool_file.id), + value_owned_by_drive=True, + ), + ], + ) + + skill_ref = AgentSkillRefConfig.model_validate( + { + "id": manifest.hash, + "name": manifest.name, + "description": manifest.description, + "file_id": archive_tool_file.id, + "path": slug, + "size": manifest.size, + "hash": manifest.hash, + "entry_path": skill_md_key, + "skill_md_file_id": md_tool_file.id, + "skill_md_key": skill_md_key, + "full_archive_file_id": archive_tool_file.id, + "full_archive_key": archive_key, + } + ) + return { + "skill": skill_ref.model_dump(exclude_none=True), + "manifest": manifest.model_dump(), + } + + +__all__ = ["SkillStandardizeService", "slugify_skill_name"] diff --git a/api/services/agent_drive_service.py b/api/services/agent_drive_service.py new file mode 100644 index 0000000000..74eb4e8751 --- /dev/null +++ b/api/services/agent_drive_service.py @@ -0,0 +1,364 @@ +"""Agent 网盘 (agent drive) service — list/manifest + commit with lifecycle (ENG-591). + +The agent drive is a per-agent path-like KV index over existing UploadFile / +ToolFile records (see ``AgentDriveFile``). This service is the control plane: + +* ``manifest`` lists a drive (optionally with download URLs). Download URLs use + **drive-owned** semantics — tenant-scoped resolution only, NOT a user-level + ``FileAccessScope`` (Agent Files §3.1.2). We reuse the standard + ``file_factory.build_from_mapping`` + ``resolve_file_url`` rebuild, which always + filters by ``tenant_id`` in the builders, so omitting the scope is safe. +* ``commit`` binds a batch of existing file refs to keys. Source ToolFiles must + belong to the current run user. Overwriting a key whose previous value is + ``value_owned_by_drive`` physically cleans the old value (storage + record), + unless another drive entry still references it. Re-committing the same + ``key -> file_ref`` is idempotent. +""" + +from __future__ import annotations + +import logging +import re +from typing import Any, Literal + +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy.exc import DataError, SQLAlchemyError +from sqlalchemy.orm import Session + +from core.app.file_access.controller import DatabaseFileAccessController +from core.app.workflow.file_runtime import DifyWorkflowFileRuntime +from core.db.session_factory import session_factory +from extensions.ext_storage import storage +from factories import file_factory +from libs.uuid_utils import uuidv7 +from models.agent import Agent, AgentDriveFile, AgentDriveFileKind +from models.model import UploadFile +from models.tools import ToolFile + +logger = logging.getLogger(__name__) + +_MAX_KEY_LENGTH = 512 +_DRIVE_REF_PREFIX = "agent-" + + +class AgentDriveError(Exception): + """A drive operation failure mapped to an HTTP status by the controller.""" + + code: str + message: str + status_code: int + + def __init__(self, code: str, message: str, *, status_code: int = 400) -> None: + super().__init__(message) + self.code = code + self.message = message + self.status_code = status_code + + +class DriveFileRef(BaseModel): + kind: Literal["upload_file", "tool_file"] + id: str + + +class DriveCommitItem(BaseModel): + key: str + file_ref: DriveFileRef + # Drive-owned values may be physically cleaned on overwrite/removal; refs to + # files shared with other business records should set this False. + value_owned_by_drive: bool = True + + +def parse_agent_drive_ref(drive_ref: str) -> str: + """Parse an ``agent-`` URL drive ref into the agent id.""" + if not drive_ref.startswith(_DRIVE_REF_PREFIX): + raise AgentDriveError("invalid_drive_ref", "drive ref must be 'agent-'", status_code=400) + agent_id = drive_ref[len(_DRIVE_REF_PREFIX) :] + if not agent_id: + raise AgentDriveError("invalid_drive_ref", "drive ref must include an agent id", status_code=400) + return agent_id + + +def normalize_drive_key(key: str) -> str: + """Validate + normalize a path-like drive key (Agent Files §6 key safety). + + The key maps back to a sandbox-relative file path, so reject anything that + could escape or break the path: empty, too long, NUL/control chars, absolute + paths, or ``..`` segments. Collapse repeated slashes and strip a leading one. + """ + if not isinstance(key, str) or not key.strip(): + raise AgentDriveError("invalid_key", "drive key must be a non-empty string", status_code=400) + if len(key) > _MAX_KEY_LENGTH: + raise AgentDriveError("invalid_key", f"drive key exceeds {_MAX_KEY_LENGTH} chars", status_code=400) + if "\x00" in key or any(ord(ch) < 0x20 for ch in key): + raise AgentDriveError("invalid_key", "drive key contains control characters", status_code=400) + normalized = re.sub(r"/{2,}", "/", key.strip()).lstrip("/") + segments = normalized.split("/") + if any(segment == ".." for segment in segments): + raise AgentDriveError("invalid_key", "drive key must not contain '..' segments", status_code=400) + if not normalized: + raise AgentDriveError("invalid_key", "drive key must be a non-empty path", status_code=400) + return normalized + + +class AgentDriveService: + """List/commit files in a per-agent drive (tenant_id -> agent-).""" + + def manifest( + self, + *, + tenant_id: str, + agent_id: str, + prefix: str = "", + include_download_url: bool = False, + ) -> list[dict[str, Any]]: + 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) + .order_by(AgentDriveFile.key) + ) + if prefix: + stmt = stmt.where(AgentDriveFile.key.startswith(prefix)) + rows = list(session.scalars(stmt)) + items: list[dict[str, Any]] = [] + for row in rows: + item: dict[str, Any] = { + "key": row.key, + "size": row.size, + "hash": row.hash, + "mime_type": row.mime_type, + "file_kind": row.file_kind.value, + "file_id": row.file_id, + } + if include_download_url: + item["download_url"] = self._resolve_download_url( + tenant_id=tenant_id, file_kind=row.file_kind, file_id=row.file_id + ) + items.append(item) + return items + + def commit( + self, + *, + tenant_id: str, + user_id: str, + agent_id: str, + items: list[DriveCommitItem], + ) -> list[dict[str, Any]]: + if not items: + raise AgentDriveError("empty_commit", "commit requires at least one item", status_code=400) + committed: list[dict[str, Any]] = [] + 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) + for item in items: + committed.append( + self._commit_one( + session, + tenant_id=tenant_id, + user_id=user_id, + agent_id=agent_id, + item=item, + pending_storage_deletes=pending_storage_deletes, + ) + ) + session.commit() + for storage_key in pending_storage_deletes: + self._delete_storage(storage_key) + return committed + + def _commit_one( + self, + session: Session, + *, + tenant_id: str, + user_id: str, + agent_id: str, + item: DriveCommitItem, + pending_storage_deletes: list[str], + ) -> dict[str, Any]: + key = normalize_drive_key(item.key) + file_kind = AgentDriveFileKind(item.file_ref.kind) + file_id = item.file_ref.id + size, mime_type = self._validate_source( + session, tenant_id=tenant_id, user_id=user_id, file_kind=file_kind, file_id=file_id + ) + + existing = session.scalar( + select(AgentDriveFile).where( + AgentDriveFile.tenant_id == tenant_id, + AgentDriveFile.agent_id == agent_id, + AgentDriveFile.key == key, + ) + ) + if existing is not None: + # Idempotent re-commit of the same value: leave it (do not clean). + if existing.file_kind == file_kind and existing.file_id == file_id: + existing.value_owned_by_drive = item.value_owned_by_drive + return self._row_dict(existing) + # Overwrite: clean the previous drive-owned value if no longer referenced. + if existing.value_owned_by_drive: + self._cleanup_value( + session, + tenant_id=tenant_id, + file_kind=existing.file_kind, + file_id=existing.file_id, + exclude_row_id=existing.id, + pending_storage_deletes=pending_storage_deletes, + ) + existing.file_kind = file_kind + existing.file_id = file_id + existing.value_owned_by_drive = item.value_owned_by_drive + existing.size = size + existing.mime_type = mime_type + return self._row_dict(existing) + + row = AgentDriveFile( + id=str(uuidv7()), + tenant_id=tenant_id, + agent_id=agent_id, + key=key, + file_kind=file_kind, + file_id=file_id, + value_owned_by_drive=item.value_owned_by_drive, + size=size, + mime_type=mime_type, + created_by=user_id, + ) + session.add(row) + return self._row_dict(row) + + @staticmethod + def _row_dict(row: AgentDriveFile) -> dict[str, Any]: + return { + "key": row.key, + "file_kind": row.file_kind.value, + "file_id": row.file_id, + "size": row.size, + "mime_type": row.mime_type, + "value_owned_by_drive": row.value_owned_by_drive, + } + + @staticmethod + def _assert_agent_belongs_to_tenant(session: Session, *, tenant_id: str, agent_id: str) -> None: + try: + found_agent_id = session.scalar(select(Agent.id).where(Agent.id == agent_id, Agent.tenant_id == tenant_id)) + except (DataError, SQLAlchemyError) as exc: + session.rollback() + raise AgentDriveError( + "agent_not_found", "agent drive does not belong to this tenant", status_code=404 + ) from exc + if found_agent_id is None: + raise AgentDriveError("agent_not_found", "agent drive does not belong to this tenant", status_code=404) + + def _validate_source( + self, + session: Session, + *, + tenant_id: str, + user_id: str, + file_kind: AgentDriveFileKind, + file_id: str, + ) -> tuple[int | None, str | None]: + """Verify the source file exists for the tenant (and user, for ToolFile). + + Malformed ids (e.g. a non-UUID hitting a UUID column) are treated as a + missing source rather than crashing the commit with a 500. + """ + try: + if file_kind == AgentDriveFileKind.TOOL_FILE: + tool_file = session.scalar( + select(ToolFile).where( + ToolFile.id == file_id, + ToolFile.tenant_id == tenant_id, + ToolFile.user_id == user_id, + ) + ) + if tool_file is None: + raise AgentDriveError( + "source_not_found", "source ToolFile not found for this tenant/user", status_code=404 + ) + return tool_file.size, tool_file.mimetype + upload_file = session.scalar( + select(UploadFile).where(UploadFile.id == file_id, UploadFile.tenant_id == tenant_id) + ) + except (DataError, SQLAlchemyError) as exc: + session.rollback() + raise AgentDriveError("source_not_found", "source file ref is invalid", status_code=404) from exc + if upload_file is None: + raise AgentDriveError("source_not_found", "source UploadFile not found for this tenant", status_code=404) + return upload_file.size, upload_file.mime_type + + def _cleanup_value( + self, + session: Session, + *, + tenant_id: str, + file_kind: AgentDriveFileKind, + file_id: str, + exclude_row_id: str, + pending_storage_deletes: list[str], + ) -> None: + """Physically delete a drive-owned value, unless another drive entry references it.""" + still_referenced = session.scalar( + select(func.count()) + .select_from(AgentDriveFile) + .where( + AgentDriveFile.tenant_id == tenant_id, + AgentDriveFile.file_kind == file_kind, + AgentDriveFile.file_id == file_id, + AgentDriveFile.id != exclude_row_id, + ) + ) + if still_referenced: + return + if file_kind == AgentDriveFileKind.TOOL_FILE: + tool_file = session.scalar(select(ToolFile).where(ToolFile.id == file_id, ToolFile.tenant_id == tenant_id)) + if tool_file is not None: + pending_storage_deletes.append(tool_file.file_key) + session.delete(tool_file) + return + upload_file = session.scalar( + select(UploadFile).where(UploadFile.id == file_id, UploadFile.tenant_id == tenant_id) + ) + if upload_file is not None: + pending_storage_deletes.append(upload_file.key) + session.delete(upload_file) + + @staticmethod + def _delete_storage(storage_key: str | None) -> None: + if not storage_key: + return + try: + storage.delete(storage_key) + except Exception: + # Best-effort: a missing/already-deleted object must not abort the commit. + 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: + if file_kind == AgentDriveFileKind.TOOL_FILE: + mapping: dict[str, Any] = {"transfer_method": "tool_file", "tool_file_id": file_id} + else: + mapping = {"transfer_method": "local_file", "upload_file_id": file_id} + controller = DatabaseFileAccessController() + runtime = DifyWorkflowFileRuntime(file_access_controller=controller) + try: + # 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) + except ValueError: + return None + + +__all__ = [ + "AgentDriveError", + "AgentDriveService", + "DriveCommitItem", + "DriveFileRef", + "normalize_drive_key", + "parse_agent_drive_ref", +] diff --git a/api/services/agent_file_request_service.py b/api/services/agent_file_request_service.py new file mode 100644 index 0000000000..091fc9fc59 --- /dev/null +++ b/api/services/agent_file_request_service.py @@ -0,0 +1,93 @@ +"""Resolve a download request for a workflow file ref to a signed URL (Agent Files §3.1.1/§4.5). + +The dify-agent server calls this on behalf of a sandbox that needs to pull a +``File`` / ``Array[File]`` workflow input. It binds the flattened file-access +context as a ``FileAccessScope``, rebuilds the graphon ``File`` from the mapping +(reusing tenant/user access checks), and returns an internal signed download URL +plus metadata — never the file bytes. The dify-agent server / sandbox then GETs +the URL directly from Dify API. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom +from core.app.file_access.controller import DatabaseFileAccessController +from core.app.file_access.scope import FileAccessScope, bind_file_access_scope +from core.app.workflow.file_runtime import DifyWorkflowFileRuntime +from factories import file_factory + + +class FileDownloadRequestError(Exception): + """A download-request failure mapped to an HTTP status by the controller.""" + + code: str + message: str + status_code: int + + def __init__(self, code: str, message: str, *, status_code: int = 400) -> None: + super().__init__(message) + self.code = code + self.message = message + self.status_code = status_code + + +class AgentFileDownloadRequestService: + """Resolve a workflow file ref to a sandbox-accessible internal signed download URL.""" + + @classmethod + def resolve( + cls, + *, + tenant_id: str, + user_id: str, + user_from: str, + invoke_from: str, + file_mapping: Mapping[str, Any], + ) -> dict[str, Any]: + try: + scope_user_from = UserFrom(user_from) + scope_invoke_from = InvokeFrom(invoke_from) + except ValueError as exc: + raise FileDownloadRequestError("invalid_access_context", str(exc), status_code=400) from exc + + if not isinstance(file_mapping, Mapping) or not file_mapping.get("transfer_method"): + raise FileDownloadRequestError("invalid_file_mapping", "file.transfer_method is required", status_code=400) + + scope = FileAccessScope( + tenant_id=tenant_id, + user_id=user_id, + user_from=scope_user_from, + invoke_from=scope_invoke_from, + ) + controller = DatabaseFileAccessController() + runtime = DifyWorkflowFileRuntime(file_access_controller=controller) + try: + with bind_file_access_scope(scope): + file = file_factory.build_from_mapping( + mapping=file_mapping, + tenant_id=tenant_id, + access_controller=controller, + ) + # Internal URL (for_external=False): the consumer is the agent backend / + # sandbox, not a browser. Resolves against INTERNAL_FILES_URL, falling + # back to FILES_URL when not configured. + download_url = runtime.resolve_file_url(file=file, for_external=False) + except ValueError as exc: + raise FileDownloadRequestError("file_not_accessible", str(exc), status_code=404) from exc + + if not download_url: + raise FileDownloadRequestError( + "download_url_unavailable", "could not resolve a download URL for the file", status_code=502 + ) + return { + "filename": file.filename, + "mime_type": file.mime_type, + "size": file.size, + "download_url": download_url, + } + + +__all__ = ["AgentFileDownloadRequestService", "FileDownloadRequestError"] 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 new file mode 100644 index 0000000000..015ae040e6 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_agent_skills.py @@ -0,0 +1,106 @@ +"""Unit tests for the console agent Skill endpoints (ENG-370 / ENG-594). + +Handlers are unwrapped past the login/app-model decorators and invoked inside a +bare Flask request context with the services mocked — covering request handling ++ error mapping, not auth. +""" + +from __future__ import annotations + +import inspect +import io +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from flask import Flask + +from controllers.console.app.agent import AgentSkillStandardizeApi, AgentSkillUploadApi +from services.agent.skill_package_service import SkillPackageError +from services.agent_drive_service import AgentDriveError + +_MOD = "controllers.console.app.agent" +app = Flask(__name__) + + +def _raw(method): + return inspect.unwrap(method) + + +def _file_ctx(*, files: dict[str, bytes] | None = None): + data = {name: (io.BytesIO(content), name) for name, content in (files or {}).items()} + return app.test_request_context("/", method="POST", data=data, content_type="multipart/form-data") + + +_USER = SimpleNamespace(id="user-1") +_APP = SimpleNamespace(tenant_id="tenant-1", bound_agent_id="agent-1") + + +def test_upload_validates_and_returns_skill_ref(): + raw = _raw(AgentSkillUploadApi.post) + manifest = MagicMock() + manifest.to_skill_ref.return_value.model_dump.return_value = {"name": "S", "file_id": "uf-1"} + manifest.model_dump.return_value = {"name": "S"} + + with _file_ctx(files={"file": b"zip-bytes"}): + with ( + patch(f"{_MOD}.SkillPackageService") as pkg, + patch(f"{_MOD}.FileService") as fs, + patch(f"{_MOD}.db"), + ): + pkg.return_value.validate_and_extract.return_value = manifest + fs.return_value.upload_file.return_value = SimpleNamespace(id="uf-1") + body, status = raw(AgentSkillUploadApi(), _USER, _APP) + + assert status == 201 + assert body["skill"] == {"name": "S", "file_id": "uf-1"} + manifest.to_skill_ref.assert_called_once_with(file_id="uf-1") + + +def test_upload_no_file_is_400(): + raw = _raw(AgentSkillUploadApi.post) + with _file_ctx(files={}): + body, status = raw(AgentSkillUploadApi(), _USER, _APP) + assert status == 400 + assert body["code"] == "no_file" + + +def test_upload_maps_package_error(): + raw = _raw(AgentSkillUploadApi.post) + with _file_ctx(files={"file": b"bad"}): + with patch(f"{_MOD}.SkillPackageService") as pkg: + pkg.return_value.validate_and_extract.side_effect = SkillPackageError( + "missing_skill_md", "no SKILL.md", status_code=400 + ) + body, status = raw(AgentSkillUploadApi(), _USER, _APP) + assert status == 400 + assert body["code"] == "missing_skill_md" + + +def test_standardize_returns_result(): + raw = _raw(AgentSkillStandardizeApi.post) + with _file_ctx(files={"file": b"zip"}): + with patch(f"{_MOD}.SkillStandardizeService") as svc: + svc.return_value.standardize.return_value = {"skill": {"path": "s"}, "manifest": {}} + body, status = raw(AgentSkillStandardizeApi(), _USER, _APP) + assert status == 201 + assert body["skill"] == {"path": "s"} + assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1" + + +def test_standardize_no_bound_agent_is_400(): + raw = _raw(AgentSkillStandardizeApi.post) + app_without_agent = SimpleNamespace(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" + + +def test_standardize_maps_drive_error(): + raw = _raw(AgentSkillStandardizeApi.post) + with _file_ctx(files={"file": b"zip"}): + with patch(f"{_MOD}.SkillStandardizeService") as svc: + svc.return_value.standardize.side_effect = AgentDriveError("source_not_found", "nope", status_code=404) + body, status = raw(AgentSkillStandardizeApi(), _USER, _APP) + assert status == 404 + assert body["code"] == "source_not_found" diff --git a/api/tests/unit_tests/controllers/inner_api/plugin/test_agent_drive.py b/api/tests/unit_tests/controllers/inner_api/plugin/test_agent_drive.py new file mode 100644 index 0000000000..b8ad86017e --- /dev/null +++ b/api/tests/unit_tests/controllers/inner_api/plugin/test_agent_drive.py @@ -0,0 +1,95 @@ +"""Unit tests for the agent drive inner-API controller (ENG-591). + +Handlers are unwrapped past the auth/setup decorators and invoked inside a bare +Flask request context, with AgentDriveService mocked — so this covers the +controller's request parsing + error mapping, not auth (tested separately). +""" + +from __future__ import annotations + +import inspect +from unittest.mock import patch + +import pytest +from flask import Flask + +from controllers.inner_api.plugin.agent_drive import AgentDriveCommitApi, AgentDriveManifestApi +from services.agent_drive_service import AgentDriveError + +_MOD = "controllers.inner_api.plugin.agent_drive" +app = Flask(__name__) + + +def _raw(method): + return inspect.unwrap(method) + + +def test_manifest_parses_query_and_returns_items(): + raw = _raw(AgentDriveManifestApi.get) + with app.test_request_context("/?tenant_id=tenant-1&prefix=docs/&include_download_url=true"): + with patch(f"{_MOD}.AgentDriveService") as svc: + svc.return_value.manifest.return_value = [{"key": "docs/a.txt"}] + result = raw(AgentDriveManifestApi(), "agent-agent-1") + assert result == {"items": [{"key": "docs/a.txt"}]} + svc.return_value.manifest.assert_called_once_with( + tenant_id="tenant-1", agent_id="agent-1", prefix="docs/", include_download_url=True + ) + + +def test_manifest_missing_tenant_id_is_400(): + raw = _raw(AgentDriveManifestApi.get) + with app.test_request_context("/"): + body, status = raw(AgentDriveManifestApi(), "agent-agent-1") + assert status == 400 + assert body["code"] == "missing_tenant_id" + + +def test_manifest_bad_drive_ref_is_400(): + raw = _raw(AgentDriveManifestApi.get) + with app.test_request_context("/?tenant_id=tenant-1"): + body, status = raw(AgentDriveManifestApi(), "not-an-agent-ref") + assert status == 400 + assert body["code"] == "invalid_drive_ref" + + +def test_commit_parses_body_and_returns_items(): + raw = _raw(AgentDriveCommitApi.post) + payload = { + "tenant_id": "tenant-1", + "user_id": "user-1", + "items": [{"key": "a.txt", "file_ref": {"kind": "tool_file", "id": "tf-1"}}], + } + with app.test_request_context("/", method="POST", json=payload): + with patch(f"{_MOD}.AgentDriveService") as svc: + svc.return_value.commit.return_value = [{"key": "a.txt"}] + result = raw(AgentDriveCommitApi(), "agent-agent-1") + assert result == {"items": [{"key": "a.txt"}]} + assert svc.return_value.commit.call_args.kwargs["agent_id"] == "agent-1" + + +def test_commit_invalid_body_is_400(): + raw = _raw(AgentDriveCommitApi.post) + with app.test_request_context("/", method="POST", json={"tenant_id": "t"}): # missing user_id/items + body, status = raw(AgentDriveCommitApi(), "agent-agent-1") + assert status == 400 + assert body["code"] == "invalid_request" + + +def test_commit_maps_service_error(): + raw = _raw(AgentDriveCommitApi.post) + payload = { + "tenant_id": "tenant-1", + "user_id": "user-1", + "items": [{"key": "a.txt", "file_ref": {"kind": "tool_file", "id": "tf-1"}}], + } + with app.test_request_context("/", method="POST", json=payload): + with patch(f"{_MOD}.AgentDriveService") as svc: + svc.return_value.commit.side_effect = AgentDriveError("source_not_found", "nope", status_code=404) + body, status = raw(AgentDriveCommitApi(), "agent-agent-1") + assert status == 404 + assert body["code"] == "source_not_found" + + +@pytest.mark.parametrize("api_cls", [AgentDriveManifestApi, AgentDriveCommitApi]) +def test_endpoints_have_handlers(api_cls): + assert callable(getattr(api_cls(), "get", None) or getattr(api_cls(), "post", None)) diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py index acef632198..e6b587bdde 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py @@ -21,7 +21,7 @@ from core.app.apps.agent_app.app_runner import AgentAppRunner from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder from core.app.apps.agent_app.session_store import AgentAppSessionScope from core.app.apps.exc import GenerateTaskStoppedError -from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.app.entities.queue_entities import QueueLLMChunkEvent, QueueMessageEndEvent from models.agent_config_entities import AgentSoulConfig @@ -90,7 +90,13 @@ def _soul() -> AgentSoulConfig: def _dify_ctx() -> Any: - return SimpleNamespace(tenant_id="tenant-1", app_id="app-1", user_id="user-1", invoke_from=InvokeFrom.WEB_APP) + return SimpleNamespace( + tenant_id="tenant-1", + app_id="app-1", + user_id="user-1", + user_from=UserFrom.END_USER, + invoke_from=InvokeFrom.WEB_APP, + ) def _runner(client: FakeAgentBackendRunClient, store: _FakeSessionStore) -> AgentAppRunner: 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 3c1bff1b22..f3348b117b 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 @@ -20,7 +20,7 @@ from core.app.apps.agent_app.runtime_request_builder import ( AgentAppRuntimeRequestBuilder, AgentAppRuntimeRequestBuildError, ) -from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from models.agent_config_entities import AgentSoulConfig @@ -85,6 +85,7 @@ def _ctx(soul: AgentSoulConfig, *, query: str = "hello") -> AgentAppRuntimeBuild tenant_id="tenant-1", app_id="app-1", user_id="user-1", + user_from=UserFrom.END_USER, invoke_from=InvokeFrom.WEB_APP, ) return AgentAppRuntimeBuildContext( @@ -130,7 +131,10 @@ class TestAgentAppRuntimeRequestBuilder: # execution context carries conversation + agent_app invoke source. exec_ctx = next(layer for layer in req.composition.layers if layer.name == "execution_context") assert exec_ctx.config.conversation_id == "conv-1" - assert exec_ctx.config.invoke_from == "agent_app" + # Real Dify access context forwarded; agent run mode in agent_mode. + assert exec_ctx.config.user_from == "end-user" + assert exec_ctx.config.invoke_from == "web-app" + assert exec_ctx.config.agent_mode == "agent_app" # credentials are redacted in the log-safe view. assert result.redacted_request["composition"]["layers"][-1]["config"]["credentials"] == "[REDACTED]" assert result.metadata["conversation_id"] == "conv-1" diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_file_tenant_validator.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_file_tenant_validator.py index c070e81790..7188990b33 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_file_tenant_validator.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_file_tenant_validator.py @@ -12,7 +12,10 @@ from unittest.mock import patch import pytest -from core.workflow.nodes.agent_v2.file_tenant_validator import UploadFileTenantValidator +from core.workflow.nodes.agent_v2.file_tenant_validator import ( + AgentOutputFileTenantValidator, + UploadFileTenantValidator, +) def test_empty_inputs_return_false_without_db_hit(): @@ -51,3 +54,31 @@ def test_db_error_swallowed_and_returns_false(): with patch("core.workflow.nodes.agent_v2.file_tenant_validator.session_factory") as factory: factory.create_session.return_value.__enter__.return_value.scalar.side_effect = SQLAlchemyError("boom") assert validator.is_owned_by_tenant(file_id=valid_uuid, tenant_id="tenant-1") is False + + +_VALID_UUID = "550e8400-e29b-41d4-a716-446655440000" + + +def test_tool_file_owned_by_tenant_returns_true(): + """Agent Files §4.6: agent output files are canonically ToolFile.""" + validator = AgentOutputFileTenantValidator() + with patch("core.workflow.nodes.agent_v2.file_tenant_validator.session_factory") as factory: + # First scalar() = tool_files lookup -> tenant owns it. + factory.create_session.return_value.__enter__.return_value.scalar.return_value = "tenant-1" + assert validator.is_owned_by_tenant(file_id=_VALID_UUID, tenant_id="tenant-1") is True + + +def test_tool_file_owned_by_other_tenant_rejected(): + validator = AgentOutputFileTenantValidator() + with patch("core.workflow.nodes.agent_v2.file_tenant_validator.session_factory") as factory: + factory.create_session.return_value.__enter__.return_value.scalar.return_value = "tenant-OTHER" + assert validator.is_owned_by_tenant(file_id=_VALID_UUID, tenant_id="tenant-1") is False + + +def test_falls_back_to_upload_file_when_not_a_tool_file(): + validator = AgentOutputFileTenantValidator() + with patch("core.workflow.nodes.agent_v2.file_tenant_validator.session_factory") as factory: + scalar = factory.create_session.return_value.__enter__.return_value.scalar + # tool_files miss -> upload_files hit for this tenant. + scalar.side_effect = [None, "tenant-1"] + assert validator.is_owned_by_tenant(file_id=_VALID_UUID, tenant_id="tenant-1") is True diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_adapter.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_adapter.py index 8b2feb2ad6..96af5e4892 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_adapter.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_adapter.py @@ -8,10 +8,85 @@ from clients.agent_backend import ( ) from core.workflow.nodes.agent_v2.output_adapter import WorkflowAgentOutputAdapter from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus -from graphon.file import FileTransferMethod, FileType +from graphon.file import File, FileTransferMethod, FileType from graphon.variables.segments import ArrayFileSegment, FileSegment +def _rebacked_tool_file(tool_file_id: str) -> File: + return File( + type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.TOOL_FILE, + remote_url=None, + related_id=tool_file_id, + filename="authoritative.pdf", + extension=".pdf", + mime_type="application/pdf", + size=99, + ) + + +def _succeeded(output: object) -> AgentBackendRunSucceededInternalEvent: + return AgentBackendRunSucceededInternalEvent( + run_id="run-1", + source_event_id="2-0", + output=output, + session_snapshot=CompositorSessionSnapshot(layers=[]), + ) + + +def test_minimal_id_file_output_is_rebacked_from_tool_file(): + """Agent Files §4.6: a bare {"id": ...} output is rebacked from the ToolFile row.""" + calls: list[tuple[str, str]] = [] + + def rebacker(*, tenant_id: str, tool_file_id: str) -> File | None: + calls.append((tenant_id, tool_file_id)) + return _rebacked_tool_file(tool_file_id) if tool_file_id == "tool-file-1" else None + + adapter = WorkflowAgentOutputAdapter(tool_file_rebacker=rebacker) + result = adapter.build_success_result( + event=_succeeded({"report": {"id": "tool-file-1"}}), + inputs={}, + process_data={}, + metadata={}, + tenant_id="tenant-1", + ) + + report = result.outputs["report"] + assert isinstance(report, FileSegment) + assert report.value.reference == "tool-file-1" + # metadata comes from the reback, not the sandbox payload + assert report.value.filename == "authoritative.pdf" + assert calls == [("tenant-1", "tool-file-1")] + + +def test_unresolved_minimal_id_stays_a_plain_object(): + adapter = WorkflowAgentOutputAdapter(tool_file_rebacker=lambda **_: None) + result = adapter.build_success_result( + event=_succeeded({"thing": {"id": "not-a-file"}}), + inputs={}, + process_data={}, + metadata={}, + tenant_id="tenant-1", + ) + assert result.outputs["thing"] == {"id": "not-a-file"} + + +def test_array_of_minimal_id_file_outputs_rebacked(): + adapter = WorkflowAgentOutputAdapter( + tool_file_rebacker=lambda *, tenant_id, tool_file_id: _rebacked_tool_file(tool_file_id) + ) + result = adapter.build_success_result( + event=_succeeded({"files": [{"id": "tool-file-1"}, {"id": "tool-file-2"}]}), + inputs={}, + process_data={}, + metadata={}, + tenant_id="tenant-1", + ) + files = result.outputs["files"] + assert isinstance(files, ArrayFileSegment) + assert [f.reference for f in files.value] == ["tool-file-1", "tool-file-2"] + + def test_success_output_adapter_preserves_dict_output(): result = WorkflowAgentOutputAdapter().build_success_result( event=AgentBackendRunSucceededInternalEvent( diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_file_rebacker.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_file_rebacker.py new file mode 100644 index 0000000000..f72b7f806a --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_file_rebacker.py @@ -0,0 +1,73 @@ +"""Unit tests for the agent output ToolFile rebacker (ENG-593).""" + +from __future__ import annotations + +from collections.abc import Generator + +import pytest +from sqlalchemy import delete + +from core.db.session_factory import session_factory +from core.workflow.file_reference import resolve_file_record_id +from core.workflow.nodes.agent_v2.output_file_rebacker import reback_tool_file_output +from graphon.file import FileTransferMethod, FileType +from models.tools import ToolFile + +TENANT = "11111111-1111-1111-1111-111111111111" + + +@pytest.fixture(autouse=True) +def _tables() -> Generator[None, None, None]: + engine = session_factory.get_session_maker().kw["bind"] + ToolFile.__table__.create(bind=engine, checkfirst=True) + yield + with session_factory.create_session() as session: + session.execute(delete(ToolFile)) + session.commit() + + +def _seed(*, mimetype: str = "application/pdf", name: str = "report.pdf", size: int = 42) -> str: + tool_file = ToolFile( + user_id="22222222-2222-2222-2222-222222222222", + tenant_id=TENANT, + conversation_id=None, + file_key=f"tools/{TENANT}/{name}", + mimetype=mimetype, + name=name, + size=size, + ) + with session_factory.create_session() as session: + session.add(tool_file) + session.commit() + return tool_file.id + + +def test_reback_resolves_tenant_tool_file_to_file(): + tf = _seed(mimetype="image/png", name="chart.png", size=99) + file = reback_tool_file_output(tenant_id=TENANT, tool_file_id=tf) + + assert file is not None + assert file.transfer_method == FileTransferMethod.TOOL_FILE + assert file.related_id == tf + assert resolve_file_record_id(file.reference) == tf + assert file.filename == "chart.png" + assert file.mime_type == "image/png" + assert file.size == 99 + assert file.type == FileType.IMAGE + assert file.extension == ".png" + + +def test_reback_other_tenant_returns_none(): + tf = _seed() + assert reback_tool_file_output(tenant_id="33333333-3333-3333-3333-333333333333", tool_file_id=tf) is None + + +@pytest.mark.parametrize("bad", ["", "not-a-uuid", "550e8400-e29b-41d4-a716-446655440000"]) +def test_reback_missing_or_malformed_returns_none(bad: str): + # empty / non-UUID / valid-but-absent all resolve to None (never raise) + assert reback_tool_file_output(tenant_id=TENANT, tool_file_id=bad) is None + + +def test_reback_empty_tenant_returns_none(): + tf = _seed() + assert reback_tool_file_output(tenant_id="", tool_file_id=tf) is None diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_type_checker.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_type_checker.py index c80b0c1f95..b0e88c7a95 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_type_checker.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_output_type_checker.py @@ -168,6 +168,20 @@ def test_file_ref_must_be_tenant_owned(): assert outcome.has_failures +def test_file_ref_accepts_canonical_id_alias(): + """Agent Files §4.6: the canonical minimal file ref is ``{"id": ""}``.""" + checker = _make_checker(allowed={"t-1": {"tool-file-1"}}) + declared = DeclaredOutputConfig(name="report", type=DeclaredOutputType.FILE) + + outcome = checker.check( + declared_outputs=[declared], + raw_output={"report": {"id": "tool-file-1"}}, + tenant_id="t-1", + ) + assert not outcome.has_failures + assert outcome.results[0].status == OutputTypeCheckStatus.READY + + def test_file_ref_missing_id_field_fails(): checker = _make_checker() declared = DeclaredOutputConfig(name="r", type=DeclaredOutputType.FILE) 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 50bea15fc6..4790e77075 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 @@ -147,7 +147,10 @@ def test_builds_create_run_request_from_agent_soul_and_node_job(): layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]} assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["agent_id"] == "agent-1" assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["agent_config_version_id"] == "snapshot-1" - assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["invoke_from"] == "single_step" + # Real Dify access context is forwarded; the agent run mode moves to agent_mode. + assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["user_from"] == "account" + assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["invoke_from"] == "debugger" + assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["agent_mode"] == "single_step" assert dumped["idempotency_key"] == "run-1:node-exec-1" assert dumped["composition"]["layers"][0]["config"]["prefix"] == "You are careful." assert dumped["composition"]["layers"][1]["config"]["prefix"] == "Use the previous output." @@ -222,7 +225,8 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada dumped = result.request.model_dump(mode="json") layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]} - assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["invoke_from"] == "workflow_run" + assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["invoke_from"] == "service-api" + assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["agent_mode"] == "workflow_run" assert dumped["idempotency_key"] == "node-exec-1" output_schema = dumped["composition"]["layers"][-1]["config"]["json_schema"] assert output_schema["properties"]["report"]["properties"]["file_id"]["type"] == "string" diff --git a/api/tests/unit_tests/services/agent/test_skill_package_service.py b/api/tests/unit_tests/services/agent/test_skill_package_service.py new file mode 100644 index 0000000000..a29f641cfe --- /dev/null +++ b/api/tests/unit_tests/services/agent/test_skill_package_service.py @@ -0,0 +1,136 @@ +"""Unit tests for the Skill package validator/extractor (ENG-370).""" + +from __future__ import annotations + +import io +import zipfile + +import pytest + +from services.agent.skill_package_service import SkillPackageError, SkillPackageService + +_SKILL_MD = """--- +name: PDF Toolkit +description: Tools for working with PDF files. +--- + +# PDF Toolkit + +Do things with PDFs. +""" + + +def _zip(members: dict[str, bytes], *, compression: int = zipfile.ZIP_DEFLATED) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", compression=compression) as archive: + for name, data in members.items(): + archive.writestr(name, data) + return buffer.getvalue() + + +def _extract(members: dict[str, bytes], *, filename: str = "skill.zip"): + return SkillPackageService().validate_and_extract(content=_zip(members), filename=filename) + + +def test_valid_skill_extracts_manifest(): + manifest = _extract({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('hi')\n"}) + + assert manifest.name == "PDF Toolkit" + assert manifest.description == "Tools for working with PDF files." + assert manifest.entry_path == "SKILL.md" + assert set(manifest.files) == {"SKILL.md", "scripts/run.py"} + assert manifest.size > 0 + assert len(manifest.hash) == 64 + + +def test_name_falls_back_to_heading_without_frontmatter(): + manifest = _extract({"SKILL.md": b"# Heading Name\n\nbody"}) + assert manifest.name == "Heading Name" + assert manifest.description == "" + + +def test_nested_skill_md_is_found(): + manifest = _extract({"pdf-toolkit/SKILL.md": _SKILL_MD.encode()}) + assert manifest.entry_path == "pdf-toolkit/SKILL.md" + + +def test_shallowest_skill_md_preferred(): + manifest = _extract({"SKILL.md": _SKILL_MD.encode(), "nested/SKILL.md": _SKILL_MD.encode()}) + assert manifest.entry_path == "SKILL.md" + + +@pytest.mark.parametrize( + ("members", "filename", "code"), + [ + ({"README.md": b"x"}, "skill.zip", "missing_skill_md"), + ({"SKILL.md": _SKILL_MD.encode()}, "skill.tar", "unsupported_extension"), + ({"SKILL.md": b""}, "skill.zip", "empty_skill_md"), + ({"SKILL.md": b"no name here"}, "skill.zip", "missing_skill_name"), + ({"SKILL.md": b"\xff\xfenot utf8"}, "skill.zip", "skill_md_not_utf8"), + ], +) +def test_invalid_packages_rejected(members: dict[str, bytes], filename: str, code: str): + with pytest.raises(SkillPackageError) as exc_info: + _extract(members, filename=filename) + assert exc_info.value.code == code + assert exc_info.value.status_code == 400 + + +def test_non_zip_content_rejected(): + with pytest.raises(SkillPackageError) as exc_info: + SkillPackageService().validate_and_extract(content=b"not a zip", filename="skill.zip") + assert exc_info.value.code == "invalid_archive" + + +def test_zip_slip_member_rejected(): + payload = _zip({"../evil.txt": b"x", "SKILL.md": _SKILL_MD.encode()}) + with pytest.raises(SkillPackageError) as exc_info: + SkillPackageService().validate_and_extract(content=payload, filename="skill.zip") + assert exc_info.value.code == "unsafe_path" + + +def test_empty_archive_rejected(): + with pytest.raises(SkillPackageError) as exc_info: + SkillPackageService().validate_and_extract(content=b"", filename="skill.zip") + assert exc_info.value.code == "empty_archive" + + +def test_bad_frontmatter_yaml_rejected(): + bad = b"---\n: : : not yaml\n---\n# x\n" + with pytest.raises(SkillPackageError) as exc_info: + _extract({"SKILL.md": bad}) + assert exc_info.value.code == "invalid_frontmatter" + + +def test_unterminated_frontmatter_falls_back_to_heading(): + # leading '---' with no closing fence -> no frontmatter, use the heading + manifest = _extract({"SKILL.md": b"---\n# Heading Wins\nbody"}) + assert manifest.name == "Heading Wins" + + +def test_read_member_bytes_roundtrip_and_errors(): + service = SkillPackageService() + payload = _zip({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('x')\n"}) + + assert service.read_member_bytes(content=payload, member_path="scripts/run.py") == b"print('x')\n" + + with pytest.raises(SkillPackageError) as missing: + service.read_member_bytes(content=payload, member_path="nope.txt") + assert missing.value.code == "member_not_found" + + with pytest.raises(SkillPackageError) as bad_zip: + service.read_member_bytes(content=b"not a zip", member_path="SKILL.md") + assert bad_zip.value.code == "invalid_archive" + + +def test_to_skill_ref_carries_metadata(): + manifest = _extract({"SKILL.md": _SKILL_MD.encode()}) + ref = manifest.to_skill_ref(file_id="upload-1", path="pdf-toolkit/.DIFY-SKILL-FULL.zip") + + assert ref.name == "PDF Toolkit" + assert ref.file_id == "upload-1" + assert ref.path == "pdf-toolkit/.DIFY-SKILL-FULL.zip" + assert ref.id == manifest.hash + dumped = ref.model_dump() + assert dumped["hash"] == manifest.hash + assert dumped["entry_path"] == "SKILL.md" 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 new file mode 100644 index 0000000000..8a99719dd3 --- /dev/null +++ b/api/tests/unit_tests/services/agent/test_skill_standardize_service.py @@ -0,0 +1,77 @@ +"""Unit tests for Skill standardization into the agent drive (ENG-594).""" + +from __future__ import annotations + +import io +import zipfile +from types import SimpleNamespace +from unittest.mock import MagicMock + +from services.agent.skill_standardize_service import SkillStandardizeService, slugify_skill_name + +_SKILL_MD = b"""--- +name: PDF Toolkit +description: Work with PDFs. +--- + +# PDF Toolkit +""" + + +def _zip(members: dict[str, bytes]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for name, data in members.items(): + archive.writestr(name, data) + return buffer.getvalue() + + +def test_slugify_skill_name(): + assert slugify_skill_name("PDF Toolkit") == "pdf-toolkit" + assert slugify_skill_name(" Weird/Name!! ") == "weird-name" + assert slugify_skill_name("") == "skill" + + +def test_standardize_creates_two_drive_owned_toolfiles_and_commits(): + content = _zip({"SKILL.md": _SKILL_MD, "scripts/run.py": b"print('x')\n"}) + + tool_files = MagicMock() + tool_files.create_file_by_raw.side_effect = [ + SimpleNamespace(id="md-tool-file"), + SimpleNamespace(id="zip-tool-file"), + ] + drive = MagicMock() + drive.commit.return_value = [] + + service = SkillStandardizeService(tool_file_manager=tool_files, drive_service=drive) + result = service.standardize( + content=content, + filename="skill.zip", + tenant_id="tenant-1", + user_id="user-1", + agent_id="agent-1", + ) + + # Two ToolFiles: SKILL.md (markdown) + full archive (zip). + assert tool_files.create_file_by_raw.call_count == 2 + md_call, zip_call = tool_files.create_file_by_raw.call_args_list + assert md_call.kwargs["mimetype"] == "text/markdown" + assert md_call.kwargs["file_binary"] == _SKILL_MD + assert zip_call.kwargs["mimetype"] == "application/zip" + assert zip_call.kwargs["file_binary"] == content + + # Committed as drive-owned with the standardized keys. + commit_kwargs = drive.commit.call_args.kwargs + assert commit_kwargs["agent_id"] == "agent-1" + items = commit_kwargs["items"] + assert [item.key for item in items] == ["pdf-toolkit/SKILL.md", "pdf-toolkit/.DIFY-SKILL-FULL.zip"] + assert all(item.value_owned_by_drive for item in items) + assert [item.file_ref.id for item in items] == ["md-tool-file", "zip-tool-file"] + + # The returned skill ref carries stable drive paths + file ids. + skill = result["skill"] + assert skill["path"] == "pdf-toolkit" + assert skill["name"] == "PDF Toolkit" + 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" diff --git a/api/tests/unit_tests/services/test_agent_drive_service.py b/api/tests/unit_tests/services/test_agent_drive_service.py new file mode 100644 index 0000000000..3ff9726668 --- /dev/null +++ b/api/tests/unit_tests/services/test_agent_drive_service.py @@ -0,0 +1,339 @@ +"""Unit tests for the agent drive service (ENG-591). + +Pure helpers (key safety / drive-ref parsing) plus the commit/manifest lifecycle +exercised against the project's in-memory SQLite engine with seeded ToolFiles. +""" + +from __future__ import annotations + +import datetime +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from sqlalchemy import delete, select + +from core.db.session_factory import session_factory +from extensions.storage.storage_type import StorageType +from models.agent import Agent, AgentDriveFile, AgentScope, AgentSource +from models.enums import CreatorUserRole +from models.model import UploadFile +from models.tools import ToolFile +from services.agent_drive_service import ( + AgentDriveError, + AgentDriveService, + DriveCommitItem, + normalize_drive_key, + parse_agent_drive_ref, +) + +TENANT = "11111111-1111-1111-1111-111111111111" +AGENT = "22222222-2222-2222-2222-222222222222" +USER = "33333333-3333-3333-3333-333333333333" + + +# ── pure helpers ────────────────────────────────────────────────────────────── + + +def test_parse_agent_drive_ref(): + assert parse_agent_drive_ref("agent-abc") == "abc" + for bad in ["abc", "agent-", ""]: + with pytest.raises(AgentDriveError): + parse_agent_drive_ref(bad) + + +def test_normalize_drive_key_ok_and_collapses_slashes(): + assert normalize_drive_key("a/b/c.txt") == "a/b/c.txt" + assert normalize_drive_key("/a//b.txt") == "a/b.txt" + assert normalize_drive_key("skill-name/SKILL.md") == "skill-name/SKILL.md" + + +@pytest.mark.parametrize("bad", ["", " ", "a/../b", "../etc", "a/\x00b", "a" * 1100]) +def test_normalize_drive_key_rejects_unsafe(bad: str): + with pytest.raises(AgentDriveError): + normalize_drive_key(bad) + + +# ── service lifecycle (in-memory ORM) ───────────────────────────────────────── + + +@pytest.fixture(autouse=True) +def _tables() -> Generator[None, None, None]: + engine = session_factory.get_session_maker().kw["bind"] + for model in (Agent, ToolFile, UploadFile, AgentDriveFile): + model.__table__.create(bind=engine, checkfirst=True) + _seed_agent() + yield + with session_factory.create_session() as session: + session.execute(delete(AgentDriveFile)) + session.execute(delete(ToolFile)) + session.execute(delete(Agent)) + session.commit() + AgentDriveFile.__table__.drop(bind=engine, checkfirst=True) + + +def _seed_agent(*, tenant_id: str = TENANT, agent_id: str = AGENT) -> None: + agent = Agent( + id=agent_id, + tenant_id=tenant_id, + name="Drive Agent", + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + ) + with session_factory.create_session() as session: + session.add(agent) + session.commit() + + +def _seed_tool_file(*, user_id: str = USER, name: str = "f.txt") -> str: + tool_file = ToolFile( + user_id=user_id, + tenant_id=TENANT, + conversation_id=None, + file_key=f"tools/{TENANT}/{name}", + mimetype="text/plain", + name=name, + size=5, + ) + with session_factory.create_session() as session: + session.add(tool_file) + session.commit() + return tool_file.id + + +def _commit(key: str, tool_file_id: str, *, owned: bool = True): + return AgentDriveService().commit( + tenant_id=TENANT, + user_id=USER, + agent_id=AGENT, + items=[ + DriveCommitItem( + key=key, + file_ref={"kind": "tool_file", "id": tool_file_id}, + value_owned_by_drive=owned, + ) + ], + ) + + +def test_commit_then_manifest_lists_the_entry(): + tf = _seed_tool_file() + _commit("data/report.txt", tf) + + items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT) + assert [i["key"] for i in items] == ["data/report.txt"] + assert items[0]["file_kind"] == "tool_file" + assert items[0]["file_id"] == tf + assert items[0]["mime_type"] == "text/plain" + + # prefix filter + assert AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, prefix="data/") != [] + assert AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, prefix="other/") == [] + + +def test_commit_rejects_tool_file_not_owned_by_user(): + other = _seed_tool_file(user_id="99999999-9999-9999-9999-999999999999") + with pytest.raises(AgentDriveError) as exc_info: + _commit("x.txt", other) + assert exc_info.value.status_code == 404 + assert exc_info.value.code == "source_not_found" + + +def test_commit_rejects_agent_from_another_tenant(): + tf = _seed_tool_file() + with pytest.raises(AgentDriveError) as exc_info: + AgentDriveService().commit( + tenant_id="99999999-9999-9999-9999-999999999999", + user_id=USER, + agent_id=AGENT, + items=[ + DriveCommitItem( + key="x.txt", + file_ref={"kind": "tool_file", "id": tf}, + value_owned_by_drive=True, + ) + ], + ) + assert exc_info.value.status_code == 404 + assert exc_info.value.code == "agent_not_found" + + +def test_overwrite_cleans_old_drive_owned_value(): + tf1 = _seed_tool_file(name="v1.txt") + tf2 = _seed_tool_file(name="v2.txt") + _commit("doc.txt", tf1, owned=True) + + with patch("services.agent_drive_service.storage") as storage_mock: + _commit("doc.txt", tf2, owned=True) + storage_mock.delete.assert_called_once() + + # old ToolFile physically removed; key now points at tf2 + with session_factory.create_session() as session: + assert session.scalar(select(ToolFile).where(ToolFile.id == tf1)) is None + assert session.scalar(select(ToolFile).where(ToolFile.id == tf2)) is not None + rows = list(session.scalars(select(AgentDriveFile).where(AgentDriveFile.key == "doc.txt"))) + assert len(rows) == 1 + assert rows[0].file_id == tf2 + + +def test_batch_failure_does_not_delete_old_storage_before_commit(): + tf1 = _seed_tool_file(name="v1.txt") + tf2 = _seed_tool_file(name="v2.txt") + _commit("doc.txt", tf1, owned=True) + + with patch("services.agent_drive_service.storage") as storage_mock: + with pytest.raises(AgentDriveError): + AgentDriveService().commit( + tenant_id=TENANT, + user_id=USER, + agent_id=AGENT, + items=[ + DriveCommitItem( + key="doc.txt", + file_ref={"kind": "tool_file", "id": tf2}, + value_owned_by_drive=True, + ), + DriveCommitItem( + key="bad.txt", + file_ref={"kind": "tool_file", "id": "44444444-4444-4444-4444-444444444444"}, + value_owned_by_drive=True, + ), + ], + ) + storage_mock.delete.assert_not_called() + + with session_factory.create_session() as session: + row = session.scalar(select(AgentDriveFile).where(AgentDriveFile.key == "doc.txt")) + assert row is not None + assert row.file_id == tf1 + assert session.scalar(select(ToolFile).where(ToolFile.id == tf1)) is not None + assert session.scalar(select(ToolFile).where(ToolFile.id == tf2)) is not None + + +def test_validate_source_db_error_maps_to_404(): + """A malformed id (non-UUID hitting a UUID column -> DataError) must not 500.""" + from unittest.mock import MagicMock + + from sqlalchemy.exc import DataError + + from models.agent import AgentDriveFileKind + + session = MagicMock() + session.scalar.side_effect = DataError("bad uuid", {}, Exception("invalid input syntax for uuid")) + + with pytest.raises(AgentDriveError) as exc_info: + AgentDriveService()._validate_source( + session, + tenant_id=TENANT, + user_id="not-a-uuid", + file_kind=AgentDriveFileKind.TOOL_FILE, + file_id="also-bad", + ) + assert exc_info.value.status_code == 404 + assert exc_info.value.code == "source_not_found" + session.rollback.assert_called_once() + + +def test_recommit_same_value_is_idempotent_and_keeps_value(): + tf = _seed_tool_file() + _commit("a.txt", tf) + _commit("a.txt", tf) # no error, no cleanup + + with session_factory.create_session() as session: + assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is not None + rows = list(session.scalars(select(AgentDriveFile).where(AgentDriveFile.key == "a.txt"))) + assert len(rows) == 1 + + +def _seed_upload_file(*, name: str = "u.txt") -> str: + upload = UploadFile( + tenant_id=TENANT, + storage_type=StorageType.LOCAL, + key=f"upload_files/{TENANT}/{name}", + name=name, + size=7, + extension="txt", + mime_type="text/plain", + created_by_role=CreatorUserRole.ACCOUNT, + created_by=USER, + created_at=datetime.datetime.now(tz=datetime.UTC), + used=False, + ) + with session_factory.create_session() as session: + session.add(upload) + session.commit() + return upload.id + + +def _commit_upload(key: str, upload_file_id: str, *, owned: bool = True): + return AgentDriveService().commit( + tenant_id=TENANT, + user_id=USER, + agent_id=AGENT, + items=[ + DriveCommitItem( + key=key, + file_ref={"kind": "upload_file", "id": upload_file_id}, + value_owned_by_drive=owned, + ) + ], + ) + + +def test_commit_upload_file_source_and_manifest(): + uf = _seed_upload_file() + _commit_upload("docs/u.txt", uf) + + items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT) + assert items[0]["file_kind"] == "upload_file" + assert items[0]["file_id"] == uf + assert items[0]["mime_type"] == "text/plain" + + +def test_commit_rejects_missing_upload_file(): + with pytest.raises(AgentDriveError) as exc_info: + _commit_upload("x.txt", "44444444-4444-4444-4444-444444444444") + assert exc_info.value.status_code == 404 + assert exc_info.value.code == "source_not_found" + + +def test_overwrite_cleans_old_upload_file_value(): + u1 = _seed_upload_file(name="v1.txt") + u2 = _seed_upload_file(name="v2.txt") + _commit_upload("doc.txt", u1, owned=True) + + with patch("services.agent_drive_service.storage") as storage_mock: + _commit_upload("doc.txt", u2, owned=True) + storage_mock.delete.assert_called_once() + + with session_factory.create_session() as session: + assert session.scalar(select(UploadFile).where(UploadFile.id == u1)) is None + assert session.scalar(select(UploadFile).where(UploadFile.id == u2)) is not None + + +def test_manifest_includes_internal_download_url(): + tf = _seed_tool_file() + _commit("data/r.txt", tf) + + with ( + patch("services.agent_drive_service.file_factory.build_from_mapping", return_value=object()), + patch("services.agent_drive_service.DifyWorkflowFileRuntime") as runtime_cls, + ): + runtime_cls.return_value.resolve_file_url.return_value = "http://internal/files/x?sign=1" + items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, include_download_url=True) + + assert items[0]["download_url"] == "http://internal/files/x?sign=1" + # drive-owned resolution: internal URL (for_external=False) + assert runtime_cls.return_value.resolve_file_url.call_args.kwargs["for_external"] is False + + +def test_manifest_download_url_none_when_unresolvable(): + tf = _seed_tool_file() + _commit("data/r.txt", tf) + + with patch( + "services.agent_drive_service.file_factory.build_from_mapping", + side_effect=ValueError("not found"), + ): + items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, include_download_url=True) + assert items[0]["download_url"] is None diff --git a/api/tests/unit_tests/services/test_agent_file_request_service.py b/api/tests/unit_tests/services/test_agent_file_request_service.py new file mode 100644 index 0000000000..c39e3f8255 --- /dev/null +++ b/api/tests/unit_tests/services/test_agent_file_request_service.py @@ -0,0 +1,105 @@ +"""Unit tests for the Agent Files download-request service (ENG-592).""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from services.agent_file_request_service import AgentFileDownloadRequestService, FileDownloadRequestError + +_MOD = "services.agent_file_request_service" + + +def _fake_file() -> SimpleNamespace: + return SimpleNamespace(filename="report.pdf", mime_type="application/pdf", size=12) + + +def test_resolve_returns_metadata_and_internal_url(): + with ( + patch(f"{_MOD}.file_factory.build_from_mapping", return_value=_fake_file()) as build, + patch(f"{_MOD}.DifyWorkflowFileRuntime") as runtime_cls, + ): + runtime_cls.return_value.resolve_file_url.return_value = "http://internal/files/x?sign=1" + data = AgentFileDownloadRequestService.resolve( + tenant_id="tenant-1", + user_id="user-1", + user_from="account", + invoke_from="service-api", + file_mapping={"transfer_method": "tool_file", "reference": "tool-file-1"}, + ) + + assert data == { + "filename": "report.pdf", + "mime_type": "application/pdf", + "size": 12, + "download_url": "http://internal/files/x?sign=1", + } + assert build.call_args.kwargs["tenant_id"] == "tenant-1" + # Sandbox/agent backend consumes the URL -> must be internal, not external. + assert runtime_cls.return_value.resolve_file_url.call_args.kwargs["for_external"] is False + + +@pytest.mark.parametrize( + ("user_from", "invoke_from", "code"), + [ + ("bogus", "service-api", "invalid_access_context"), + ("account", "not-a-source", "invalid_access_context"), + ], +) +def test_invalid_access_context_rejected(user_from: str, invoke_from: str, code: str): + with pytest.raises(FileDownloadRequestError) as exc_info: + AgentFileDownloadRequestService.resolve( + tenant_id="t", + user_id="u", + user_from=user_from, + invoke_from=invoke_from, + file_mapping={"transfer_method": "tool_file", "reference": "x"}, + ) + assert exc_info.value.status_code == 400 + assert exc_info.value.code == code + + +def test_missing_transfer_method_rejected(): + with pytest.raises(FileDownloadRequestError) as exc_info: + AgentFileDownloadRequestService.resolve( + tenant_id="t", + user_id="u", + user_from="account", + invoke_from="service-api", + file_mapping={}, + ) + assert exc_info.value.status_code == 400 + assert exc_info.value.code == "invalid_file_mapping" + + +def test_inaccessible_file_maps_to_404(): + with patch(f"{_MOD}.file_factory.build_from_mapping", side_effect=ValueError("ToolFile x not found")): + with pytest.raises(FileDownloadRequestError) as exc_info: + AgentFileDownloadRequestService.resolve( + tenant_id="t", + user_id="u", + user_from="end-user", + invoke_from="web-app", + file_mapping={"transfer_method": "tool_file", "reference": "x"}, + ) + assert exc_info.value.status_code == 404 + assert exc_info.value.code == "file_not_accessible" + + +def test_unresolved_url_maps_to_502(): + with ( + patch(f"{_MOD}.file_factory.build_from_mapping", return_value=_fake_file()), + patch(f"{_MOD}.DifyWorkflowFileRuntime") as runtime_cls, + ): + runtime_cls.return_value.resolve_file_url.return_value = None + with pytest.raises(FileDownloadRequestError) as exc_info: + AgentFileDownloadRequestService.resolve( + tenant_id="t", + user_id="u", + user_from="account", + invoke_from="service-api", + file_mapping={"transfer_method": "tool_file", "reference": "x"}, + ) + assert exc_info.value.status_code == 502 diff --git a/dify-agent/src/dify_agent/layers/execution_context/__init__.py b/dify-agent/src/dify_agent/layers/execution_context/__init__.py index daf67ef7db..f1534bceff 100644 --- a/dify-agent/src/dify_agent/layers/execution_context/__init__.py +++ b/dify-agent/src/dify_agent/layers/execution_context/__init__.py @@ -7,12 +7,16 @@ needs to build run requests. from dify_agent.layers.execution_context.configs import ( DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + DifyExecutionContextAgentMode, DifyExecutionContextInvokeFrom, DifyExecutionContextLayerConfig, + DifyExecutionContextUserFrom, ) __all__ = [ "DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID", + "DifyExecutionContextAgentMode", "DifyExecutionContextInvokeFrom", "DifyExecutionContextLayerConfig", + "DifyExecutionContextUserFrom", ] diff --git a/dify-agent/src/dify_agent/layers/execution_context/configs.py b/dify-agent/src/dify_agent/layers/execution_context/configs.py index e5eedbba3c..f72e59292a 100644 --- a/dify-agent/src/dify_agent/layers/execution_context/configs.py +++ b/dify-agent/src/dify_agent/layers/execution_context/configs.py @@ -5,6 +5,13 @@ transport context shared by plugin-backed business layers. The identifiers are for observability and product correlation only; callers must not treat them as authorization proof. Server-only plugin-daemon settings are injected by the runtime provider factory and therefore do not appear in this public schema. + +Protocol note (Agent Files §1.3 / ENG-589): ``invoke_from`` now carries the *real* +Dify invocation source (who triggered the run) so downstream file/drive APIs can +rebuild the access context, while the agent *run mode* (how the runtime is driven) +moved to the dedicated ``agent_mode`` field. For back-compat ``invoke_from`` still +accepts the legacy agent-mode literals; new requests set ``agent_mode`` + a real +``invoke_from`` + ``user_from``. """ from typing import ClassVar, Final, Literal, TypeAlias @@ -15,7 +22,31 @@ from agenton.layers import LayerConfig DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID: Final[str] = "dify.execution_context" + +# How the Dify Agent runtime is being driven (the agent run mode). +DifyExecutionContextAgentMode: TypeAlias = Literal[ + "workflow_run", + "single_step", + "agent_app", + "babysit", + "fasten", +] + +# The origin class of the acting user. +DifyExecutionContextUserFrom: TypeAlias = Literal["account", "end-user"] + +# The real Dify invocation source. Includes the legacy agent-mode literals so +# older requests that carried the run mode in ``invoke_from`` still validate. DifyExecutionContextInvokeFrom: TypeAlias = Literal[ + "service-api", + "openapi", + "web-app", + "trigger", + "explore", + "debugger", + "published", + "validation", + # legacy agent-mode values (back-compat) "workflow_run", "single_step", "agent_app", @@ -29,6 +60,7 @@ class DifyExecutionContextLayerConfig(LayerConfig): tenant_id: str user_id: str | None = None + user_from: DifyExecutionContextUserFrom | None = None app_id: str | None = None workflow_id: str | None = None workflow_run_id: str | None = None @@ -37,7 +69,11 @@ class DifyExecutionContextLayerConfig(LayerConfig): conversation_id: str | None = None agent_id: str | None = None agent_config_version_id: str | None = None - invoke_from: DifyExecutionContextInvokeFrom + # Real Dify invocation source. Optional for back-compat (older requests carried + # the agent run mode here instead). + invoke_from: DifyExecutionContextInvokeFrom | None = None + # The agent run mode. New requests set this explicitly. + agent_mode: DifyExecutionContextAgentMode | None = None trace_id: str | None = None model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True) @@ -45,6 +81,8 @@ class DifyExecutionContextLayerConfig(LayerConfig): __all__ = [ "DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID", + "DifyExecutionContextAgentMode", "DifyExecutionContextInvokeFrom", "DifyExecutionContextLayerConfig", + "DifyExecutionContextUserFrom", ] diff --git a/dify-agent/tests/local/dify_agent/layers/execution_context/test_configs.py b/dify-agent/tests/local/dify_agent/layers/execution_context/test_configs.py index 691a483b65..1cd268ad2f 100644 --- a/dify-agent/tests/local/dify_agent/layers/execution_context/test_configs.py +++ b/dify-agent/tests/local/dify_agent/layers/execution_context/test_configs.py @@ -8,13 +8,36 @@ from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig def test_execution_context_package_exports_client_safe_config_symbols_only() -> None: assert execution_context_exports.__all__ == [ "DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID", + "DifyExecutionContextAgentMode", "DifyExecutionContextInvokeFrom", "DifyExecutionContextLayerConfig", + "DifyExecutionContextUserFrom", ] assert execution_context_exports.DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID == "dify.execution_context" assert not hasattr(execution_context_exports, "DifyExecutionContextLayer") +def test_execution_context_accepts_real_invoke_from_user_from_and_agent_mode() -> None: + config = DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_id="user-1", + user_from="end-user", + invoke_from="web-app", + agent_mode="agent_app", + ) + + assert config.user_from == "end-user" + assert config.invoke_from == "web-app" + assert config.agent_mode == "agent_app" + + +def test_execution_context_still_accepts_legacy_agent_mode_in_invoke_from() -> None: + # Back-compat: older requests carried the run mode in invoke_from. + config = DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run") + assert config.invoke_from == "workflow_run" + assert config.agent_mode is None + + def test_execution_context_layer_config_forbids_runtime_settings_and_unknown_fields() -> None: config = DifyExecutionContextLayerConfig( tenant_id="tenant-1", diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index 77a3ff7a5e..80ad462299 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -269,6 +269,10 @@ import { zPostAppsByAppIdAgentFeaturesBody, zPostAppsByAppIdAgentFeaturesPath, zPostAppsByAppIdAgentFeaturesResponse, + zPostAppsByAppIdAgentSkillsStandardizePath, + zPostAppsByAppIdAgentSkillsStandardizeResponse, + zPostAppsByAppIdAgentSkillsUploadPath, + zPostAppsByAppIdAgentSkillsUploadResponse, zPostAppsByAppIdAnnotationReplyByActionBody, zPostAppsByAppIdAnnotationReplyByActionPath, zPostAppsByAppIdAnnotationReplyByActionResponse, @@ -1036,8 +1040,74 @@ export const logs = { get: get10, } +/** + * Upload a Skill, validate it, and standardize it into the app agent's drive + * + * Validate + standardize a Skill into the agent drive (ENG-594) + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post11 = oc + .route({ + deprecated: true, + description: + 'Validate + standardize a Skill into the agent drive (ENG-594)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdAgentSkillsStandardize', + path: '/apps/{app_id}/agent/skills/standardize', + successStatus: 201, + summary: 'Upload a Skill, validate it, and standardize it into the app agent\'s drive', + tags: ['console'], + }) + .input(z.object({ params: zPostAppsByAppIdAgentSkillsStandardizePath })) + .output(zPostAppsByAppIdAgentSkillsStandardizeResponse) + +export const standardize = { + post: post11, +} + +/** + * Validate an uploaded Skill package and persist the archive + * + * Upload + validate a Skill package (.zip/.skill) and extract its manifest + * 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. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post12 = oc + .route({ + deprecated: true, + 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.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdAgentSkillsUpload', + path: '/apps/{app_id}/agent/skills/upload', + successStatus: 201, + summary: 'Validate an uploaded Skill package and persist the archive', + tags: ['console'], + }) + .input(z.object({ params: zPostAppsByAppIdAgentSkillsUploadPath })) + .output(zPostAppsByAppIdAgentSkillsUploadResponse) + +export const upload = { + post: post12, +} + +export const skills = { + standardize, + upload, +} + export const agent = { logs, + skills, } /** @@ -1076,7 +1146,7 @@ export const status = { * * @deprecated */ -export const post11 = oc +export const post13 = oc .route({ deprecated: true, description: @@ -1096,7 +1166,7 @@ export const post11 = oc .output(zPostAppsByAppIdAnnotationReplyByActionResponse) export const byAction = { - post: post11, + post: post13, status, } @@ -1136,7 +1206,7 @@ export const annotationSetting = { * * @deprecated */ -export const post12 = oc +export const post14 = oc .route({ deprecated: true, description: @@ -1156,7 +1226,7 @@ export const post12 = oc .output(zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse) export const byAnnotationSettingId = { - post: post12, + post: post14, } export const annotationSettings = { @@ -1170,7 +1240,7 @@ export const annotationSettings = { * * @deprecated */ -export const post13 = oc +export const post15 = oc .route({ deprecated: true, description: @@ -1185,7 +1255,7 @@ export const post13 = oc .output(zPostAppsByAppIdAnnotationsBatchImportResponse) export const batchImport = { - post: post13, + post: post15, } /** @@ -1305,7 +1375,7 @@ export const delete_ = oc * * @deprecated */ -export const post14 = oc +export const post16 = oc .route({ deprecated: true, description: @@ -1326,7 +1396,7 @@ export const post14 = oc export const byAnnotationId = { delete: delete_, - post: post14, + post: post16, hitHistories, } @@ -1382,7 +1452,7 @@ export const get17 = oc * * @deprecated */ -export const post15 = oc +export const post17 = oc .route({ deprecated: true, description: @@ -1402,7 +1472,7 @@ export const post15 = oc export const annotations = { delete: delete2, get: get17, - post: post15, + post: post17, batchImport, batchImportStatus, count: count2, @@ -1417,7 +1487,7 @@ export const annotations = { * * @deprecated */ -export const post16 = oc +export const post18 = oc .route({ deprecated: true, description: @@ -1432,13 +1502,13 @@ export const post16 = oc .output(zPostAppsByAppIdApiEnableResponse) export const apiEnable = { - post: post16, + post: post18, } /** * Transcript audio to text for chat messages */ -export const post17 = oc +export const post19 = oc .route({ description: 'Transcript audio to text for chat messages', inputStructure: 'detailed', @@ -1451,7 +1521,7 @@ export const post17 = oc .output(zPostAppsByAppIdAudioToTextResponse) export const audioToText = { - post: post17, + post: post19, } /** @@ -1547,7 +1617,7 @@ export const byMessageId = { /** * Stop a running chat message generation */ -export const post18 = oc +export const post20 = oc .route({ description: 'Stop a running chat message generation', inputStructure: 'detailed', @@ -1560,7 +1630,7 @@ export const post18 = oc .output(zPostAppsByAppIdChatMessagesByTaskIdStopResponse) export const stop = { - post: post18, + post: post20, } export const byTaskId = { @@ -1666,7 +1736,7 @@ export const completionConversations = { /** * Stop a running completion message generation */ -export const post19 = oc +export const post21 = oc .route({ description: 'Stop a running completion message generation', inputStructure: 'detailed', @@ -1679,7 +1749,7 @@ export const post19 = oc .output(zPostAppsByAppIdCompletionMessagesByTaskIdStopResponse) export const stop2 = { - post: post19, + post: post21, } export const byTaskId2 = { @@ -1693,7 +1763,7 @@ export const byTaskId2 = { * * @deprecated */ -export const post20 = oc +export const post22 = oc .route({ deprecated: true, description: @@ -1713,7 +1783,7 @@ export const post20 = oc .output(zPostAppsByAppIdCompletionMessagesResponse) export const completionMessages = { - post: post20, + post: post22, byTaskId: byTaskId2, } @@ -1748,7 +1818,7 @@ export const conversationVariables = { * Convert expert mode of chatbot app to workflow mode * Convert Completion App to Workflow App */ -export const post21 = oc +export const post23 = oc .route({ description: 'Convert application to workflow mode\nConvert expert mode of chatbot app to workflow mode\nConvert Completion App to Workflow App', @@ -1768,7 +1838,7 @@ export const post21 = oc .output(zPostAppsByAppIdConvertToWorkflowResponse) export const convertToWorkflow = { - post: post21, + post: post23, } /** @@ -1780,7 +1850,7 @@ export const convertToWorkflow = { * * @deprecated */ -export const post22 = oc +export const post24 = oc .route({ deprecated: true, description: @@ -1797,7 +1867,7 @@ export const post22 = oc .output(zPostAppsByAppIdCopyResponse) export const copy = { - post: post22, + post: post24, } /** @@ -1857,7 +1927,7 @@ export const export3 = { /** * Create or update message feedback (like/dislike) */ -export const post23 = oc +export const post25 = oc .route({ description: 'Create or update message feedback (like/dislike)', inputStructure: 'detailed', @@ -1870,7 +1940,7 @@ export const post23 = oc .output(zPostAppsByAppIdFeedbacksResponse) export const feedbacks = { - post: post23, + post: post25, export: export3, } @@ -1881,7 +1951,7 @@ export const feedbacks = { * * @deprecated */ -export const post24 = oc +export const post26 = oc .route({ deprecated: true, description: @@ -1896,7 +1966,7 @@ export const post24 = oc .output(zPostAppsByAppIdIconResponse) export const icon = { - post: post24, + post: post26, } /** @@ -1937,7 +2007,7 @@ export const messages = { * * @deprecated */ -export const post25 = oc +export const post27 = oc .route({ deprecated: true, description: @@ -1955,7 +2025,7 @@ export const post25 = oc .output(zPostAppsByAppIdModelConfigResponse) export const modelConfig = { - post: post25, + post: post27, } /** @@ -1965,7 +2035,7 @@ export const modelConfig = { * * @deprecated */ -export const post26 = oc +export const post28 = oc .route({ deprecated: true, description: @@ -1980,13 +2050,13 @@ export const post26 = oc .output(zPostAppsByAppIdNameResponse) export const name = { - post: post26, + post: post28, } /** * Publish app to Creators Platform */ -export const post27 = oc +export const post29 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1999,7 +2069,7 @@ export const post27 = oc .output(zPostAppsByAppIdPublishToCreatorsPlatformResponse) export const publishToCreatorsPlatform = { - post: post27, + post: post29, } /** @@ -2030,7 +2100,7 @@ export const get28 = oc * * @deprecated */ -export const post28 = oc +export const post30 = oc .route({ deprecated: true, description: @@ -2068,14 +2138,14 @@ export const put2 = oc export const server = { get: get28, - post: post28, + post: post30, put: put2, } /** * Reset access token for application site */ -export const post29 = oc +export const post31 = oc .route({ description: 'Reset access token for application site', inputStructure: 'detailed', @@ -2088,13 +2158,13 @@ export const post29 = oc .output(zPostAppsByAppIdSiteAccessTokenResetResponse) export const accessTokenReset = { - post: post29, + post: post31, } /** * Update application site configuration */ -export const post30 = oc +export const post32 = oc .route({ description: 'Update application site configuration', inputStructure: 'detailed', @@ -2107,7 +2177,7 @@ export const post30 = oc .output(zPostAppsByAppIdSiteResponse) export const site = { - post: post30, + post: post32, accessTokenReset, } @@ -2118,7 +2188,7 @@ export const site = { * * @deprecated */ -export const post31 = oc +export const post33 = oc .route({ deprecated: true, description: @@ -2133,7 +2203,7 @@ export const post31 = oc .output(zPostAppsByAppIdSiteEnableResponse) export const siteEnable = { - post: post31, + post: post33, } /** @@ -2424,7 +2494,7 @@ export const voices = { * * @deprecated */ -export const post32 = oc +export const post34 = oc .route({ deprecated: true, description: @@ -2441,7 +2511,7 @@ export const post32 = oc .output(zPostAppsByAppIdTextToAudioResponse) export const textToAudio = { - post: post32, + post: post34, voices, } @@ -2472,7 +2542,7 @@ export const get38 = oc /** * Update app tracing configuration */ -export const post33 = oc +export const post35 = oc .route({ description: 'Update app tracing configuration', inputStructure: 'detailed', @@ -2486,7 +2556,7 @@ export const post33 = oc export const trace = { get: get38, - post: post33, + post: post35, } /** @@ -2571,7 +2641,7 @@ export const patch = oc * * @deprecated */ -export const post34 = oc +export const post36 = oc .route({ deprecated: true, description: @@ -2593,13 +2663,13 @@ export const traceConfig = { delete: delete5, get: get39, patch, - post: post34, + post: post36, } /** * Update app trigger (enable/disable) */ -export const post35 = oc +export const post37 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2617,7 +2687,7 @@ export const post35 = oc .output(zPostAppsByAppIdTriggerEnableResponse) export const triggerEnable = { - post: post35, + post: post37, } /** @@ -2725,7 +2795,7 @@ export const count3 = { * * Stop running workflow task */ -export const post36 = oc +export const post38 = oc .route({ description: 'Stop running workflow task', inputStructure: 'detailed', @@ -2739,7 +2809,7 @@ export const post36 = oc .output(zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse) export const stop3 = { - post: post36, + post: post38, } export const byTaskId3 = { @@ -3016,7 +3086,7 @@ export const byReplyId = { * * Add a reply to a workflow comment */ -export const post37 = oc +export const post39 = oc .route({ description: 'Add a reply to a workflow comment', inputStructure: 'detailed', @@ -3036,7 +3106,7 @@ export const post37 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponse) export const replies = { - post: post37, + post: post39, byReplyId, } @@ -3045,7 +3115,7 @@ export const replies = { * * Resolve a workflow comment */ -export const post38 = oc +export const post40 = oc .route({ description: 'Resolve a workflow comment', inputStructure: 'detailed', @@ -3059,7 +3129,7 @@ export const post38 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse) export const resolve = { - post: post38, + post: post40, } /** @@ -3153,7 +3223,7 @@ export const get53 = oc * * Create a new workflow comment */ -export const post39 = oc +export const post41 = oc .route({ description: 'Create a new workflow comment', inputStructure: 'detailed', @@ -3174,7 +3244,7 @@ export const post39 = oc export const comments = { get: get53, - post: post39, + post: post41, mentionUsers, byCommentId, } @@ -3401,7 +3471,7 @@ export const get60 = oc * * @deprecated */ -export const post40 = oc +export const post42 = oc .route({ deprecated: true, description: @@ -3422,7 +3492,7 @@ export const post40 = oc export const conversationVariables2 = { get: get60, - post: post40, + post: post42, } /** @@ -3456,7 +3526,7 @@ export const get61 = oc * * @deprecated */ -export const post41 = oc +export const post43 = oc .route({ deprecated: true, description: @@ -3477,7 +3547,7 @@ export const post41 = oc export const environmentVariables = { get: get61, - post: post41, + post: post43, } /** @@ -3487,7 +3557,7 @@ export const environmentVariables = { * * @deprecated */ -export const post42 = oc +export const post44 = oc .route({ deprecated: true, description: @@ -3507,7 +3577,7 @@ export const post42 = oc .output(zPostAppsByAppIdWorkflowsDraftFeaturesResponse) export const features = { - post: post42, + post: post44, } /** @@ -3519,7 +3589,7 @@ export const features = { * * @deprecated */ -export const post43 = oc +export const post45 = oc .route({ deprecated: true, description: @@ -3540,7 +3610,7 @@ export const post43 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse) export const deliveryTest = { - post: post43, + post: post45, } /** @@ -3552,7 +3622,7 @@ export const deliveryTest = { * * @deprecated */ -export const post44 = oc +export const post46 = oc .route({ deprecated: true, description: @@ -3573,7 +3643,7 @@ export const post44 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse) export const preview4 = { - post: post44, + post: post46, } /** @@ -3585,7 +3655,7 @@ export const preview4 = { * * @deprecated */ -export const post45 = oc +export const post47 = oc .route({ deprecated: true, description: @@ -3606,7 +3676,7 @@ export const post45 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse) export const run5 = { - post: post45, + post: post47, } export const form2 = { @@ -3636,7 +3706,7 @@ export const humanInput2 = { * * @deprecated */ -export const post46 = oc +export const post48 = oc .route({ deprecated: true, description: @@ -3657,7 +3727,7 @@ export const post46 = oc .output(zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse) export const run6 = { - post: post46, + post: post48, } export const byNodeId6 = { @@ -3681,7 +3751,7 @@ export const iteration2 = { * * @deprecated */ -export const post47 = oc +export const post49 = oc .route({ deprecated: true, description: @@ -3702,7 +3772,7 @@ export const post47 = oc .output(zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse) export const run7 = { - post: post47, + post: post49, } export const byNodeId7 = { @@ -3747,7 +3817,7 @@ export const candidates2 = { * * @deprecated */ -export const post48 = oc +export const post50 = oc .route({ deprecated: true, description: @@ -3767,7 +3837,7 @@ export const post48 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse) export const impact = { - post: post48, + post: post50, } /** @@ -3775,7 +3845,7 @@ export const impact = { * * @deprecated */ -export const post49 = oc +export const post51 = oc .route({ deprecated: true, description: @@ -3795,7 +3865,7 @@ export const post49 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse) export const saveToRoster = { - post: post49, + post: post51, } /** @@ -3803,7 +3873,7 @@ export const saveToRoster = { * * @deprecated */ -export const post50 = oc +export const post52 = oc .route({ deprecated: true, description: @@ -3823,7 +3893,7 @@ export const post50 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse) export const validate2 = { - post: post50, + post: post52, } /** @@ -3906,7 +3976,7 @@ export const lastRun = { * * @deprecated */ -export const post51 = oc +export const post53 = oc .route({ deprecated: true, description: @@ -3927,7 +3997,7 @@ export const post51 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse) export const run8 = { - post: post51, + post: post53, } /** @@ -3939,7 +4009,7 @@ export const run8 = { * * @deprecated */ -export const post52 = oc +export const post54 = oc .route({ deprecated: true, description: @@ -3955,7 +4025,7 @@ export const post52 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse) export const run9 = { - post: post52, + post: post54, } export const trigger = { @@ -4025,7 +4095,7 @@ export const nodes7 = { * * @deprecated */ -export const post53 = oc +export const post55 = oc .route({ deprecated: true, description: @@ -4046,7 +4116,7 @@ export const post53 = oc .output(zPostAppsByAppIdWorkflowsDraftRunResponse) export const run10 = { - post: post53, + post: post55, } /** @@ -4202,7 +4272,7 @@ export const systemVariables = { * * @deprecated */ -export const post54 = oc +export const post56 = oc .route({ deprecated: true, description: @@ -4223,7 +4293,7 @@ export const post54 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunResponse) export const run11 = { - post: post54, + post: post56, } /** @@ -4235,7 +4305,7 @@ export const run11 = { * * @deprecated */ -export const post55 = oc +export const post57 = oc .route({ deprecated: true, description: @@ -4256,7 +4326,7 @@ export const post55 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse) export const runAll = { - post: post55, + post: post57, } export const trigger2 = { @@ -4443,7 +4513,7 @@ export const get73 = oc * * @deprecated */ -export const post56 = oc +export const post58 = oc .route({ deprecated: true, description: @@ -4465,7 +4535,7 @@ export const post56 = oc export const draft2 = { get: get73, - post: post56, + post: post58, conversationVariables: conversationVariables2, environmentVariables, features, @@ -4511,7 +4581,7 @@ export const get74 = oc * * @deprecated */ -export const post57 = oc +export const post59 = oc .route({ deprecated: true, description: @@ -4533,7 +4603,7 @@ export const post57 = oc export const publish = { get: get74, - post: post57, + post: post59, } /** @@ -4705,7 +4775,7 @@ export const triggers2 = { * * @deprecated */ -export const post58 = oc +export const post60 = oc .route({ deprecated: true, description: @@ -4720,7 +4790,7 @@ export const post58 = oc .output(zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse) export const restore = { - post: post58, + post: post60, } /** @@ -4978,7 +5048,7 @@ export const get82 = oc * * Create a new API key for an app */ -export const post59 = oc +export const post61 = oc .route({ description: 'Create a new API key for an app', inputStructure: 'detailed', @@ -4994,7 +5064,7 @@ export const post59 = oc export const apiKeys = { get: get82, - post: post59, + post: post61, byApiKeyId, } @@ -5062,7 +5132,7 @@ export const get84 = oc * * @deprecated */ -export const post60 = oc +export const post62 = oc .route({ deprecated: true, description: @@ -5080,7 +5150,7 @@ export const post60 = oc export const apps = { get: get84, - post: post60, + post: post62, 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 41c8fdc9dd..7cc5a0bd5c 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -2601,6 +2601,60 @@ export type GetAppsByAppIdAgentLogsResponses = { export type GetAppsByAppIdAgentLogsResponse = GetAppsByAppIdAgentLogsResponses[keyof GetAppsByAppIdAgentLogsResponses] +export type PostAppsByAppIdAgentSkillsStandardizeData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/agent/skills/standardize' +} + +export type PostAppsByAppIdAgentSkillsStandardizeErrors = { + 400: { + [key: string]: unknown + } +} + +export type PostAppsByAppIdAgentSkillsStandardizeError + = PostAppsByAppIdAgentSkillsStandardizeErrors[keyof PostAppsByAppIdAgentSkillsStandardizeErrors] + +export type PostAppsByAppIdAgentSkillsStandardizeResponses = { + 201: { + [key: string]: unknown + } +} + +export type PostAppsByAppIdAgentSkillsStandardizeResponse + = PostAppsByAppIdAgentSkillsStandardizeResponses[keyof PostAppsByAppIdAgentSkillsStandardizeResponses] + +export type PostAppsByAppIdAgentSkillsUploadData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/agent/skills/upload' +} + +export type PostAppsByAppIdAgentSkillsUploadErrors = { + 400: { + [key: string]: unknown + } +} + +export type PostAppsByAppIdAgentSkillsUploadError + = PostAppsByAppIdAgentSkillsUploadErrors[keyof PostAppsByAppIdAgentSkillsUploadErrors] + +export type PostAppsByAppIdAgentSkillsUploadResponses = { + 201: { + [key: string]: unknown + } +} + +export type PostAppsByAppIdAgentSkillsUploadResponse + = PostAppsByAppIdAgentSkillsUploadResponses[keyof PostAppsByAppIdAgentSkillsUploadResponses] + 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 31753f203b..46f37b1b8e 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -3029,6 +3029,24 @@ export const zGetAppsByAppIdAgentLogsQuery = z.object({ */ export const zGetAppsByAppIdAgentLogsResponse = z.array(z.record(z.string(), z.unknown())) +export const zPostAppsByAppIdAgentSkillsStandardizePath = z.object({ + app_id: z.string(), +}) + +/** + * Skill standardized into drive + */ +export const zPostAppsByAppIdAgentSkillsStandardizeResponse = z.record(z.string(), z.unknown()) + +export const zPostAppsByAppIdAgentSkillsUploadPath = z.object({ + app_id: z.string(), +}) + +/** + * Skill validated + */ +export const zPostAppsByAppIdAgentSkillsUploadResponse = z.record(z.string(), z.unknown()) + export const zPostAppsByAppIdAnnotationReplyByActionBody = zAnnotationReplyPayload export const zPostAppsByAppIdAnnotationReplyByActionPath = z.object({