mirror of
https://github.com/langgenius/dify.git
synced 2026-06-10 18:24:09 +08:00
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:
parent
789698cddd
commit
a80bba2c35
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
80
api/controllers/inner_api/plugin/agent_drive.py
Normal file
80
api/controllers/inner_api/plugin/agent_drive.py
Normal 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}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
63
api/core/workflow/nodes/agent_v2/output_file_rebacker.py
Normal file
63
api/core/workflow/nodes/agent_v2/output_file_rebacker.py
Normal 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"]
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 ###
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
212
api/services/agent/skill_package_service.py
Normal file
212
api/services/agent/skill_package_service.py
Normal 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"]
|
||||
123
api/services/agent/skill_standardize_service.py
Normal file
123
api/services/agent/skill_standardize_service.py
Normal 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"]
|
||||
364
api/services/agent_drive_service.py
Normal file
364
api/services/agent_drive_service.py
Normal 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",
|
||||
]
|
||||
93
api/services/agent_file_request_service.py
Normal file
93
api/services/agent_file_request_service.py
Normal 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"]
|
||||
@ -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"
|
||||
@ -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))
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
@ -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"
|
||||
339
api/tests/unit_tests/services/test_agent_drive_service.py
Normal file
339
api/tests/unit_tests/services/test_agent_drive_service.py
Normal 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
|
||||
105
api/tests/unit_tests/services/test_agent_file_request_service.py
Normal file
105
api/tests/unit_tests/services/test_agent_file_request_service.py
Normal 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
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user