feat(agent): Agent Files / agent Cloud storage — api backend (ENG-589) (#37172)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
zyssyz123 2026-06-09 12:01:05 +08:00 committed by GitHub
parent 789698cddd
commit a80bba2c35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 2730 additions and 166 deletions

View File

@ -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/<uuid:app_id>/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/<uuid:app_id>/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

View File

@ -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",

View File

@ -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-<agent_id>``; 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/<string:drive_ref>/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/<string:drive_ref>/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}

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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(),
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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": "<tool_file_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"]

View File

@ -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": "<tool_file_id>"}``; the rest are accepted aliases.
_FILE_ID_KEYS: tuple[str, ...] = ("id", "file_id", "upload_file_id", "tool_file_id")
class PerOutputTypeChecker:

View File

@ -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"

View File

@ -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 ###

View File

@ -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",

View File

@ -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-<agent_id>`` (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-<agent_id>; 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)

View File

@ -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

View File

@ -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"]

View File

@ -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):
* ``<slug>/SKILL.md`` the canonical entry, the source of truth for loading.
* ``<slug>/.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"]

View File

@ -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-<agent_id>`` 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-<agent_id>'", 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-<agent_id>)."""
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",
]

View File

@ -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"]

View File

@ -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"

View File

@ -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))

View File

@ -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:

View File

@ -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"

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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": "<tool_file_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)

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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",
]

View File

@ -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",
]

View File

@ -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",

View File

@ -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,

View File

@ -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: {

View File

@ -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({