Compare commits

..

2 Commits

Author SHA1 Message Date
09b6f25fb9 fix: render marketplace template icons with AppIcon (#37401) 2026-06-13 02:50:53 +00:00
8cac86d5c5 feat(agent): Skills & Files effective chain — drive runtime exposure, inspector, lifecycle, infer-tools (#37370)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-13 02:30:55 +00:00
41 changed files with 4088 additions and 375 deletions

View File

@ -26,6 +26,7 @@ from dify_agent.layers.dify_plugin import (
DifyPluginLLMLayerConfig,
DifyPluginToolsLayerConfig,
)
from dify_agent.layers.drive import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig
from dify_agent.layers.execution_context import (
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
DifyExecutionContextLayerConfig,
@ -50,6 +51,7 @@ WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt"
WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt"
AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt"
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
DIFY_DRIVE_LAYER_ID = "drive"
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
DIFY_SHELL_LAYER_ID = "shell"
@ -134,6 +136,9 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
idempotency_key: str | None = None
output: AgentBackendOutputConfig | None = None
tools: DifyPluginToolsLayerConfig | None = None
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
# through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED.
drive_config: DifyDriveLayerConfig | None = None
# Inject the sandboxed shell layer (dify.shell). Requires the agent backend
# to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED.
include_shell: bool = False
@ -170,6 +175,9 @@ class AgentBackendAgentAppRunInput(BaseModel):
idempotency_key: str | None = None
output: AgentBackendOutputConfig | None = None
tools: DifyPluginToolsLayerConfig | None = None
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
# through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED.
drive_config: DifyDriveLayerConfig | None = None
# Inject the sandboxed shell layer (dify.shell). Requires the agent backend
# to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED.
include_shell: bool = False
@ -228,6 +236,18 @@ class AgentBackendRunRequestBuilder:
]
)
if run_input.drive_config is not None:
# Drive Skills & Files declaration (dify.drive): a config-only index;
# the agent pulls listed entries through the back proxy by drive_ref.
layers.append(
RunLayerSpec(
name=DIFY_DRIVE_LAYER_ID,
type=DIFY_DRIVE_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=run_input.drive_config,
)
)
if run_input.include_history:
layers.append(
RunLayerSpec(
@ -383,6 +403,18 @@ class AgentBackendRunRequestBuilder:
]
)
if run_input.drive_config is not None:
# Drive Skills & Files declaration (dify.drive): a config-only index;
# the agent pulls listed entries through the back proxy by drive_ref.
layers.append(
RunLayerSpec(
name=DIFY_DRIVE_LAYER_ID,
type=DIFY_DRIVE_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=run_input.drive_config,
)
)
if run_input.include_history:
layers.append(
RunLayerSpec(

View File

@ -31,3 +31,13 @@ class AgentBackendConfig(BaseSettings):
),
default=False,
)
AGENT_DRIVE_MANIFEST_ENABLED: bool = Field(
description=(
"Inject the dify.drive layer (Skills & Files drive manifest declaration) "
"into Agent runs. The declaration is an index only — the agent backend "
"pulls the actual SKILL.md / files through the back proxy. Keep it off "
"until the agent backend registers the dify.drive layer type."
),
default=False,
)

View File

@ -54,6 +54,7 @@ from .app import (
agent_app_access,
agent_app_feature,
agent_app_sandbox,
agent_drive_inspector,
annotation,
app,
audio,
@ -155,6 +156,7 @@ __all__ = [
"agent_app_feature",
"agent_app_sandbox",
"agent_composer",
"agent_drive_inspector",
"agent_providers",
"agent_roster",
"annotation",

View File

@ -94,7 +94,13 @@ class WorkflowAgentComposerValidateApi(Resource):
def post(self, tenant_id: str, app_model: App, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload)
findings = AgentComposerService.collect_validation_findings(
tenant_id=tenant_id,
payload=payload,
agent_id=AgentComposerService.resolve_workflow_node_agent_id(
tenant_id=tenant_id, app_id=app_model.id, node_id=node_id
),
)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
@ -220,7 +226,11 @@ class AgentAppComposerValidateApi(Resource):
def post(self, tenant_id: str, app_model: App):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload)
findings = AgentComposerService.collect_validation_findings(
tenant_id=tenant_id,
payload=payload,
agent_id=AgentComposerService.resolve_bound_agent_id(tenant_id=tenant_id, app_id=app_model.id),
)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})

View File

@ -1,10 +1,17 @@
import logging
from typing import Any
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel, field_validator
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_response_schema_models,
register_schema_models,
)
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required, with_current_user
@ -13,13 +20,30 @@ from fields.base import ResponseModel
from libs.helper import uuid_value
from libs.login import login_required
from models import Account
from models.model import App, AppMode
from services.agent.skill_package_service import SkillPackageError, SkillPackageService
from models.agent_config_entities import AgentFileRefConfig, AgentSkillRefConfig
from models.model import App, AppMode, UploadFile
from services.agent.composer_service import AgentComposerService
from services.agent.skill_package_service import SkillManifest, SkillPackageError, SkillPackageService
from services.agent.skill_standardize_service import SkillStandardizeService
from services.agent_drive_service import AgentDriveError
from services.agent.skill_tool_inference_service import (
SkillToolInferenceError,
SkillToolInferenceResult,
SkillToolInferenceService,
)
from services.agent_drive_service import (
AgentDriveError,
AgentDriveService,
DriveCommitItem,
DriveFileRef,
normalize_drive_key,
)
from services.agent_service import AgentService
from services.file_service import FileService
logger = logging.getLogger(__name__)
_AGENT_DRIVE_APP_MODES = [AppMode.AGENT, AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
class AgentLogQuery(BaseModel):
message_id: str = Field(..., description="Message UUID")
@ -31,6 +55,23 @@ class AgentLogQuery(BaseModel):
return uuid_value(value)
class AgentDriveFilePayload(BaseModel):
upload_file_id: str = Field(..., description="UploadFile UUID from POST /console/api/files/upload")
@field_validator("upload_file_id")
@classmethod
def validate_upload_file_id(cls, value: str) -> str:
return uuid_value(value)
class AgentDriveMutationQuery(BaseModel):
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveDeleteFileQuery(AgentDriveMutationQuery):
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
class AgentLogMetaResponse(ResponseModel):
status: str
executor: str
@ -68,16 +109,58 @@ class AgentLogResponse(ResponseModel):
files: list[Any] = Field(default_factory=list)
class AgentSkillUploadResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
class AgentSkillUploadResponse(ResponseModel):
skill: AgentSkillRefConfig
manifest: SkillManifest
class AgentSkillStandardizeResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
class AgentSkillStandardizeResponse(ResponseModel):
skill: AgentSkillRefConfig
manifest: SkillManifest
register_schema_models(console_ns, AgentLogQuery)
register_response_schema_models(console_ns, AgentLogResponse, AgentSkillUploadResponse, AgentSkillStandardizeResponse)
class AgentDriveFileResponse(ResponseModel):
name: str
drive_key: str
file_id: str
size: int | None = None
mime_type: str | None = None
class AgentDriveFileCommitResponse(ResponseModel):
file: AgentDriveFileResponse
config_version_id: str | None = None
class AgentDriveDeleteResponse(ResponseModel):
result: str
removed_keys: list[str] = Field(default_factory=list)
config_version_id: str | None = None
register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload)
register_response_schema_models(
console_ns,
AgentDriveDeleteResponse,
AgentDriveFileCommitResponse,
AgentDriveFileResponse,
AgentLogResponse,
AgentSkillStandardizeResponse,
AgentSkillUploadResponse,
SkillToolInferenceResult,
)
def _resolve_agent_id(app_model: App, node_id: str | None) -> str | None:
if node_id:
return AgentComposerService.resolve_workflow_node_agent_id(
tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
)
return app_model.bound_agent_id
def _agent_not_bound() -> tuple[dict[str, str], int]:
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
@console_ns.route("/apps/<uuid:app_id>/agent/logs")
@ -109,7 +192,7 @@ class AgentSkillUploadApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@get_app_model(mode=_AGENT_DRIVE_APP_MODES)
@with_current_user
def post(self, current_user: Account, app_model: App):
"""Validate an uploaded Skill package and persist the archive.
@ -143,7 +226,7 @@ class AgentSkillUploadApi(Resource):
class AgentSkillStandardizeApi(Resource):
@console_ns.doc("standardize_agent_skill")
@console_ns.doc(description="Validate + standardize a Skill into the agent drive (ENG-594)")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveMutationQuery)})
@console_ns.response(
201,
"Skill standardized into drive",
@ -153,13 +236,14 @@ class AgentSkillStandardizeApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@get_app_model(mode=_AGENT_DRIVE_APP_MODES)
@with_current_user
def post(self, current_user: Account, app_model: App):
"""Upload a Skill, validate it, and standardize it into the app agent's drive."""
agent_id = app_model.bound_agent_id
query = query_params_from_request(AgentDriveMutationQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return {"code": "no_bound_agent", "message": "app has no bound agent"}, 400
return _agent_not_bound()
if "file" not in request.files:
return {"code": "no_file", "message": "no skill file uploaded"}, 400
if len(request.files) > 1:
@ -178,3 +262,205 @@ class AgentSkillStandardizeApi(Resource):
except (SkillPackageError, AgentDriveError) as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
return result, 201
@console_ns.route("/apps/<uuid:app_id>/agent/files")
class AgentDriveFilesApi(Resource):
@console_ns.doc("commit_agent_drive_file")
@console_ns.doc(description="Commit an uploaded file into the agent drive under files/<name> (ENG-625 D3)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveMutationQuery)})
@console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
@console_ns.response(
201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_AGENT_DRIVE_APP_MODES)
@with_current_user
def post(self, current_user: Account, app_model: App):
"""ADD FILE: commit one uploaded file into the bound agent's drive."""
query = query_params_from_request(AgentDriveMutationQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
payload = AgentDriveFilePayload.model_validate(console_ns.payload or {})
upload_file = db.session.scalar(
select(UploadFile).where(
UploadFile.id == payload.upload_file_id,
UploadFile.tenant_id == app_model.tenant_id,
)
)
if upload_file is None:
return {"code": "upload_file_not_found", "message": "upload file not found in this workspace"}, 404
try:
key = normalize_drive_key(f"files/{upload_file.name}")
committed = AgentDriveService().commit(
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
items=[
DriveCommitItem(
key=key,
file_ref=DriveFileRef(kind="upload_file", id=upload_file.id),
# ADD FILE uploads exist solely to live in the drive, so the
# drive owns (and physically cleans) the value on delete.
value_owned_by_drive=True,
)
],
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
row = committed[0]
file_ref = AgentFileRefConfig.model_validate(
{
"id": row["key"],
"name": upload_file.name,
"file_id": upload_file.id,
"drive_key": row["key"],
"type": row.get("mime_type"),
"size": row.get("size"),
}
)
config_version_id = AgentComposerService.add_drive_file_ref(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
file_ref=file_ref,
app_id=app_model.id,
node_id=query.node_id,
)
return {
"file": {
"name": upload_file.name,
"drive_key": row["key"],
"file_id": upload_file.id,
"size": row.get("size"),
"mime_type": row.get("mime_type"),
},
"config_version_id": config_version_id,
}, 201
@console_ns.doc("delete_agent_drive_file")
@console_ns.doc(description="Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveDeleteFileQuery)})
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_AGENT_DRIVE_APP_MODES)
@with_current_user
def delete(self, current_user: Account, app_model: App):
query = query_params_from_request(AgentDriveDeleteFileQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
key = normalize_drive_key(query.key)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
config_version_id = AgentComposerService.remove_drive_refs(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
file_key=key,
app_id=app_model.id,
node_id=query.node_id,
)
removed_keys: list[str] = []
try:
removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, key=key)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
except Exception:
# Soul-first ordering: the ref is already gone; orphan KV rows are
# harmless and an idempotent DELETE retry cleans them.
logger.exception("agent drive delete failed for key %s (soul already updated)", key)
return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id}
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>")
class AgentSkillApi(Resource):
@console_ns.doc("delete_agent_skill")
@console_ns.doc(
description="Delete a standardized skill: soul ref first, then the <slug>/ drive prefix (ENG-625 D5)"
)
@console_ns.doc(
params={
"app_id": "Application ID",
"slug": "Skill slug (single path segment)",
**query_params_from_model(AgentDriveMutationQuery),
}
)
@console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_AGENT_DRIVE_APP_MODES)
@with_current_user
def delete(self, current_user: Account, app_model: App, slug: str):
query = query_params_from_request(AgentDriveMutationQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
if "/" in slug or not slug.strip():
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
config_version_id = AgentComposerService.remove_drive_refs(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
skill_slug=slug,
app_id=app_model.id,
node_id=query.node_id,
)
removed_keys: list[str] = []
try:
removed_keys = AgentDriveService().delete(
tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=f"{slug}/"
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
except Exception:
logger.exception("agent drive delete failed for skill %s (soul already updated)", slug)
return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id}
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>/infer-tools")
class AgentSkillInferToolsApi(Resource):
@console_ns.doc("infer_agent_skill_tools")
@console_ns.doc(
description="Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)"
)
@console_ns.doc(
params={
"app_id": "Application ID",
"slug": "Skill slug (single path segment)",
**query_params_from_model(AgentDriveMutationQuery),
}
)
@console_ns.response(
200,
"Inference result (draft suggestions, nothing persisted)",
console_ns.models[SkillToolInferenceResult.__name__],
)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_AGENT_DRIVE_APP_MODES)
def post(self, app_model: App, slug: str):
"""Suggest CLI tools/env for a skill. Saving still goes through composer validation."""
query = query_params_from_request(AgentDriveMutationQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
if "/" in slug or not slug.strip():
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
try:
return SkillToolInferenceService().infer(tenant_id=app_model.tenant_id, agent_id=agent_id, slug=slug)
except SkillToolInferenceError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code

View File

@ -0,0 +1,162 @@
"""Console read-only inspector for the agent drive (ENG-624).
``agent-drive`` looks at the *static* drive assets (standardized skills and
committed files); the sibling ``agent-sandbox`` routes look at a *runtime*
sandbox workspace. Unlike the sandbox routes this never proxies to the agent
backend — drive data lives in the API's own DB/storage, served straight from
``AgentDriveService``. Download hands the browser an **external** signed URL
(the inner manifest hands agents internal ones — the two must never mix).
"""
from __future__ import annotations
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_response_schema_models,
)
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required
from fields.base import ResponseModel
from libs.login import login_required
from models.model import App, AppMode
from services.agent.composer_service import AgentComposerService
from services.agent_drive_service import AgentDriveError, AgentDriveService
class AgentDriveListQuery(BaseModel):
prefix: str = Field(default="", description="Key prefix filter: '<slug>/' for one skill, 'files/' for files")
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveFileQuery(BaseModel):
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveItemResponse(ResponseModel):
key: str
size: int | None = None
mime_type: str | None = None
hash: str | None = None
file_kind: str
created_at: int | None = None
class AgentDriveListResponse(ResponseModel):
items: list[AgentDriveItemResponse] = Field(default_factory=list)
class AgentDrivePreviewResponse(ResponseModel):
key: str
size: int | None = None
truncated: bool
binary: bool
text: str | None = None
class AgentDriveDownloadResponse(ResponseModel):
url: str
register_response_schema_models(
console_ns, AgentDriveListResponse, AgentDrivePreviewResponse, AgentDriveDownloadResponse
)
def _resolve_agent_id(app_model: App, node_id: str | None) -> str | None:
"""Agent identity for the drive: app-bound agent, or the workflow node binding."""
if node_id:
return AgentComposerService.resolve_workflow_node_agent_id(
tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
)
return app_model.bound_agent_id
def _agent_not_bound() -> tuple[dict[str, object], int]:
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]:
return {"code": exc.code, "message": exc.message}, exc.status_code
_APP_MODES = [AppMode.AGENT, AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files")
class AgentDriveListApi(Resource):
@console_ns.doc("list_agent_drive_files")
@console_ns.doc(description="List agent drive entries (read-only inspector; one endpoint for both tabs)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)})
@console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_APP_MODES)
def get(self, app_model: App):
query = query_params_from_request(AgentDriveListQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
items = AgentDriveService().manifest(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix)
except AgentDriveError as exc:
return _handle(exc)
# the inner manifest exposes file_id for agent-side pulls; the console
# inspector is a pure read surface and does not need value pointers
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/preview")
class AgentDrivePreviewApi(Resource):
@console_ns.doc("preview_agent_drive_file")
@console_ns.doc(description="Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
@console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_APP_MODES)
def get(self, app_model: App):
query = query_params_from_request(AgentDriveFileQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
return AgentDriveService().preview(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
except AgentDriveError as exc:
return _handle(exc)
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/download")
class AgentDriveDownloadApi(Resource):
@console_ns.doc("download_agent_drive_file")
@console_ns.doc(description="Time-limited external signed URL for one drive value (no streaming proxy)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
@console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_APP_MODES)
def get(self, app_model: App):
query = query_params_from_request(AgentDriveFileQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
url = AgentDriveService().download_url(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
except AgentDriveError as exc:
return _handle(exc)
return {"url": url}
__all__ = [
"AgentDriveDownloadApi",
"AgentDriveListApi",
"AgentDrivePreviewApi",
]

View File

@ -32,7 +32,11 @@ from core.workflow.nodes.agent_v2.plugin_tools_builder import (
WorkflowAgentPluginToolsBuilder,
WorkflowAgentPluginToolsBuildError,
)
from core.workflow.nodes.agent_v2.runtime_request_builder import build_shell_layer_config
from core.workflow.nodes.agent_v2.runtime_request_builder import (
append_runtime_warnings,
build_drive_layer_config,
build_shell_layer_config,
)
from models.agent_config_entities import AgentSoulConfig
from models.provider_ids import ModelProviderID
from services.agent.prompt_mentions import build_soul_mention_resolver, expand_prompt_mentions
@ -112,6 +116,11 @@ class AgentAppRuntimeRequestBuilder:
"cli_tool_count": len(agent_soul.tools.cli_tools),
}
drive_config = None
if dify_config.AGENT_DRIVE_MANIFEST_ENABLED:
drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent_id)
append_runtime_warnings(metadata, drive_warnings)
request = self._request_builder.build_for_agent_app(
AgentBackendAgentAppRunInput(
model=AgentBackendModelConfig(
@ -144,6 +153,7 @@ class AgentAppRuntimeRequestBuilder:
or None,
user_prompt=context.user_query,
tools=tools_layer,
drive_config=drive_config,
include_shell=dify_config.AGENT_SHELL_ENABLED,
shell_config=build_shell_layer_config(agent_soul),
session_snapshot=context.session_snapshot,

View File

@ -15,12 +15,14 @@ SUPPORTED_AGENT_BACKEND_FEATURES = frozenset(
"tools.cli_tools",
"env",
"sandbox",
# ENG-623: exposed at runtime as the dify.drive declaration layer
# (an index the agent pulls through the back proxy).
"skills_files",
}
)
RESERVED_AGENT_BACKEND_FEATURES = frozenset(
{
"skills_files",
"knowledge",
"human",
"memory",
@ -28,7 +30,11 @@ RESERVED_AGENT_BACKEND_FEATURES = frozenset(
)
def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any]:
def build_runtime_feature_manifest(
agent_soul: AgentSoulConfig,
*,
drive_manifest_enabled: bool = False,
) -> dict[str, Any]:
"""Describe PRD capabilities supported by or still reserved from Agent backend runtime."""
warnings: list[dict[str, str]] = []
soul_dump = agent_soul.model_dump(mode="json", exclude_none=True, exclude_defaults=True)
@ -46,7 +52,35 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any
}
)
has_skills_files = bool(agent_soul.skills_files.skills or agent_soul.skills_files.files)
if has_skills_files and not drive_manifest_enabled:
warnings.append(
{
"section": "agent_soul.skills_files",
"code": "drive_manifest_disabled",
"message": (
"skills_files is configured but AGENT_DRIVE_MANIFEST_ENABLED is off; "
"the drive declaration layer is not injected into this run."
),
}
)
for skill in agent_soul.skills_files.skills:
if not skill.skill_md_key:
warnings.append(
{
"section": "agent_soul.skills_files",
"code": "skill_ref_dangling",
"message": (
f"skill_ref_dangling: skill '{skill.name or skill.id or 'unknown'}' has no drive key; "
"re-standardize it to expose it at runtime."
),
}
)
reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed")
reserved_status["skills_files"] = (
"supported_by_drive_manifest" if drive_manifest_enabled else "drive_manifest_disabled"
)
reserved_status["tools.dify_tools"] = "supported_when_config_valid"
reserved_status["tools.cli_tools"] = "supported_by_shell_bootstrap"
reserved_status["env"] = "supported_by_shell_bootstrap"

View File

@ -5,6 +5,11 @@ from dataclasses import dataclass
from typing import Any, Literal, Protocol, assert_never, cast
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.layers.drive import (
DifyDriveFileConfig,
DifyDriveLayerConfig,
DifyDriveSkillConfig,
)
from dify_agent.layers.execution_context import (
DifyExecutionContextInvokeFrom,
DifyExecutionContextLayerConfig,
@ -169,6 +174,11 @@ class WorkflowAgentRuntimeRequestBuilder:
"cli_tool_count": len(agent_soul.tools.cli_tools),
}
drive_config: DifyDriveLayerConfig | None = None
if dify_config.AGENT_DRIVE_MANIFEST_ENABLED:
drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent.id)
append_runtime_warnings(metadata, drive_warnings)
request = self._request_builder.build_for_workflow_node(
AgentBackendWorkflowNodeRunInput(
model=AgentBackendModelConfig(
@ -206,6 +216,7 @@ class WorkflowAgentRuntimeRequestBuilder:
user_prompt=user_prompt,
output=self._build_output_config(node_job.declared_outputs),
tools=tools_layer,
drive_config=drive_config,
include_shell=dify_config.AGENT_SHELL_ENABLED,
shell_config=build_shell_layer_config(agent_soul),
session_snapshot=context.session_snapshot,
@ -269,7 +280,10 @@ class WorkflowAgentRuntimeRequestBuilder:
"agent_config_snapshot_id": context.snapshot.id,
"binding_id": context.binding.id,
"workflow_node_job_mode": node_job.mode.value,
"runtime_support": build_runtime_feature_manifest(agent_soul),
"runtime_support": build_runtime_feature_manifest(
agent_soul,
drive_manifest_enabled=dify_config.AGENT_DRIVE_MANIFEST_ENABLED,
),
}
def _build_workflow_context_prompt(
@ -482,6 +496,89 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi
)
def append_runtime_warnings(metadata: dict[str, Any], warnings: list[dict[str, str]]) -> None:
"""Merge build-time warnings into the metadata runtime-support manifest."""
if not warnings:
return
manifest = metadata.setdefault("runtime_support", {})
if isinstance(manifest, dict):
existing = manifest.setdefault("unsupported_runtime_warnings", [])
if isinstance(existing, list):
existing.extend(warnings)
def build_drive_layer_config(
agent_soul: AgentSoulConfig,
*,
agent_id: str | None,
) -> tuple[DifyDriveLayerConfig | None, list[dict[str, str]]]:
"""Catalog the soul's drive-backed Skills & Files into the dify.drive declaration.
Returns ``(config, warnings)`` — ``config is None`` means nothing to inject
(no skills/files configured, or no agent identity to address the drive by).
Refs that predate standardization (no drive key) are skipped with a warning
instead of failing the run, so historic souls keep running.
"""
skill_refs = agent_soul.skills_files.skills
file_refs = agent_soul.skills_files.files
if not skill_refs and not file_refs:
return None, []
warnings: list[dict[str, str]] = []
if not agent_id:
warnings.append(
{
"section": "agent_soul.skills_files",
"code": "skill_ref_dangling",
"message": "skills_files is configured but the run has no bound agent to address a drive by.",
}
)
return None, warnings
skills: list[DifyDriveSkillConfig] = []
for skill in skill_refs:
if not skill.skill_md_key:
warnings.append(
{
"section": "agent_soul.skills_files",
"code": "skill_ref_dangling",
"message": (
f"skill_ref_dangling: skill '{skill.name or skill.id or 'unknown'}' has no drive key; "
"re-standardize it to expose it at runtime."
),
}
)
continue
skills.append(
DifyDriveSkillConfig(
name=skill.name or skill.skill_md_key.split("/", 1)[0],
description=skill.description or "",
skill_md_key=skill.skill_md_key,
archive_key=skill.full_archive_key,
)
)
files: list[DifyDriveFileConfig] = []
for file in file_refs:
if not file.drive_key:
# Plain upload references (pre-ENG-625) are not drive-backed; they are
# simply invisible to the manifest rather than a defect worth warning on.
continue
size = file.get("size")
files.append(
DifyDriveFileConfig(
name=file.name or file.drive_key.rsplit("/", 1)[-1],
key=file.drive_key,
size=size if isinstance(size, int) else None,
mime_type=file.type,
)
)
if not skills and not files:
return None, warnings
return DifyDriveLayerConfig(drive_ref=f"agent-{agent_id}", skills=skills, files=files), warnings
def _cli_tool_enabled(item: object) -> bool:
"""A CLI tool is bootstrapped unless explicitly disabled (default is enabled)."""
data = _plain_mapping(item)

View File

@ -99,6 +99,10 @@ class AgentFileRefConfig(AgentFlexibleConfig):
transfer_method: str | None = Field(default=None, max_length=64)
url: str | None = None
remote_url: str | None = None
# Drive key once the file is committed to the agent drive ("files/<name>",
# ENG-625). Files without it are plain upload references and stay invisible
# to the runtime drive manifest.
drive_key: str | None = Field(default=None, max_length=512)
class AgentSkillRefConfig(AgentFlexibleConfig):
@ -107,6 +111,16 @@ class AgentSkillRefConfig(AgentFlexibleConfig):
description: str | None = None
file_id: str | None = Field(default=None, max_length=255)
path: str | None = None
# Standardization outputs (ENG-594) — previously riding along via
# ``extra="allow"``, promoted to the explicit schema because the runtime
# drive manifest (ENG-623) keys off them.
skill_md_key: str | None = Field(default=None, max_length=512)
skill_md_file_id: str | None = Field(default=None, max_length=255)
full_archive_key: str | None = Field(default=None, max_length=512)
full_archive_file_id: str | None = Field(default=None, max_length=255)
# Zip member path listing from standardization (ENG-371): lets infer-tools
# show the model strong signals like ``scripts/*.sh`` without unpacking.
manifest_files: list[str] | None = None
class AgentPermissionConfig(BaseModel):
@ -175,6 +189,10 @@ class AgentCliToolConfig(AgentFlexibleConfig):
risk_accepted: bool = False
approved: bool = False
risk_level: AgentCliToolRiskLevel | None = None
# Slug of the skill an infer-tools suggestion came from (ENG-371); drives
# the "inferred from <skill>" badge. Plain provenance metadata — saving an
# inferred tool still passes every composer validation rule.
inferred_from: str | None = Field(default=None, max_length=255)
class AgentKnowledgeDatasetConfig(AgentFlexibleConfig):

View File

@ -1042,6 +1042,98 @@ Upload one Agent App sandbox file as a Dify ToolFile mapping
| ---- | ----------- | ------ |
| 200 | Uploaded | **application/json**: [SandboxUploadResponse](#sandboxuploadresponse)<br> |
### [GET] /apps/{app_id}/agent/drive/files
List agent drive entries (read-only inspector; one endpoint for both tabs)
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
| prefix | query | Key prefix filter: '<slug>/' for one skill, 'files/' for files | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Drive entries | **application/json**: [AgentDriveListResponse](#agentdrivelistresponse)<br> |
### [GET] /apps/{app_id}/agent/drive/files/download
Time-limited external signed URL for one drive value (no streaming proxy)
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string |
| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Signed URL | **application/json**: [AgentDriveDownloadResponse](#agentdrivedownloadresponse)<br> |
### [GET] /apps/{app_id}/agent/drive/files/preview
Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string |
| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Preview | **application/json**: [AgentDrivePreviewResponse](#agentdrivepreviewresponse)<br> |
### [DELETE] /apps/{app_id}/agent/files
Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5)
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string |
| key | query | Drive key, e.g. files/sample.pdf | Yes | string |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | File removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)<br> |
### [POST] /apps/{app_id}/agent/files
**ADD FILE: commit one uploaded file into the bound agent's drive**
Commit an uploaded file into the agent drive under files/<name> (ENG-625 D3)
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [AgentDriveFilePayload](#agentdrivefilepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | File committed into the agent drive | **application/json**: [AgentDriveFileCommitResponse](#agentdrivefilecommitresponse)<br> |
### [GET] /apps/{app_id}/agent/logs
**Get agent logs**
@ -1072,6 +1164,7 @@ Validate + standardize a Skill into the agent drive (ENG-594)
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
#### Responses
@ -1100,6 +1193,43 @@ plus its manifest. Standardizing into the agent drive is ENG-594.
| 201 | Skill validated | **application/json**: [AgentSkillUploadResponse](#agentskilluploadresponse)<br> |
| 400 | Invalid skill package | |
### [DELETE] /apps/{app_id}/agent/skills/{slug}
Delete a standardized skill: soul ref first, then the <slug>/ drive prefix (ENG-625 D5)
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string |
| slug | path | Skill slug (single path segment) | Yes | string |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)<br> |
### [POST] /apps/{app_id}/agent/skills/{slug}/infer-tools
**Suggest CLI tools/env for a skill**
Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)
Saving still goes through composer validation.
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string |
| slug | path | Skill slug (single path segment) | Yes | string |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Inference result (draft suggestions, nothing persisted) | **application/json**: [SkillToolInferenceResult](#skilltoolinferenceresult)<br> |
### [POST] /apps/{app_id}/annotation-reply/{action}
Enable or disable annotation reply for an app
@ -10852,6 +10982,7 @@ composer/publish validators and skipped by runtime request builders.
| enabled | boolean, <br>**Default:** true | | No |
| env | [AgentCliToolEnvConfig](#agentclitoolenvconfig) | | No |
| id | string | | No |
| inferred_from | string | | No |
| install | string | | No |
| install_command | string | | No |
| install_commands | [ string ] | | No |
@ -10930,6 +11061,7 @@ Risk marker for CLI tool bootstrap commands.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| drive_key | string | | No |
| file_id | string | | No |
| id | string | | No |
| kind | string, <br>**Default:** file | | No |
@ -10972,10 +11104,15 @@ Risk marker for CLI tool bootstrap commands.
| ---- | ---- | ----------- | -------- |
| description | string | | No |
| file_id | string | | No |
| full_archive_file_id | string | | No |
| full_archive_key | string | | No |
| id | string | | No |
| kind | string, <br>**Default:** skill | | No |
| manifest_files | [ string ] | | No |
| name | string | | No |
| path | string | | No |
| skill_md_file_id | string | | No |
| skill_md_key | string | | No |
#### AgentComposerSoulCandidatesResponse
@ -11058,6 +11195,70 @@ Audit operation recorded for Agent Soul version/revision changes.
| version | integer | | Yes |
| version_note | string | | No |
#### AgentDriveDeleteResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| config_version_id | string | | No |
| removed_keys | [ string ] | | No |
| result | string | | Yes |
#### AgentDriveDownloadResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| url | string | | Yes |
#### AgentDriveFileCommitResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| config_version_id | string | | No |
| file | [AgentDriveFileResponse](#agentdrivefileresponse) | | Yes |
#### AgentDriveFilePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| upload_file_id | string | UploadFile UUID from POST /console/api/files/upload | Yes |
#### AgentDriveFileResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| drive_key | string | | Yes |
| file_id | string | | Yes |
| mime_type | string | | No |
| name | string | | Yes |
| size | integer | | No |
#### AgentDriveItemResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_at | integer | | No |
| file_kind | string | | Yes |
| hash | string | | No |
| key | string | | Yes |
| mime_type | string | | No |
| size | integer | | No |
#### AgentDriveListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| items | [ [AgentDriveItemResponse](#agentdriveitemresponse) ] | | No |
#### AgentDrivePreviewResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| binary | boolean | | Yes |
| key | string | | Yes |
| size | integer | | No |
| text | string | | No |
| truncated | boolean | | Yes |
#### AgentEnvVariableConfig
| Name | Type | Description | Required |
@ -11081,6 +11282,7 @@ Audit operation recorded for Agent Soul version/revision changes.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| drive_key | string | | No |
| file_id | string | | No |
| id | string | | No |
| name | string | | No |
@ -11425,21 +11627,28 @@ Visibility and lifecycle scope of an Agent record.
| ---- | ---- | ----------- | -------- |
| description | string | | No |
| file_id | string | | No |
| full_archive_file_id | string | | No |
| full_archive_key | string | | No |
| id | string | | No |
| manifest_files | [ string ] | | No |
| name | string | | No |
| path | string | | No |
| skill_md_file_id | string | | No |
| skill_md_key | string | | No |
#### AgentSkillStandardizeResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentSkillStandardizeResponse | object | | |
| manifest | [SkillManifest](#skillmanifest) | | Yes |
| skill | [AgentSkillRefConfig](#agentskillrefconfig) | | Yes |
#### AgentSkillUploadResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentSkillUploadResponse | object | | |
| manifest | [SkillManifest](#skillmanifest) | | Yes |
| skill | [AgentSkillRefConfig](#agentskillrefconfig) | | Yes |
#### AgentSoulAppFeaturesConfig
@ -12457,6 +12666,17 @@ Button styles for user actions.
| ---- | ---- | ----------- | -------- |
| content | string | | Yes |
#### CliToolSuggestion
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| command | string | | No |
| description | string | | No |
| env_suggestions | [ [EnvSuggestion](#envsuggestion) ] | | No |
| inferred_from | string | | No |
| install_commands | [ string ] | | No |
| name | string | | Yes |
#### CodeBasedExtensionQuery
| Name | Type | Description | Required |
@ -14087,6 +14307,14 @@ Request payload for bulk downloading documents as a zip archive.
| ---- | ---- | ----------- | -------- |
| success | boolean | Operation success | Yes |
#### EnvSuggestion
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| key | string | | Yes |
| reason | string | | No |
| secret_likely | boolean | | No |
#### EnvironmentVariableItemResponse
| Name | Type | Description | Required |
@ -17117,6 +17345,27 @@ Simple provider entity response.
| title | string | | Yes |
| use_icon_as_answer_icon | boolean | | Yes |
#### SkillManifest
Validated metadata extracted from a Skill package.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | Yes |
| entry_path | string | | Yes |
| files | [ string ] | | Yes |
| hash | string | | Yes |
| name | string | | Yes |
| size | integer | | Yes |
#### SkillToolInferenceResult
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| cli_tools | [ [CliToolSuggestion](#clitoolsuggestion) ] | | No |
| inferable | boolean | | Yes |
| reason | string | | No |
#### Snippet
| Name | Type | Description | Required |

View File

@ -12,6 +12,7 @@ from models.agent import (
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
AgentDriveFile,
AgentKind,
AgentScope,
AgentSource,
@ -20,6 +21,7 @@ from models.agent import (
WorkflowAgentNodeBinding,
)
from models.agent_config_entities import (
AgentFileRefConfig,
DeclaredOutputConfig,
)
from models.agent_config_entities import (
@ -28,7 +30,12 @@ from models.agent_config_entities import (
from models.workflow import Workflow
from services.agent.agent_soul_state import agent_soul_has_model
from services.agent.composer_validator import ComposerConfigValidator
from services.agent.errors import AgentNameConflictError, AgentNotFoundError, AgentVersionNotFoundError
from services.agent.errors import (
AgentNameConflictError,
AgentNotFoundError,
AgentVersionNotFoundError,
InvalidComposerConfigError,
)
from services.entities.agent_entities import (
AgentSoulConfig,
ComposerCandidatesResponse,
@ -99,6 +106,21 @@ class AgentComposerService:
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
# ENG-623 §4.4: drive-backed refs must point at real drive rows before the
# soul is persisted. Only strategies that write the soul onto an *existing*
# agent are checked — new-agent strategies create a fresh (empty) drive, so
# any carried drive key would be flagged on the next save instead.
if (
payload.agent_soul is not None
and binding is not None
and binding.agent_id
and payload.save_strategy
in (ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, ComposerSaveStrategy.SAVE_AS_NEW_VERSION)
):
cls._require_drive_refs_resolved(
tenant_id=tenant_id, agent_id=binding.agent_id, agent_soul=payload.agent_soul
)
match payload.save_strategy:
case ComposerSaveStrategy.NODE_JOB_ONLY:
binding = cls._save_node_job_only(
@ -220,6 +242,9 @@ class AgentComposerService:
db.session.rollback()
raise AgentNameConflictError() from exc
# ENG-623 §4.4: dangling drive-backed refs are rejected before persisting.
cls._require_drive_refs_resolved(tenant_id=tenant_id, agent_id=agent.id, agent_soul=payload.agent_soul)
if payload.save_strategy == ComposerSaveStrategy.SAVE_AS_NEW_VERSION or not agent.active_config_snapshot_id:
version = cls._create_config_version(
tenant_id=tenant_id,
@ -252,8 +277,18 @@ class AgentComposerService:
return state
@classmethod
def collect_validation_findings(cls, *, tenant_id: str, payload: ComposerSavePayload) -> dict[str, Any]:
"""ENG-617 soft findings, with DB-backed dataset existence for placeholders."""
def collect_validation_findings(
cls,
*,
tenant_id: str,
payload: ComposerSavePayload,
agent_id: str | None = None,
) -> dict[str, Any]:
"""ENG-617 soft findings, with DB-backed dataset existence for placeholders.
With ``agent_id`` the drive-backed skill/file refs are also checked against
the agent drive (ENG-623 §4.4) and dangling ones surface as warnings.
"""
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
mentioned_ids: set[str] = set()
@ -266,7 +301,242 @@ class AgentComposerService:
existing_dataset_ids: set[str] | None = None
if mentioned_ids:
existing_dataset_ids = set(cls._dataset_rows(tenant_id=tenant_id, dataset_ids=sorted(mentioned_ids)))
return ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids)
findings = ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids)
if agent_id and payload.agent_soul is not None:
findings["warnings"].extend(
cls._drive_ref_findings(tenant_id=tenant_id, agent_id=agent_id, agent_soul=payload.agent_soul)
)
return findings
@classmethod
def remove_drive_refs(
cls,
*,
tenant_id: str,
agent_id: str,
account_id: str,
skill_slug: str | None = None,
file_key: str | None = None,
app_id: str | None = None,
node_id: str | None = None,
) -> str | None:
"""Drop the soul refs backed by a drive skill/file before the drive rows go.
Soul-first ordering (ENG-625 D5): a mid-failure leaves harmless orphan KV
rows that an idempotent DELETE retry cleans, instead of a soul ref that
keeps failing dangling-ref validation. Returns the new config version id,
or ``None`` when the soul held no matching ref (idempotent re-delete).
"""
if (skill_slug is None) == (file_key is None):
raise ValueError("remove_drive_refs requires exactly one of skill_slug or file_key")
agent = db.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id).limit(1))
if agent is None or not agent.active_config_snapshot_id:
return None
current_snapshot = cls._require_version(
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
)
agent_soul = AgentSoulConfig.model_validate(current_snapshot.config_snapshot_dict)
removed_display: str | None = None
if skill_slug is not None:
kept_skills = []
for skill in agent_soul.skills_files.skills:
slug = (skill.skill_md_key or "").split("/", 1)[0] or (skill.path or "").strip("/")
if slug == skill_slug:
removed_display = skill.name or skill.id or skill_slug
continue
kept_skills.append(skill)
if removed_display is None:
return None
agent_soul.skills_files.skills = kept_skills
note = f"Removed skill '{removed_display}' from the drive."
else:
kept_files = []
for file in agent_soul.skills_files.files:
if file.drive_key == file_key:
removed_display = file.name or file.drive_key
continue
kept_files.append(file)
if removed_display is None:
return None
agent_soul.skills_files.files = kept_files
note = f"Removed file '{removed_display}' from the drive."
version = cls._update_current_version(
current_snapshot=current_snapshot,
account_id=account_id,
agent_soul=agent_soul,
operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION,
version_note=note,
)
agent.active_config_snapshot_id = version.id
agent.updated_by = account_id
cls._sync_draft_binding_snapshot(
tenant_id=tenant_id,
app_id=app_id,
node_id=node_id,
agent_id=agent_id,
snapshot_id=version.id,
account_id=account_id,
)
db.session.commit()
return version.id
@classmethod
def add_drive_file_ref(
cls,
*,
tenant_id: str,
agent_id: str,
account_id: str,
file_ref: AgentFileRefConfig,
app_id: str | None = None,
node_id: str | None = None,
) -> str | None:
"""Add or replace one drive-backed file ref in the active Agent Soul.
``POST /agent/files`` is an ADD FILE user action, not just a low-level
drive commit. The committed file must be present in ``skills_files.files``
because runtime ``dify.drive`` is built from the active Agent Soul.
"""
if not file_ref.drive_key:
raise ValueError("file_ref.drive_key is required")
agent = db.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id).limit(1))
if agent is None or not agent.active_config_snapshot_id:
return None
current_snapshot = cls._require_version(
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
)
agent_soul = AgentSoulConfig.model_validate(current_snapshot.config_snapshot_dict)
kept_files = [item for item in agent_soul.skills_files.files if item.drive_key != file_ref.drive_key]
kept_files.append(file_ref)
agent_soul.skills_files.files = kept_files
display = file_ref.name or file_ref.drive_key
version = cls._update_current_version(
current_snapshot=current_snapshot,
account_id=account_id,
agent_soul=agent_soul,
operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION,
version_note=f"Added file '{display}' to the drive.",
)
agent.active_config_snapshot_id = version.id
agent.active_config_has_model = agent_soul_has_model(agent_soul)
agent.updated_by = account_id
cls._sync_draft_binding_snapshot(
tenant_id=tenant_id,
app_id=app_id,
node_id=node_id,
agent_id=agent_id,
snapshot_id=version.id,
account_id=account_id,
)
db.session.commit()
return version.id
@classmethod
def resolve_bound_agent_id(cls, *, tenant_id: str, app_id: str) -> str | None:
"""The Agent App's bound roster agent id, if any (validate-endpoint context)."""
return db.session.scalar(
select(Agent.id)
.where(
Agent.tenant_id == tenant_id,
Agent.app_id == app_id,
Agent.scope == AgentScope.ROSTER,
Agent.status == AgentStatus.ACTIVE,
)
.order_by(Agent.created_at.desc())
.limit(1)
)
@classmethod
def resolve_workflow_node_agent_id(cls, *, tenant_id: str, app_id: str, node_id: str) -> str | None:
"""The draft workflow node binding's agent id, if any (validate-endpoint context)."""
try:
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
except ValueError:
return None
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
return binding.agent_id if binding else None
@classmethod
def _sync_draft_binding_snapshot(
cls,
*,
tenant_id: str,
app_id: str | None,
node_id: str | None,
agent_id: str,
snapshot_id: str,
account_id: str,
) -> None:
"""Keep workflow node bindings on the new active snapshot after direct drive edits."""
if not app_id or not node_id:
return
try:
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
except ValueError:
return
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
if binding is None or binding.agent_id != agent_id:
return
binding.current_snapshot_id = snapshot_id
binding.updated_by = account_id
@classmethod
def _drive_ref_findings(
cls,
*,
tenant_id: str,
agent_id: str,
agent_soul: AgentSoulConfig,
) -> list[dict[str, str | None]]:
"""Drive-backed refs whose keys have no row in the agent drive (ENG-623 §4.4).
Each finding message starts with its stable code token
(``skill_ref_dangling`` / ``file_ref_dangling``) in the ENG-616/617 style.
"""
wanted_keys: dict[str, tuple[str, str]] = {}
for skill in agent_soul.skills_files.skills:
if skill.skill_md_key:
wanted_keys[skill.skill_md_key] = ("skill_ref_dangling", skill.name or skill.id or "unknown")
for file in agent_soul.skills_files.files:
if file.drive_key:
wanted_keys[file.drive_key] = ("file_ref_dangling", file.name or file.id or "unknown")
if not wanted_keys:
return []
existing_keys = set(
db.session.scalars(
select(AgentDriveFile.key).where(
AgentDriveFile.tenant_id == tenant_id,
AgentDriveFile.agent_id == agent_id,
AgentDriveFile.key.in_(sorted(wanted_keys)),
)
)
)
findings: list[dict[str, str | None]] = []
for key, (code, display) in wanted_keys.items():
if key in existing_keys:
continue
kind = "skill" if code == "skill_ref_dangling" else "file"
findings.append(
{
"code": code,
"surface": "agent_soul",
"kind": kind,
"id": key,
"message": f"{code}: {kind} '{display}' has no drive entry for key '{key}'.",
}
)
return findings
@classmethod
def _require_drive_refs_resolved(cls, *, tenant_id: str, agent_id: str, agent_soul: AgentSoulConfig) -> None:
"""Hard save-time guard: dangling drive-backed refs are rejected (400)."""
findings = cls._drive_ref_findings(tenant_id=tenant_id, agent_id=agent_id, agent_soul=agent_soul)
if findings:
raise InvalidComposerConfigError("; ".join(str(finding["message"]) for finding in findings))
@classmethod
def get_workflow_candidates(cls, *, tenant_id: str, app_id: str, node_id: str, user_id: str) -> dict[str, Any]:

View File

@ -70,6 +70,7 @@ class SkillManifest(BaseModel):
"size": self.size,
"hash": self.hash,
"entry_path": self.entry_path,
"manifest_files": self.files,
}
)

View File

@ -112,6 +112,8 @@ class SkillStandardizeService:
"skill_md_key": skill_md_key,
"full_archive_file_id": archive_tool_file.id,
"full_archive_key": archive_key,
# ENG-371: zip member listing — strong signals (scripts/*.sh) for infer-tools.
"manifest_files": manifest.files,
}
)
return {

View File

@ -0,0 +1,215 @@
"""Infer CLI tool + ENV suggestions from a standardized skill (ENG-371).
Reads the skill's SKILL.md from the agent drive, asks the tenant's default
reasoning model once (a plain LLM call, never an agent run), and returns
*draft* suggestions only — nothing is persisted here. The frontend prefills
the TOOLS box (``inferred from <skill>`` badge) and the Pre-Authorize ENV
panel, and saving still goes through the composer's full shell/env/secret/
dangerous-command validation, so inference opens no bypass.
ENV suggestions carry only ``key`` + ``reason`` — the model never produces a
value; users fill those in themselves and the runtime injects ``$VAR`` only.
"""
from __future__ import annotations
import json
import logging
from typing import Any
import json_repair
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import select
from core.errors.error import ProviderTokenNotInitError
from core.model_manager import ModelManager
from extensions.ext_database import db
from graphon.model_runtime.entities.message_entities import SystemPromptMessage, UserPromptMessage
from graphon.model_runtime.entities.model_entities import ModelType
from models.agent import Agent
from models.agent_config_entities import AgentSoulConfig
from services.agent_drive_service import AgentDriveError, AgentDriveService
logger = logging.getLogger(__name__)
class SkillToolInferenceError(Exception):
"""Stable-code error for the infer-tools endpoint."""
def __init__(self, code: str, message: str, *, status_code: int = 400) -> None:
self.code = code
self.message = message
self.status_code = status_code
super().__init__(message)
class EnvSuggestion(BaseModel):
key: str
reason: str = ""
secret_likely: bool = False
class CliToolSuggestion(BaseModel):
name: str
description: str = ""
command: str = ""
install_commands: list[str] = Field(default_factory=list)
env_suggestions: list[EnvSuggestion] = Field(default_factory=list)
inferred_from: str = ""
class SkillToolInferenceResult(BaseModel):
inferable: bool
cli_tools: list[CliToolSuggestion] = Field(default_factory=list)
reason: str | None = None
_SYSTEM_PROMPT = """\
You analyze an agent skill document (SKILL.md) and infer which command-line \
tools the skill depends on at runtime, so a user can pre-install them in the \
agent's sandbox.
Rules:
- Only suggest tools the document explicitly uses or clearly requires; never guess.
- For each tool give: name, a one-line reason-style description referencing the \
document, the base command, and install commands for a Debian-based sandbox \
(apt-get / pip / npm).
- If a step needs an environment variable (an API key, token, endpoint), add it \
to env_suggestions with the variable key and the reason. NEVER produce a value. \
Mark secret_likely=true for credentials.
- If the document describes no external command-line dependency, return \
{"inferable": false, "cli_tools": [], "reason": "<one short sentence why>"}.
Respond with JSON only, matching exactly:
{"inferable": bool,
"cli_tools": [{"name": str, "description": str, "command": str,
"install_commands": [str], "env_suggestions":
[{"key": str, "reason": str, "secret_likely": bool}]}],
"reason": str | null}
"""
class SkillToolInferenceService:
"""Single-shot LLM inference over a drive-stored SKILL.md."""
def __init__(self, *, drive_service: AgentDriveService | None = None) -> None:
self._drive = drive_service or AgentDriveService()
def infer(self, *, tenant_id: str, agent_id: str, slug: str) -> dict[str, Any]:
skill_md = self._load_skill_md(tenant_id=tenant_id, agent_id=agent_id, slug=slug)
manifest_files = self._manifest_files_from_soul(tenant_id=tenant_id, agent_id=agent_id, slug=slug)
user_prompt = f"SKILL.md of skill '{slug}':\n\n{skill_md}"
if manifest_files:
listing = "\n".join(manifest_files[:200])
user_prompt += f"\n\nFiles inside the skill package:\n{listing}"
raw = self._invoke(tenant_id=tenant_id, user_prompt=user_prompt)
try:
result = self._parse(raw)
except (ValidationError, ValueError):
logger.warning("skill tool inference output unparsable, retrying once")
raw = self._invoke(tenant_id=tenant_id, user_prompt=user_prompt)
try:
result = self._parse(raw)
except (ValidationError, ValueError) as exc:
raise SkillToolInferenceError(
"inference_failed",
"inference_failed: the model output could not be parsed into tool suggestions.",
status_code=422,
) from exc
for tool in result.cli_tools:
tool.inferred_from = slug
return result.model_dump(mode="json")
def _load_skill_md(self, *, tenant_id: str, agent_id: str, slug: str) -> str:
try:
preview = self._drive.preview(tenant_id=tenant_id, agent_id=agent_id, key=f"{slug}/SKILL.md")
except AgentDriveError as exc:
if exc.code == "drive_key_not_found":
raise SkillToolInferenceError(
"skill_not_found", f"skill_not_found: no drive entry for skill '{slug}'.", status_code=404
) from exc
raise SkillToolInferenceError(exc.code, exc.message, status_code=exc.status_code) from exc
if preview["binary"] or not preview["text"]:
raise SkillToolInferenceError(
"skill_not_found", f"skill_not_found: SKILL.md of '{slug}' is not readable text.", status_code=404
)
return str(preview["text"])
@staticmethod
def _manifest_files_from_soul(*, tenant_id: str, agent_id: str, slug: str) -> list[str]:
"""The zip path listing standardize persisted onto the ref, if present.
Degrades to an empty list (SKILL.md-only inference) for refs that
predate ``manifest_files``.
"""
agent = db.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id).limit(1))
if agent is None or not agent.active_config_snapshot_id:
return []
from models.agent import AgentConfigSnapshot
snapshot = db.session.scalar(
select(AgentConfigSnapshot).where(
AgentConfigSnapshot.tenant_id == tenant_id,
AgentConfigSnapshot.agent_id == agent_id,
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
)
)
if snapshot is None:
return []
soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
for skill in soul.skills_files.skills:
ref_slug = (skill.skill_md_key or "").split("/", 1)[0] or (skill.path or "").strip("/")
if ref_slug != slug:
continue
files = skill.get("manifest_files")
if isinstance(files, list):
return [str(item) for item in files]
return []
@staticmethod
def _invoke(*, tenant_id: str, user_prompt: str) -> str:
try:
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
model_instance = model_manager.get_default_model_instance(tenant_id=tenant_id, model_type=ModelType.LLM)
except ProviderTokenNotInitError as exc:
raise SkillToolInferenceError(
"default_model_not_configured",
"default_model_not_configured: the workspace has no default reasoning model.",
status_code=400,
) from exc
try:
response = model_instance.invoke_llm(
prompt_messages=[
SystemPromptMessage(content=_SYSTEM_PROMPT),
UserPromptMessage(content=user_prompt),
],
model_parameters={"temperature": 0.1},
stream=False,
)
except Exception as exc:
raise SkillToolInferenceError(
"inference_failed", f"inference_failed: model invocation failed: {exc}", status_code=422
) from exc
return response.message.get_text_content()
@staticmethod
def _parse(raw: str) -> SkillToolInferenceResult:
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
parsed = json_repair.loads(raw)
if not isinstance(parsed, dict):
raise ValueError("model output is not a JSON object")
return SkillToolInferenceResult.model_validate(parsed)
__all__ = [
"CliToolSuggestion",
"EnvSuggestion",
"SkillToolInferenceError",
"SkillToolInferenceResult",
"SkillToolInferenceService",
]

View File

@ -131,6 +131,7 @@ class AgentDriveService:
"mime_type": row.mime_type,
"file_kind": row.file_kind.value,
"file_id": row.file_id,
"created_at": int(row.created_at.timestamp()) if row.created_at else None,
}
if include_download_url:
item["download_url"] = self._resolve_download_url(
@ -169,6 +170,52 @@ class AgentDriveService:
self._delete_storage(storage_key)
return committed
def delete(
self,
*,
tenant_id: str,
agent_id: str,
prefix: str | None = None,
key: str | None = None,
) -> list[str]:
"""Delete drive entries by exact ``key`` or by ``prefix`` (ENG-625 D5).
Drive-owned values get their backing record + storage object cleaned via
the same ``_cleanup_value`` path commit-overwrite uses; shared values only
lose the KV row. Idempotent: deleting nothing returns ``[]``.
"""
if (prefix is None) == (key is None):
raise AgentDriveError("invalid_delete_scope", "delete requires exactly one of prefix or key")
removed_keys: list[str] = []
pending_storage_deletes: list[str] = []
with session_factory.create_session() as session:
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
stmt = select(AgentDriveFile).where(
AgentDriveFile.tenant_id == tenant_id,
AgentDriveFile.agent_id == agent_id,
)
if key is not None:
stmt = stmt.where(AgentDriveFile.key == normalize_drive_key(key))
else:
stmt = stmt.where(AgentDriveFile.key.startswith(normalize_drive_key(prefix or "")))
rows = list(session.scalars(stmt))
for row in rows:
if row.value_owned_by_drive:
self._cleanup_value(
session,
tenant_id=tenant_id,
file_kind=row.file_kind,
file_id=row.file_id,
exclude_row_id=row.id,
pending_storage_deletes=pending_storage_deletes,
)
removed_keys.append(row.key)
session.delete(row)
session.commit()
for storage_key in pending_storage_deletes:
self._delete_storage(storage_key)
return removed_keys
def _commit_one(
self,
session: Session,
@ -338,7 +385,12 @@ class AgentDriveService:
logger.warning("failed to delete drive storage object %s", storage_key, exc_info=True)
@staticmethod
def _resolve_download_url(*, tenant_id: str, file_kind: AgentDriveFileKind, file_id: str) -> str | None:
def _resolve_download_url(
*, tenant_id: str, file_kind: AgentDriveFileKind, file_id: str, for_external: bool = False
) -> str | None:
"""Signed URL for a drive value. ``for_external`` selects the audience:
the inner manifest hands agents *internal* URLs, while the console
inspector must hand browsers *external* ones — never mix the two."""
if file_kind == AgentDriveFileKind.TOOL_FILE:
mapping: dict[str, Any] = {"transfer_method": "tool_file", "tool_file_id": file_id}
else:
@ -349,10 +401,86 @@ class AgentDriveService:
# No FileAccessScope bound -> drive-owned: the builders still filter by
# tenant_id, so resolution is tenant-scoped without user-level checks.
file = file_factory.build_from_mapping(mapping=mapping, tenant_id=tenant_id, access_controller=controller)
return runtime.resolve_file_url(file=file, for_external=False)
return runtime.resolve_file_url(file=file, for_external=for_external)
except ValueError:
return None
# ── console drive inspector (ENG-624) ────────────────────────────────────
# SKILL.md is the primary preview use case; 64 KiB covers it with headroom
# while keeping the console payload bounded.
PREVIEW_MAX_BYTES = 64 * 1024
def _require_row(self, session: Session, *, tenant_id: str, agent_id: str, key: str) -> AgentDriveFile:
row = session.scalar(
select(AgentDriveFile).where(
AgentDriveFile.tenant_id == tenant_id,
AgentDriveFile.agent_id == agent_id,
AgentDriveFile.key == normalize_drive_key(key),
)
)
if row is None:
raise AgentDriveError("drive_key_not_found", "no drive entry for this key", status_code=404)
return row
def _storage_key_for_row(self, session: Session, *, tenant_id: str, row: AgentDriveFile) -> str:
if row.file_kind == AgentDriveFileKind.TOOL_FILE:
tool_file = session.scalar(
select(ToolFile).where(ToolFile.id == row.file_id, ToolFile.tenant_id == tenant_id)
)
if tool_file is None:
raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404)
return tool_file.file_key
upload_file = session.scalar(
select(UploadFile).where(UploadFile.id == row.file_id, UploadFile.tenant_id == tenant_id)
)
if upload_file is None:
raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404)
return upload_file.key
def preview(self, *, tenant_id: str, agent_id: str, key: str) -> dict[str, Any]:
"""Truncated text preview of one drive value (binary-safe, never 500s on size)."""
with session_factory.create_session() as session:
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
storage_key = self._storage_key_for_row(session, tenant_id=tenant_id, row=row)
size = row.size
data = bytearray()
for chunk in storage.load_stream(storage_key):
data.extend(chunk)
if len(data) > self.PREVIEW_MAX_BYTES:
break
truncated = len(data) > self.PREVIEW_MAX_BYTES
sample = bytes(data[: self.PREVIEW_MAX_BYTES])
# Same semantics as the sandbox read endpoint: NUL or undecodable -> binary.
if b"\x00" in sample:
return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None}
try:
text = sample.decode("utf-8")
except UnicodeDecodeError:
if truncated:
# A multi-byte char may sit on the cut point; retry without the tail.
try:
text = sample[:-3].decode("utf-8", errors="strict")
except UnicodeDecodeError:
return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None}
else:
return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None}
return {"key": row.key, "size": size, "truncated": truncated, "binary": False, "text": text}
def download_url(self, *, tenant_id: str, agent_id: str, key: str) -> str:
"""External signed URL for a browser download of one drive value."""
with session_factory.create_session() as session:
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key)
url = self._resolve_download_url(
tenant_id=tenant_id, file_kind=row.file_kind, file_id=row.file_id, for_external=True
)
if url is None:
raise AgentDriveError("drive_key_not_found", "drive value cannot be resolved", status_code=404)
return url
__all__ = [
"AgentDriveError",

View File

@ -283,6 +283,10 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(
lambda **kwargs: _workflow_composer_response(save_options=[kwargs["payload"].save_strategy.value]),
)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
monkeypatch.setattr(
composer_controller.AgentComposerService, "resolve_workflow_node_agent_id", lambda **kwargs: None
)
monkeypatch.setattr(composer_controller.AgentComposerService, "resolve_bound_agent_id", lambda **kwargs: None)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"get_workflow_candidates",
@ -354,6 +358,10 @@ def test_agent_app_composer_get_put_validate_and_candidates(
lambda **kwargs: _agent_app_composer_response(),
)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
monkeypatch.setattr(
composer_controller.AgentComposerService, "resolve_workflow_node_agent_id", lambda **kwargs: None
)
monkeypatch.setattr(composer_controller.AgentComposerService, "resolve_bound_agent_id", lambda **kwargs: None)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"get_agent_app_candidates",

View File

@ -0,0 +1,110 @@
"""Unit tests for the console agent drive inspector (ENG-624).
Handlers are unwrapped past the login/app-model decorators and invoked inside a
bare Flask request context with the drive service mocked — covering agent
resolution, query handling, and error mapping, not auth.
"""
from __future__ import annotations
import inspect
from types import SimpleNamespace
from unittest.mock import patch
from flask import Flask
from controllers.console.app.agent_drive_inspector import (
AgentDriveDownloadApi,
AgentDriveListApi,
AgentDrivePreviewApi,
)
from services.agent_drive_service import AgentDriveError
_MOD = "controllers.console.app.agent_drive_inspector"
app = Flask(__name__)
def _raw(method):
return inspect.unwrap(method)
_APP = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id="agent-1")
def test_list_filters_value_pointers_out_of_console_payload():
raw = _raw(AgentDriveListApi.get)
with app.test_request_context("/?prefix=pdf-toolkit/"):
with patch(f"{_MOD}.AgentDriveService") as drive:
drive.return_value.manifest.return_value = [
{
"key": "pdf-toolkit/SKILL.md",
"size": 5,
"hash": "h",
"mime_type": "text/markdown",
"file_kind": "tool_file",
"file_id": "tf-1",
"created_at": 1718000000,
}
]
body = raw(AgentDriveListApi(), _APP)
assert body["items"][0]["key"] == "pdf-toolkit/SKILL.md"
assert "file_id" not in body["items"][0]
assert drive.return_value.manifest.call_args.kwargs["prefix"] == "pdf-toolkit/"
def test_list_resolves_workflow_node_binding_agent():
raw = _raw(AgentDriveListApi.get)
with app.test_request_context("/?node_id=agent-node-1"):
with (
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentDriveService") as drive,
):
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
drive.return_value.manifest.return_value = []
raw(AgentDriveListApi(), _APP)
assert drive.return_value.manifest.call_args.kwargs["agent_id"] == "wf-agent-9"
assert composer.resolve_workflow_node_agent_id.call_args.kwargs["node_id"] == "agent-node-1"
def test_list_400_when_no_agent_bound():
raw = _raw(AgentDriveListApi.get)
app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None)
with app.test_request_context("/"):
body, status = raw(AgentDriveListApi(), app_without_agent)
assert status == 400
assert body["code"] == "agent_not_bound"
def test_preview_passes_through_and_maps_errors():
raw = _raw(AgentDrivePreviewApi.get)
with app.test_request_context("/?key=pdf-toolkit/SKILL.md"):
with patch(f"{_MOD}.AgentDriveService") as drive:
drive.return_value.preview.return_value = {
"key": "pdf-toolkit/SKILL.md",
"size": 5,
"truncated": False,
"binary": False,
"text": "# hi",
}
body = raw(AgentDrivePreviewApi(), _APP)
assert body["text"] == "# hi"
with app.test_request_context("/?key=ghost/SKILL.md"):
with patch(f"{_MOD}.AgentDriveService") as drive:
drive.return_value.preview.side_effect = AgentDriveError(
"drive_key_not_found", "no drive entry", status_code=404
)
body, status = raw(AgentDrivePreviewApi(), _APP)
assert status == 404
assert body["code"] == "drive_key_not_found"
def test_download_returns_signed_url_json():
raw = _raw(AgentDriveDownloadApi.get)
with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"):
with patch(f"{_MOD}.AgentDriveService") as drive:
drive.return_value.download_url.return_value = "https://signed.example/zip"
body = raw(AgentDriveDownloadApi(), _APP)
assert body == {"url": "https://signed.example/zip"}

View File

@ -32,7 +32,7 @@ def _file_ctx(*, files: dict[str, bytes] | None = None):
_USER = SimpleNamespace(id="user-1")
_APP = SimpleNamespace(tenant_id="tenant-1", bound_agent_id="agent-1")
_APP = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id="agent-1")
def test_upload_validates_and_returns_skill_ref():
@ -89,11 +89,30 @@ def test_standardize_returns_result():
def test_standardize_no_bound_agent_is_400():
raw = _raw(AgentSkillStandardizeApi.post)
app_without_agent = SimpleNamespace(tenant_id="tenant-1", bound_agent_id=None)
app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None)
with _file_ctx(files={"file": b"zip"}):
body, status = raw(AgentSkillStandardizeApi(), _USER, app_without_agent)
assert status == 400
assert body["code"] == "no_bound_agent"
assert body["code"] == "agent_not_bound"
def test_standardize_resolves_workflow_node_agent():
raw = _raw(AgentSkillStandardizeApi.post)
workflow_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None)
with app.test_request_context(
"/?node_id=agent-node-1", method="POST", data={"file": (io.BytesIO(b"zip"), "skill.zip")}
):
with (
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.SkillStandardizeService") as svc,
):
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
svc.return_value.standardize.return_value = {"skill": {"path": "s"}, "manifest": {}}
body, status = raw(AgentSkillStandardizeApi(), _USER, workflow_app)
assert status == 201
assert body["skill"] == {"path": "s"}
assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "wf-agent-1"
def test_standardize_maps_drive_error():
@ -104,3 +123,239 @@ def test_standardize_maps_drive_error():
body, status = raw(AgentSkillStandardizeApi(), _USER, _APP)
assert status == 404
assert body["code"] == "source_not_found"
# ── ENG-625: drive files commit + delete endpoints ────────────────────────────
def _json_ctx(payload: dict | None = None, *, method: str = "POST", query_string: str = ""):
return app.test_request_context(f"/?{query_string}", method=method, json=payload or {})
def test_files_commit_validates_upload_and_returns_drive_ref():
from controllers.console.app.agent import AgentDriveFilesApi
raw = _raw(AgentDriveFilesApi.post)
upload = SimpleNamespace(id="uf-1", name="sample qna.pdf")
with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}):
with (
patch(f"{_MOD}.console_ns") as ns,
patch(f"{_MOD}.db") as db_mock,
patch(f"{_MOD}.AgentDriveService") as drive,
patch(f"{_MOD}.AgentComposerService") as composer,
):
ns.payload = {"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}
db_mock.session.scalar.return_value = upload
drive.return_value.commit.return_value = [
{"key": "files/sample qna.pdf", "size": 5, "mime_type": "application/pdf"}
]
composer.add_drive_file_ref.return_value = "ver-2"
body, status = raw(AgentDriveFilesApi(), _USER, _APP)
assert status == 201
assert body["file"]["drive_key"] == "files/sample qna.pdf"
assert body["file"]["file_id"] == "uf-1"
assert body["config_version_id"] == "ver-2"
item = drive.return_value.commit.call_args.kwargs["items"][0]
assert item.value_owned_by_drive is True
assert item.file_ref.kind == "upload_file"
file_ref = composer.add_drive_file_ref.call_args.kwargs["file_ref"]
assert file_ref.drive_key == "files/sample qna.pdf"
assert file_ref.name == "sample qna.pdf"
assert composer.add_drive_file_ref.call_args.kwargs["app_id"] == "app-1"
def test_files_commit_404_when_upload_not_in_tenant():
from controllers.console.app.agent import AgentDriveFilesApi
raw = _raw(AgentDriveFilesApi.post)
with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}):
with (
patch(f"{_MOD}.console_ns") as ns,
patch(f"{_MOD}.db") as db_mock,
):
ns.payload = {"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}
db_mock.session.scalar.return_value = None
body, status = raw(AgentDriveFilesApi(), _USER, _APP)
assert status == 404
assert body["code"] == "upload_file_not_found"
def test_files_commit_resolves_workflow_node_agent():
from controllers.console.app.agent import AgentDriveFilesApi
raw = _raw(AgentDriveFilesApi.post)
upload = SimpleNamespace(id="uf-1", name="sample.pdf")
workflow_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None)
with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}, query_string="node_id=agent-node-1"):
with (
patch(f"{_MOD}.console_ns") as ns,
patch(f"{_MOD}.db") as db_mock,
patch(f"{_MOD}.AgentDriveService") as drive,
patch(f"{_MOD}.AgentComposerService") as composer,
):
ns.payload = {"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}
db_mock.session.scalar.return_value = upload
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
drive.return_value.commit.return_value = [
{"key": "files/sample.pdf", "size": 5, "mime_type": "application/pdf"}
]
composer.add_drive_file_ref.return_value = "ver-2"
body, status = raw(AgentDriveFilesApi(), _USER, workflow_app)
assert status == 201
assert body["config_version_id"] == "ver-2"
assert drive.return_value.commit.call_args.kwargs["agent_id"] == "wf-agent-1"
assert composer.add_drive_file_ref.call_args.kwargs["node_id"] == "agent-node-1"
def test_files_delete_updates_soul_then_drive():
from controllers.console.app.agent import AgentDriveFilesApi
raw = _raw(AgentDriveFilesApi.delete)
calls: list[str] = []
with _json_ctx(method="DELETE", query_string="key=files/sample.pdf"):
with (
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentDriveService") as drive,
):
composer.remove_drive_refs.side_effect = lambda **kw: calls.append("soul") or "ver-2"
drive.return_value.delete.side_effect = lambda **kw: calls.append("drive") or ["files/sample.pdf"]
body = raw(AgentDriveFilesApi(), _USER, _APP)
assert calls == ["soul", "drive"] # soul-first ordering
assert body == {"result": "success", "removed_keys": ["files/sample.pdf"], "config_version_id": "ver-2"}
assert composer.remove_drive_refs.call_args.kwargs["file_key"] == "files/sample.pdf"
assert composer.remove_drive_refs.call_args.kwargs["app_id"] == "app-1"
def test_files_delete_resolves_workflow_node_agent():
from controllers.console.app.agent import AgentDriveFilesApi
raw = _raw(AgentDriveFilesApi.delete)
workflow_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None)
with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=agent-node-1"):
with (
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentDriveService") as drive,
):
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
composer.remove_drive_refs.return_value = "ver-2"
drive.return_value.delete.return_value = ["files/sample.pdf"]
body = raw(AgentDriveFilesApi(), _USER, workflow_app)
assert body["config_version_id"] == "ver-2"
assert drive.return_value.delete.call_args.kwargs["agent_id"] == "wf-agent-1"
assert composer.remove_drive_refs.call_args.kwargs["node_id"] == "agent-node-1"
def test_files_delete_survives_drive_failure():
from controllers.console.app.agent import AgentDriveFilesApi
raw = _raw(AgentDriveFilesApi.delete)
with _json_ctx(method="DELETE", query_string="key=files/sample.pdf"):
with (
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentDriveService") as drive,
):
composer.remove_drive_refs.return_value = "ver-2"
drive.return_value.delete.side_effect = RuntimeError("storage down")
body = raw(AgentDriveFilesApi(), _USER, _APP)
# soul already updated; drive cleanup is best-effort and retryable
assert body == {"result": "success", "removed_keys": [], "config_version_id": "ver-2"}
def test_skill_delete_uses_slug_prefix_and_is_idempotent():
from controllers.console.app.agent import AgentSkillApi
raw = _raw(AgentSkillApi.delete)
with _json_ctx(method="DELETE"):
with (
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentDriveService") as drive,
):
composer.remove_drive_refs.return_value = None # ref already gone
drive.return_value.delete.return_value = []
body = raw(AgentSkillApi(), _USER, _APP, "tender-analyzer")
assert body == {"result": "success", "removed_keys": [], "config_version_id": None}
assert drive.return_value.delete.call_args.kwargs["prefix"] == "tender-analyzer/"
assert composer.remove_drive_refs.call_args.kwargs["skill_slug"] == "tender-analyzer"
assert composer.remove_drive_refs.call_args.kwargs["app_id"] == "app-1"
def test_skill_delete_rejects_path_like_slug():
from controllers.console.app.agent import AgentSkillApi
raw = _raw(AgentSkillApi.delete)
with _json_ctx(method="DELETE"):
body, status = raw(AgentSkillApi(), _USER, _APP, "a/b")
assert status == 400
assert body["code"] == "drive_key_invalid"
# ── ENG-371: infer-tools endpoint ─────────────────────────────────────────────
def test_infer_tools_returns_draft_suggestions():
from controllers.console.app.agent import AgentSkillInferToolsApi
raw = _raw(AgentSkillInferToolsApi.post)
with _json_ctx():
with patch(f"{_MOD}.SkillToolInferenceService") as svc:
svc.return_value.infer.return_value = {
"inferable": True,
"cli_tools": [{"name": "ffmpeg", "inferred_from": "audio-transcribe"}],
"reason": None,
}
body = raw(AgentSkillInferToolsApi(), _APP, "audio-transcribe")
assert body["inferable"] is True
assert svc.return_value.infer.call_args.kwargs["slug"] == "audio-transcribe"
def test_infer_tools_resolves_workflow_node_agent():
from controllers.console.app.agent import AgentSkillInferToolsApi
raw = _raw(AgentSkillInferToolsApi.post)
workflow_app = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None)
with _json_ctx(query_string="node_id=agent-node-1"):
with (
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.SkillToolInferenceService") as svc,
):
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
svc.return_value.infer.return_value = {"inferable": False, "cli_tools": [], "reason": "none"}
body = raw(AgentSkillInferToolsApi(), workflow_app, "audio-transcribe")
assert body["inferable"] is False
assert svc.return_value.infer.call_args.kwargs["agent_id"] == "wf-agent-1"
def test_infer_tools_maps_inference_errors():
from controllers.console.app.agent import AgentSkillInferToolsApi
from services.agent.skill_tool_inference_service import SkillToolInferenceError
raw = _raw(AgentSkillInferToolsApi.post)
with _json_ctx():
with patch(f"{_MOD}.SkillToolInferenceService") as svc:
svc.return_value.infer.side_effect = SkillToolInferenceError(
"default_model_not_configured", "no model", status_code=400
)
body, status = raw(AgentSkillInferToolsApi(), _APP, "audio-transcribe")
assert status == 400
assert body["code"] == "default_model_not_configured"
def test_infer_tools_rejects_path_like_slug_and_unbound_app():
from controllers.console.app.agent import AgentSkillInferToolsApi
raw = _raw(AgentSkillInferToolsApi.post)
with _json_ctx():
body, status = raw(AgentSkillInferToolsApi(), _APP, "a/b")
assert (status, body["code"]) == (400, "drive_key_invalid")
app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", bound_agent_id=None)
with _json_ctx():
body, status = raw(AgentSkillInferToolsApi(), app_without_agent, "x")
assert (status, body["code"]) == (400, "agent_not_bound")

View File

@ -186,3 +186,52 @@ class TestAgentAppRuntimeRequestBuilder:
"dify_tool_names": [],
"cli_tool_count": 1,
}
# ── ENG-623: drive declaration on the Agent App surface ──────────────────────
def _soul_with_model_and_skill() -> AgentSoulConfig:
from models.agent_config_entities import AgentSkillRefConfig
soul = _soul_with_model()
soul.skills_files.skills = [
AgentSkillRefConfig.model_validate(
{
"id": "abc",
"name": "Tender Analyzer",
"description": "Parses RFPs.",
"skill_md_key": "tender-analyzer/SKILL.md",
}
)
]
return soul
class TestAgentAppDriveLayer:
def test_drive_layer_injected_when_flag_enabled(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(_soul_with_model_and_skill()))
drive = next(layer for layer in result.request.composition.layers if layer.name == "drive")
assert drive.type == "dify.drive"
assert drive.config.drive_ref == "agent-agent-1"
assert [skill.skill_md_key for skill in drive.config.skills] == ["tender-analyzer/SKILL.md"]
# injected right after execution_context, mirroring the workflow surface
names = [layer.name for layer in result.request.composition.layers]
assert names.index("drive") == names.index("execution_context") + 1
def test_no_drive_layer_when_flag_disabled(self):
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(_soul_with_model_and_skill()))
assert all(layer.name != "drive" for layer in result.request.composition.layers)

View File

@ -675,3 +675,124 @@ def test_mentions_expand_in_soul_and_job_prompts_without_token_leak():
# the value still rides the Workflow context block, not the job prompt
assert "Previous result" in dumped["composition"]["layers"][2]["config"]["user"]
assert "" not in json.dumps(dumped["composition"]["layers"][:3])
# ── ENG-623: dify.drive declaration layer ─────────────────────────────────────
def _soul_with_drive_skill() -> AgentSoulConfig:
return AgentSoulConfig(
prompt={"system_prompt": "You are careful."},
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
skills_files={
"skills": [
{
"id": "abc123",
"name": "Tender Analyzer",
"description": "Parses RFPs.",
"skill_md_key": "tender-analyzer/SKILL.md",
"full_archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
},
{"id": "legacy", "name": "Legacy Skill"}, # pre-standardization: no drive key
],
"files": [
{"name": "sample.pdf", "drive_key": "files/sample.pdf", "type": "application/pdf"},
{"name": "plain-upload.pdf", "file_id": "upload-1"}, # not drive-backed
],
},
)
def test_build_drive_layer_config_catalogs_only_drive_backed_refs():
from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config
config, warnings = build_drive_layer_config(_soul_with_drive_skill(), agent_id="agent-1")
assert config is not None
assert config.drive_ref == "agent-agent-1"
assert [skill.skill_md_key for skill in config.skills] == ["tender-analyzer/SKILL.md"]
assert config.skills[0].archive_key == "tender-analyzer/.DIFY-SKILL-FULL.zip"
assert [file.key for file in config.files] == ["files/sample.pdf"]
assert [w["code"] for w in warnings] == ["skill_ref_dangling"]
assert "Legacy Skill" in warnings[0]["message"]
def test_build_drive_layer_config_skips_when_nothing_configured():
from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config
soul = AgentSoulConfig(
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test")
)
assert build_drive_layer_config(soul, agent_id="agent-1") == (None, [])
def test_build_drive_layer_config_requires_agent_identity():
from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config
config, warnings = build_drive_layer_config(_soul_with_drive_skill(), agent_id=None)
assert config is None
assert [w["code"] for w in warnings] == ["skill_ref_dangling"]
def test_workflow_run_request_contains_drive_layer_when_flag_enabled(monkeypatch: pytest.MonkeyPatch):
"""Contract test: locks the dify.drive composition shape against cross-package drift."""
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
context = _context()
context.snapshot.config_snapshot = _soul_with_drive_skill()
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
dumped = result.request.model_dump(mode="json")
layer_names = [layer["name"] for layer in dumped["composition"]["layers"]]
assert "drive" in layer_names
# injected right after execution_context, before history/llm
assert layer_names.index("drive") == layer_names.index("execution_context") + 1
drive = next(layer for layer in dumped["composition"]["layers"] if layer["name"] == "drive")
assert drive["type"] == "dify.drive"
assert drive["config"]["drive_ref"] == "agent-agent-1"
assert drive["config"]["skills"] == [
{
"name": "Tender Analyzer",
"description": "Parses RFPs.",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
}
]
assert drive["config"]["files"] == [
{"name": "sample.pdf", "key": "files/sample.pdf", "size": None, "mime_type": "application/pdf"}
]
# the dangling legacy ref degraded to a warning instead of failing the run
warnings = result.metadata["runtime_support"]["unsupported_runtime_warnings"]
assert any(w["code"] == "skill_ref_dangling" for w in warnings)
# the drive layer is non-sensitive and must survive into persistable specs
from dify_agent.protocol import extract_runtime_layer_specs
specs = extract_runtime_layer_specs(result.request.composition)
assert any(spec.name == "drive" and spec.type == "dify.drive" for spec in specs)
def test_workflow_run_request_has_no_drive_layer_when_flag_disabled():
context = _context()
context.snapshot.config_snapshot = _soul_with_drive_skill()
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
dumped = result.request.model_dump(mode="json")
assert all(layer["name"] != "drive" for layer in dumped["composition"]["layers"])
warnings = result.metadata["runtime_support"]["unsupported_runtime_warnings"]
assert any(w["code"] == "drive_manifest_disabled" for w in warnings)
def test_build_drive_layer_config_all_refs_dangling_yields_no_config():
from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config
soul = AgentSoulConfig(
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
skills_files={"skills": [{"id": "legacy", "name": "Legacy"}], "files": [{"name": "u.pdf", "file_id": "u1"}]},
)
config, warnings = build_drive_layer_config(soul, agent_id="agent-1")
assert config is None
assert [w["code"] for w in warnings] == ["skill_ref_dangling"]

View File

@ -14,7 +14,7 @@ from models.agent import (
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from models.agent_config_entities import WorkflowNodeJobConfig
from models.agent_config_entities import AgentFileRefConfig, WorkflowNodeJobConfig
from models.workflow import Workflow
from services.agent import composer_service, roster_service
from services.agent.agent_soul_state import agent_soul_has_model
@ -1233,3 +1233,334 @@ def test_workspace_dify_tools_returns_provider_and_tool_granularities(monkeypatc
}
assert [entry["id"] for entry in entries[1:]] == ["duckduckgo/ddg_search", "duckduckgo/ddg_news"]
assert {entry["granularity"] for entry in entries[1:]} == {"tool"}
# ── ENG-623 §4.4: drive-backed ref validation ────────────────────────────────
def _drive_soul(**overrides):
from services.entities.agent_entities import AgentSoulConfig
base = {
"skills_files": {
"skills": [
{"id": "sk-1", "name": "Tender Analyzer", "skill_md_key": "tender-analyzer/SKILL.md"},
],
"files": [{"name": "sample.pdf", "drive_key": "files/sample.pdf"}],
},
}
base.update(overrides)
return AgentSoulConfig.model_validate(base)
def _patch_drive_keys(monkeypatch, existing_keys):
import services.agent.composer_service as composer_service_module
captured: dict[str, object] = {}
def fake_scalars(stmt):
captured["stmt"] = stmt
return list(existing_keys)
monkeypatch.setattr(composer_service_module.db, "session", type("S", (), {"scalars": staticmethod(fake_scalars)})())
return captured
def test_drive_ref_findings_reports_missing_keys(monkeypatch):
_patch_drive_keys(monkeypatch, existing_keys=["tender-analyzer/SKILL.md"])
findings = AgentComposerService._drive_ref_findings(
tenant_id="tenant-1", agent_id="agent-1", agent_soul=_drive_soul()
)
assert [(f["code"], f["id"]) for f in findings] == [("file_ref_dangling", "files/sample.pdf")]
assert str(findings[0]["message"]).startswith("file_ref_dangling: ")
def test_drive_ref_findings_clean_when_all_keys_exist(monkeypatch):
_patch_drive_keys(monkeypatch, existing_keys=["tender-analyzer/SKILL.md", "files/sample.pdf"])
assert (
AgentComposerService._drive_ref_findings(tenant_id="tenant-1", agent_id="agent-1", agent_soul=_drive_soul())
== []
)
def test_drive_ref_findings_skips_refs_without_drive_keys(monkeypatch):
# No drive-backed ref at all -> no DB roundtrip, no findings.
soul = _drive_soul(
skills_files={"skills": [{"id": "legacy", "name": "Legacy"}], "files": [{"name": "u.pdf", "file_id": "u-1"}]}
)
findings = AgentComposerService._drive_ref_findings(tenant_id="tenant-1", agent_id="agent-1", agent_soul=soul)
assert findings == []
def test_require_drive_refs_resolved_raises_with_stable_code(monkeypatch):
from services.agent.errors import InvalidComposerConfigError
_patch_drive_keys(monkeypatch, existing_keys=[])
with pytest.raises(InvalidComposerConfigError, match="skill_ref_dangling"):
AgentComposerService._require_drive_refs_resolved(
tenant_id="tenant-1", agent_id="agent-1", agent_soul=_drive_soul()
)
def test_collect_validation_findings_appends_drive_findings_with_agent_context(monkeypatch):
from services.entities.agent_entities import ComposerSavePayload
_patch_drive_keys(monkeypatch, existing_keys=[])
payload = ComposerSavePayload.model_validate(
{
"variant": "agent_app",
"save_strategy": "save_to_current_version",
"agent_soul": _drive_soul().model_dump(mode="json"),
}
)
findings = AgentComposerService.collect_validation_findings(
tenant_id="tenant-1", payload=payload, agent_id="agent-1"
)
codes = {w["code"] for w in findings["warnings"]}
assert {"skill_ref_dangling", "file_ref_dangling"} <= codes
# without agent context the drive check is skipped entirely
findings_no_agent = AgentComposerService.collect_validation_findings(tenant_id="tenant-1", payload=payload)
assert all(w["code"] not in {"skill_ref_dangling", "file_ref_dangling"} for w in findings_no_agent["warnings"])
# ── ENG-625 D5: soul-first ref removal ───────────────────────────────────────
def _patch_remove_drive_refs_env(monkeypatch, *, soul_dict):
"""Wire the classmethod's collaborators so soul editing + versioning is observable."""
from types import SimpleNamespace
import services.agent.composer_service as module
agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="snap-1", updated_by=None)
snapshot = SimpleNamespace(id="snap-1", tenant_id="tenant-1", agent_id="agent-1", config_snapshot_dict=soul_dict)
committed: dict[str, object] = {}
fake_session = SimpleNamespace(scalar=lambda stmt: agent, commit=lambda: committed.setdefault("committed", True))
monkeypatch.setattr(module.db, "session", fake_session)
monkeypatch.setattr(AgentComposerService, "_require_version", classmethod(lambda cls, **kwargs: snapshot))
captured: dict[str, object] = {}
def fake_update(cls, *, current_snapshot, account_id, agent_soul, operation, version_note):
captured["agent_soul"] = agent_soul
captured["version_note"] = version_note
return SimpleNamespace(id="snap-2")
monkeypatch.setattr(AgentComposerService, "_update_current_version", classmethod(fake_update))
return agent, captured, committed
def test_remove_drive_refs_drops_skill_by_slug_and_versions(monkeypatch):
soul_dict = {
"skills_files": {
"skills": [
{"id": "sk-1", "name": "Tender Analyzer", "skill_md_key": "tender-analyzer/SKILL.md"},
{"id": "sk-2", "name": "Other", "skill_md_key": "other-skill/SKILL.md"},
],
"files": [],
}
}
agent, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
version_id = AgentComposerService.remove_drive_refs(
tenant_id="tenant-1", agent_id="agent-1", account_id="acc-1", skill_slug="tender-analyzer"
)
assert version_id == "snap-2"
assert agent.active_config_snapshot_id == "snap-2"
kept = [s.skill_md_key for s in captured["agent_soul"].skills_files.skills]
assert kept == ["other-skill/SKILL.md"]
assert "Tender Analyzer" in str(captured["version_note"])
assert committed.get("committed") is True
def test_remove_drive_refs_is_noop_when_ref_absent(monkeypatch):
soul_dict = {"skills_files": {"skills": [], "files": []}}
agent, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
assert (
AgentComposerService.remove_drive_refs(
tenant_id="tenant-1", agent_id="agent-1", account_id="acc-1", file_key="files/none.pdf"
)
is None
)
assert "agent_soul" not in captured
assert committed == {}
def test_remove_drive_refs_drops_file_by_key(monkeypatch):
soul_dict = {
"skills_files": {
"skills": [],
"files": [
{"name": "keep.pdf", "drive_key": "files/keep.pdf"},
{"name": "drop.pdf", "drive_key": "files/drop.pdf"},
],
}
}
_, captured, _ = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
version_id = AgentComposerService.remove_drive_refs(
tenant_id="tenant-1", agent_id="agent-1", account_id="acc-1", file_key="files/drop.pdf"
)
assert version_id == "snap-2"
assert [f.drive_key for f in captured["agent_soul"].skills_files.files] == ["files/keep.pdf"]
def test_add_drive_file_ref_adds_or_replaces_file_and_versions(monkeypatch):
soul_dict = {
"skills_files": {
"skills": [],
"files": [
{"name": "old.pdf", "drive_key": "files/old.pdf"},
{"name": "stale.pdf", "drive_key": "files/new.pdf"},
],
}
}
agent, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
version_id = AgentComposerService.add_drive_file_ref(
tenant_id="tenant-1",
agent_id="agent-1",
account_id="acc-1",
file_ref=AgentFileRefConfig(name="new.pdf", file_id="uf-1", drive_key="files/new.pdf", type="application/pdf"),
)
assert version_id == "snap-2"
assert agent.active_config_snapshot_id == "snap-2"
assert [f.drive_key for f in captured["agent_soul"].skills_files.files] == ["files/old.pdf", "files/new.pdf"]
assert captured["agent_soul"].skills_files.files[-1].name == "new.pdf"
assert "new.pdf" in str(captured["version_note"])
assert committed.get("committed") is True
def test_add_drive_file_ref_syncs_workflow_binding_snapshot(monkeypatch):
binding = SimpleNamespace(agent_id="agent-1", current_snapshot_id="snap-1", updated_by=None)
_patch_remove_drive_refs_env(monkeypatch, soul_dict={"skills_files": {"skills": [], "files": []}})
monkeypatch.setattr(
AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1"))
)
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding))
AgentComposerService.add_drive_file_ref(
tenant_id="tenant-1",
agent_id="agent-1",
account_id="acc-1",
file_ref=AgentFileRefConfig(name="new.pdf", file_id="uf-1", drive_key="files/new.pdf"),
app_id="app-1",
node_id="agent-node-1",
)
assert binding.current_snapshot_id == "snap-2"
assert binding.updated_by == "acc-1"
def test_remove_drive_refs_requires_exactly_one_scope():
with pytest.raises(ValueError):
AgentComposerService.remove_drive_refs(tenant_id="t", agent_id="a", account_id="u")
# ── ENG-623/625: resolver helpers + save-path drive guard ────────────────────
def test_resolve_bound_agent_id_queries_active_roster_agent(monkeypatch):
from types import SimpleNamespace
import services.agent.composer_service as module
monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: "agent-9"))
assert AgentComposerService.resolve_bound_agent_id(tenant_id="t-1", app_id="app-1") == "agent-9"
def test_resolve_workflow_node_agent_id_degrades_without_workflow_or_binding(monkeypatch):
from types import SimpleNamespace
def boom(cls, **kwargs):
raise ValueError("no draft workflow")
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", classmethod(boom))
assert AgentComposerService.resolve_workflow_node_agent_id(tenant_id="t", app_id="a", node_id="n") is None
monkeypatch.setattr(
AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1"))
)
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: None))
assert AgentComposerService.resolve_workflow_node_agent_id(tenant_id="t", app_id="a", node_id="n") is None
monkeypatch.setattr(
AgentComposerService,
"_get_workflow_binding",
classmethod(lambda cls, **kwargs: SimpleNamespace(agent_id="agent-7")),
)
assert AgentComposerService.resolve_workflow_node_agent_id(tenant_id="t", app_id="a", node_id="n") == "agent-7"
def test_remove_drive_refs_returns_none_without_agent_or_snapshot(monkeypatch):
from types import SimpleNamespace
import services.agent.composer_service as module
monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: None))
assert AgentComposerService.remove_drive_refs(tenant_id="t", agent_id="a", account_id="u", skill_slug="s") is None
agent_without_snapshot = SimpleNamespace(id="a", active_config_snapshot_id=None)
monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: agent_without_snapshot))
assert AgentComposerService.remove_drive_refs(tenant_id="t", agent_id="a", account_id="u", skill_slug="s") is None
def test_save_workflow_composer_guards_drive_refs_for_existing_agent_strategies(monkeypatch):
from types import SimpleNamespace
from services.entities.agent_entities import ComposerSavePayload
payload = ComposerSavePayload.model_validate(
{
"variant": "workflow",
"save_strategy": "save_to_current_version",
"agent_soul": _drive_soul().model_dump(mode="json"),
"soul_lock": {"locked": False},
}
)
monkeypatch.setattr(
AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1"))
)
monkeypatch.setattr(
AgentComposerService,
"_get_workflow_binding",
classmethod(lambda cls, **kwargs: SimpleNamespace(agent_id="agent-1")),
)
guarded: dict[str, str] = {}
def fake_guard(cls, *, tenant_id, agent_id, agent_soul):
guarded["agent_id"] = agent_id
raise InvalidComposerConfigError("skill_ref_dangling: boom")
from services.agent.errors import InvalidComposerConfigError
monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fake_guard))
with pytest.raises(InvalidComposerConfigError, match="skill_ref_dangling"):
AgentComposerService.save_workflow_composer(
tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload
)
assert guarded["agent_id"] == "agent-1"
def test_remove_drive_refs_noop_when_skill_slug_unmatched(monkeypatch):
soul_dict = {"skills_files": {"skills": [{"name": "Other", "skill_md_key": "other/SKILL.md"}], "files": []}}
_, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
assert (
AgentComposerService.remove_drive_refs(
tenant_id="t-1", agent_id="agent-1", account_id="acc-1", skill_slug="ghost"
)
is None
)
assert committed == {}

View File

@ -75,3 +75,6 @@ def test_standardize_creates_two_drive_owned_toolfiles_and_commits():
assert skill["full_archive_file_id"] == "zip-tool-file"
assert skill["skill_md_file_id"] == "md-tool-file"
assert skill["skill_md_key"] == "pdf-toolkit/SKILL.md"
# ENG-371: zip member listing persisted for infer-tools signals
assert "SKILL.md" in skill["manifest_files"]
assert "scripts/run.py" in skill["manifest_files"]

View File

@ -0,0 +1,225 @@
"""Unit tests for skill → CLI tool inference (ENG-371)."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from services.agent.skill_tool_inference_service import (
SkillToolInferenceError,
SkillToolInferenceService,
)
from services.agent_drive_service import AgentDriveError
_MOD = "services.agent.skill_tool_inference_service"
_SKILL_MD_PREVIEW = {
"key": "audio-transcribe/SKILL.md",
"size": 100,
"truncated": False,
"binary": False,
"text": "# Audio Transcribe\nStep 2 runs ffmpeg, step 3 calls the whisper API.",
}
def _service(preview=_SKILL_MD_PREVIEW):
drive = MagicMock()
drive.preview.return_value = preview
return SkillToolInferenceService(drive_service=drive), drive
def _patch_soul_files(monkeypatch, files):
monkeypatch.setattr(SkillToolInferenceService, "_manifest_files_from_soul", staticmethod(lambda **kwargs: files))
def test_infer_returns_suggestions_with_inferred_from(monkeypatch):
service, drive = _service()
_patch_soul_files(monkeypatch, ["SKILL.md", "scripts/transcribe.sh"])
raw = (
'{"inferable": true, "reason": null, "cli_tools": [{"name": "ffmpeg",'
' "description": "transcoding for step 2", "command": "ffmpeg",'
' "install_commands": ["apt-get install -y ffmpeg"],'
' "env_suggestions": [{"key": "OPENAI_API_KEY", "reason": "whisper call", "secret_likely": true}]}]}'
)
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)):
result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe")
assert result["inferable"] is True
tool = result["cli_tools"][0]
assert tool["name"] == "ffmpeg"
assert tool["inferred_from"] == "audio-transcribe"
assert tool["env_suggestions"] == [{"key": "OPENAI_API_KEY", "reason": "whisper call", "secret_likely": True}]
drive.preview.assert_called_once_with(tenant_id="t-1", agent_id="a-1", key="audio-transcribe/SKILL.md")
def test_infer_threads_manifest_files_into_the_prompt(monkeypatch):
service, _ = _service()
_patch_soul_files(monkeypatch, ["scripts/run.sh"])
captured: dict[str, str] = {}
def fake_invoke(*, tenant_id, user_prompt):
captured["prompt"] = user_prompt
return '{"inferable": false, "cli_tools": [], "reason": "none"}'
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(fake_invoke)):
service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe")
assert "scripts/run.sh" in captured["prompt"]
assert "ffmpeg" in captured["prompt"] # SKILL.md body present
def test_infer_not_inferable_passes_reason_through(monkeypatch):
service, _ = _service()
_patch_soul_files(monkeypatch, [])
raw = '{"inferable": false, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"}'
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)):
result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe")
assert result == {"inferable": False, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"}
def test_infer_retries_once_then_422(monkeypatch):
service, _ = _service()
_patch_soul_files(monkeypatch, [])
calls: list[int] = []
def bad_invoke(**kwargs):
calls.append(1)
return "not json at all ]["
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(bad_invoke)):
with pytest.raises(SkillToolInferenceError) as exc_info:
service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe")
assert len(calls) == 2 # one retry
assert exc_info.value.code == "inference_failed"
assert exc_info.value.status_code == 422
def test_infer_repairs_slightly_malformed_json(monkeypatch):
service, _ = _service()
_patch_soul_files(monkeypatch, [])
raw = 'Here you go: {"inferable": true, "cli_tools": [], "reason": null,}'
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)):
result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe")
assert result["inferable"] is True
def test_missing_skill_maps_to_404():
drive = MagicMock()
drive.preview.side_effect = AgentDriveError("drive_key_not_found", "nope", status_code=404)
service = SkillToolInferenceService(drive_service=drive)
with pytest.raises(SkillToolInferenceError) as exc_info:
service.infer(tenant_id="t-1", agent_id="a-1", slug="ghost")
assert exc_info.value.code == "skill_not_found"
assert exc_info.value.status_code == 404
def test_binary_skill_md_maps_to_404():
service, _ = _service(preview={"key": "x/SKILL.md", "size": 1, "truncated": False, "binary": True, "text": None})
with pytest.raises(SkillToolInferenceError) as exc_info:
service.infer(tenant_id="t-1", agent_id="a-1", slug="x")
assert exc_info.value.code == "skill_not_found"
# ── real-path coverage: _invoke / _manifest_files_from_soul / passthrough ────
def test_invoke_maps_missing_default_model_to_400(monkeypatch):
import services.agent.skill_tool_inference_service as module
from core.errors.error import ProviderTokenNotInitError
fake_manager = MagicMock()
fake_manager.get_default_model_instance.side_effect = ProviderTokenNotInitError("no default")
monkeypatch.setattr(module.ModelManager, "for_tenant", classmethod(lambda cls, tenant_id: fake_manager))
with pytest.raises(SkillToolInferenceError) as exc_info:
SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x")
assert exc_info.value.code == "default_model_not_configured"
assert exc_info.value.status_code == 400
def test_invoke_maps_model_failure_to_422_and_success_returns_text(monkeypatch):
import services.agent.skill_tool_inference_service as module
fake_manager = MagicMock()
fake_instance = MagicMock()
fake_manager.get_default_model_instance.return_value = fake_instance
monkeypatch.setattr(module.ModelManager, "for_tenant", classmethod(lambda cls, tenant_id: fake_manager))
fake_instance.invoke_llm.side_effect = RuntimeError("provider down")
with pytest.raises(SkillToolInferenceError) as exc_info:
SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x")
assert exc_info.value.code == "inference_failed"
assert exc_info.value.status_code == 422
fake_instance.invoke_llm.side_effect = None
fake_instance.invoke_llm.return_value.message.get_text_content.return_value = '{"inferable": false}'
raw = SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x")
assert raw == '{"inferable": false}'
call = fake_instance.invoke_llm.call_args.kwargs
assert call["model_parameters"] == {"temperature": 0.1}
assert call["stream"] is False
def test_load_skill_md_passes_through_non_missing_drive_errors():
drive = MagicMock()
drive.preview.side_effect = AgentDriveError("agent_not_found", "tenant mismatch", status_code=404)
service = SkillToolInferenceService(drive_service=drive)
with pytest.raises(SkillToolInferenceError) as exc_info:
service.infer(tenant_id="t-1", agent_id="a-1", slug="x")
assert exc_info.value.code == "agent_not_found"
def _patch_inference_db(monkeypatch, *, agent, snapshot):
from types import SimpleNamespace
import services.agent.skill_tool_inference_service as module
results = iter([agent, snapshot])
monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: next(results)))
def test_manifest_files_from_soul_reads_active_snapshot(monkeypatch):
from types import SimpleNamespace
soul_dict = {
"skills_files": {
"skills": [
{"name": "Other", "skill_md_key": "other/SKILL.md", "manifest_files": ["x.md"]},
{"name": "Audio", "skill_md_key": "audio-transcribe/SKILL.md", "manifest_files": ["scripts/a.sh"]},
]
}
}
agent = SimpleNamespace(active_config_snapshot_id="snap-1")
snapshot = SimpleNamespace(config_snapshot_dict=soul_dict)
_patch_inference_db(monkeypatch, agent=agent, snapshot=snapshot)
files = SkillToolInferenceService._manifest_files_from_soul(
tenant_id="t-1", agent_id="a-1", slug="audio-transcribe"
)
assert files == ["scripts/a.sh"]
def test_manifest_files_from_soul_degrades_when_agent_or_snapshot_missing(monkeypatch):
_patch_inference_db(monkeypatch, agent=None, snapshot=None)
assert SkillToolInferenceService._manifest_files_from_soul(tenant_id="t", agent_id="a", slug="s") == []
from types import SimpleNamespace
_patch_inference_db(monkeypatch, agent=SimpleNamespace(active_config_snapshot_id="snap-1"), snapshot=None)
assert SkillToolInferenceService._manifest_files_from_soul(tenant_id="t", agent_id="a", slug="s") == []
def test_manifest_files_from_soul_empty_when_slug_not_in_soul(monkeypatch):
from types import SimpleNamespace
soul_dict = {"skills_files": {"skills": [{"name": "Other", "skill_md_key": "other/SKILL.md"}]}}
_patch_inference_db(
monkeypatch,
agent=SimpleNamespace(active_config_snapshot_id="snap-1"),
snapshot=SimpleNamespace(config_snapshot_dict=soul_dict),
)
assert SkillToolInferenceService._manifest_files_from_soul(tenant_id="t", agent_id="a", slug="ghost") == []

View File

@ -337,3 +337,166 @@ def test_manifest_download_url_none_when_unresolvable():
):
items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, include_download_url=True)
assert items[0]["download_url"] is None
# ── ENG-625 D5: delete ────────────────────────────────────────────────────────
def test_delete_by_key_cleans_drive_owned_value():
tf = _seed_tool_file(name="doomed.txt")
_commit("files/doomed.txt", tf, owned=True)
with patch("services.agent_drive_service.storage") as storage_mock:
removed = AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, key="files/doomed.txt")
storage_mock.delete.assert_called_once()
assert removed == ["files/doomed.txt"]
with session_factory.create_session() as session:
assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is None
assert list(session.scalars(select(AgentDriveFile))) == []
def test_delete_by_prefix_removes_all_skill_keys():
md = _seed_tool_file(name="SKILL.md")
zf = _seed_tool_file(name="full.zip")
_commit("tender-analyzer/SKILL.md", md, owned=True)
_commit("tender-analyzer/.DIFY-SKILL-FULL.zip", zf, owned=True)
other = _seed_tool_file(name="other.txt")
_commit("files/other.txt", other, owned=True)
with patch("services.agent_drive_service.storage"):
removed = AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, prefix="tender-analyzer/")
assert sorted(removed) == ["tender-analyzer/.DIFY-SKILL-FULL.zip", "tender-analyzer/SKILL.md"]
with session_factory.create_session() as session:
# both skill ToolFiles physically removed, the unrelated file untouched
assert session.scalar(select(ToolFile).where(ToolFile.id == md)) is None
assert session.scalar(select(ToolFile).where(ToolFile.id == zf)) is None
assert session.scalar(select(ToolFile).where(ToolFile.id == other)) is not None
keys = [row.key for row in session.scalars(select(AgentDriveFile))]
assert keys == ["files/other.txt"]
def test_delete_is_idempotent():
assert AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, key="files/never-there.txt") == []
assert AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, prefix="ghost-skill/") == []
def test_delete_requires_exactly_one_scope():
with pytest.raises(AgentDriveError) as exc_info:
AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT)
assert exc_info.value.code == "invalid_delete_scope"
with pytest.raises(AgentDriveError):
AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, prefix="a/", key="a/b")
def test_delete_keeps_shared_value_records():
tf = _seed_tool_file(name="shared.txt")
_commit("files/shared.txt", tf, owned=False)
with patch("services.agent_drive_service.storage") as storage_mock:
removed = AgentDriveService().delete(tenant_id=TENANT, agent_id=AGENT, key="files/shared.txt")
storage_mock.delete.assert_not_called()
assert removed == ["files/shared.txt"]
with session_factory.create_session() as session:
# only the KV row dropped; the shared ToolFile survives
assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is not None
def test_restandardize_same_slug_overwrites_both_keys_and_cleans_old_toolfiles():
"""ENG-625 §5.3 replacement semantics: re-standardizing a same-name skill
overwrites <slug>/SKILL.md and <slug>/.DIFY-SKILL-FULL.zip, physically
cleaning both old drive-owned ToolFiles."""
old_md = _seed_tool_file(name="SKILL.md")
old_zip = _seed_tool_file(name="full-v1.zip")
_commit("pdf-toolkit/SKILL.md", old_md, owned=True)
_commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", old_zip, owned=True)
new_md = _seed_tool_file(name="SKILL-v2.md")
new_zip = _seed_tool_file(name="full-v2.zip")
with patch("services.agent_drive_service.storage") as storage_mock:
_commit("pdf-toolkit/SKILL.md", new_md, owned=True)
_commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", new_zip, owned=True)
assert storage_mock.delete.call_count == 2
with session_factory.create_session() as session:
assert session.scalar(select(ToolFile).where(ToolFile.id == old_md)) is None
assert session.scalar(select(ToolFile).where(ToolFile.id == old_zip)) is None
rows = {row.key: row.file_id for row in session.scalars(select(AgentDriveFile))}
assert rows == {
"pdf-toolkit/SKILL.md": new_md,
"pdf-toolkit/.DIFY-SKILL-FULL.zip": new_zip,
}
# ── ENG-624: console drive inspector (service layer) ─────────────────────────
def test_preview_returns_text_with_truncation_flags():
tf = _seed_tool_file(name="SKILL.md")
_commit("pdf-toolkit/SKILL.md", tf)
with patch("services.agent_drive_service.storage") as storage_mock:
storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\nUse responsibly.\n"])
result = AgentDriveService().preview(tenant_id=TENANT, agent_id=AGENT, key="pdf-toolkit/SKILL.md")
assert result == {
"key": "pdf-toolkit/SKILL.md",
"size": 5,
"truncated": False,
"binary": False,
"text": "# PDF Toolkit\nUse responsibly.\n",
}
def test_preview_marks_binary_and_oversized_content():
tf = _seed_tool_file(name="blob.bin")
_commit("files/blob.bin", tf)
with patch("services.agent_drive_service.storage") as storage_mock:
storage_mock.load_stream.return_value = iter([b"\x00\x01\x02"])
binary = AgentDriveService().preview(tenant_id=TENANT, agent_id=AGENT, key="files/blob.bin")
assert binary["binary"] is True
assert binary["text"] is None
with patch("services.agent_drive_service.storage") as storage_mock:
storage_mock.load_stream.return_value = iter([b"x" * (AgentDriveService.PREVIEW_MAX_BYTES + 10)])
oversized = AgentDriveService().preview(tenant_id=TENANT, agent_id=AGENT, key="files/blob.bin")
assert oversized["truncated"] is True
assert oversized["binary"] is False
assert len(oversized["text"]) == AgentDriveService.PREVIEW_MAX_BYTES
def test_preview_unknown_key_is_404():
with pytest.raises(AgentDriveError) as exc_info:
AgentDriveService().preview(tenant_id=TENANT, agent_id=AGENT, key="ghost/SKILL.md")
assert exc_info.value.code == "drive_key_not_found"
assert exc_info.value.status_code == 404
def test_preview_rejects_cross_tenant_agent():
with pytest.raises(AgentDriveError) as exc_info:
AgentDriveService().preview(
tenant_id="99999999-9999-9999-9999-999999999999", agent_id=AGENT, key="pdf-toolkit/SKILL.md"
)
assert exc_info.value.code == "agent_not_found"
def test_download_url_signs_external_audience():
tf = _seed_tool_file(name="full.zip")
_commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", tf)
with patch.object(AgentDriveService, "_resolve_download_url", return_value="https://signed.example/x") as resolver:
url = AgentDriveService().download_url(tenant_id=TENANT, agent_id=AGENT, key="pdf-toolkit/.DIFY-SKILL-FULL.zip")
assert url == "https://signed.example/x"
# console downloads are for browsers: external signing, never the internal URL
assert resolver.call_args.kwargs["for_external"] is True
def test_manifest_items_carry_created_at_for_inspector():
tf = _seed_tool_file()
_commit("files/x.txt", tf)
items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT)
assert items[0]["created_at"] is None or isinstance(items[0]["created_at"], int)

View File

@ -0,0 +1,19 @@
"""Client-safe exports for the Dify drive declaration layer DTOs.
The layer implementation lives in the sibling ``layer`` module. Keep this
package root import-safe for client code that only builds run requests.
"""
from dify_agent.layers.drive.configs import (
DIFY_DRIVE_LAYER_TYPE_ID,
DifyDriveFileConfig,
DifyDriveLayerConfig,
DifyDriveSkillConfig,
)
__all__ = [
"DIFY_DRIVE_LAYER_TYPE_ID",
"DifyDriveFileConfig",
"DifyDriveLayerConfig",
"DifyDriveSkillConfig",
]

View File

@ -0,0 +1,67 @@
"""Client-safe DTOs for the Dify drive declaration layer.
The drive layer is a config-only manifest of the Skills & Files an agent has
in its drive. It is an index, never the content: each entry carries only a
display name, a model-facing description, and the drive key needed to fetch
the real bytes through the back proxy (``GET /inner/api/drive/<drive_ref>/
manifest`` → internal download URL). Inlining SKILL.md bodies here would break
the PRD's dynamic-loading principle and bloat every run request.
The API backend catalogs and writes this config; the Agent backend consumes it
(ENG-387: pull via back proxy, lazy-load SKILL.md, materialize files).
"""
from typing import Final
from pydantic import BaseModel, ConfigDict, Field
from agenton.layers import LayerConfig
DIFY_DRIVE_LAYER_TYPE_ID: Final[str] = "dify.drive"
class DifyDriveSkillConfig(BaseModel):
"""Runtime declaration of one standardized skill — an index, not content."""
model_config = ConfigDict(extra="forbid")
name: str
# The model judges from this description whether the skill is worth loading.
description: str
# "<slug>/SKILL.md" — the canonical entry document in the drive.
skill_md_key: str
# "<slug>/.DIFY-SKILL-FULL.zip" — full archive for restoring the complete skill.
archive_key: str | None = None
class DifyDriveFileConfig(BaseModel):
"""Runtime declaration of one plain drive file."""
model_config = ConfigDict(extra="forbid")
name: str
# "files/<filename>" — the drive key of the file value.
key: str
size: int | None = None
mime_type: str | None = None
class DifyDriveLayerConfig(LayerConfig):
"""Config-only declaration layer: API writes the catalog, the agent pulls
the listed entries through the back proxy using ``drive_ref``."""
# "agent-<agent_id>" — storage addressing, deliberately explicit instead of
# derived from execution context so a shared (non-agent-bound) drive stays
# possible later.
drive_ref: str
skills: list[DifyDriveSkillConfig] = Field(default_factory=list)
files: list[DifyDriveFileConfig] = Field(default_factory=list)
__all__ = [
"DIFY_DRIVE_LAYER_TYPE_ID",
"DifyDriveFileConfig",
"DifyDriveLayerConfig",
"DifyDriveSkillConfig",
]

View File

@ -0,0 +1,34 @@
"""Inert Dify drive declaration layer.
Registering this layer makes ``dify.drive`` a known composition type id so a
run that carries the declaration never fails as "unknown layer type", even
before the consumption work (ENG-387) lands. It deliberately contributes no
prompt and no tools: a model that can see skill names but cannot read SKILL.md
would only hallucinate. The skills prompt (including the "pull SKILL.md via
drive" guidance) ships together with the consumption implementation.
"""
from dataclasses import dataclass
from typing import ClassVar
from typing_extensions import Self, override
from agenton.layers import EmptyRuntimeState, NoLayerDeps, PlainLayer
from dify_agent.layers.drive.configs import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig
@dataclass(slots=True)
class DifyDriveLayer(PlainLayer[NoLayerDeps, DifyDriveLayerConfig, EmptyRuntimeState]):
"""Config-only carrier of the drive Skills & Files manifest."""
type_id: ClassVar[str] = DIFY_DRIVE_LAYER_TYPE_ID
config: DifyDriveLayerConfig
@classmethod
@override
def from_config(cls, config: DifyDriveLayerConfig) -> Self:
return cls(config=config)
__all__ = ["DifyDriveLayer"]

View File

@ -6,6 +6,7 @@ state-free Dify structured output layer, the optional Dify ask-human layer, the
Dify execution-context layer, the stateful Dify shell layer, and the Dify
plugin business-layer family:
- ``dify.drive`` for the inert Skills & Files drive declaration,
- ``dify.execution_context`` for shared tenant/user/run daemon context,
- ``dify.shell`` for shellctl-backed shell job control,
- ``dify.plugin.llm`` for plugin-backed model selection, and
@ -37,6 +38,7 @@ from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
from dify_agent.layers.ask_human.layer import DifyAskHumanLayer
from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer
from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer
from dify_agent.layers.drive.layer import DifyDriveLayer
from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
from dify_agent.layers.output.output_layer import DifyOutputLayer
@ -83,6 +85,10 @@ def create_default_layer_providers(
LayerProvider.from_layer_type(PydanticAIHistoryLayer),
LayerProvider.from_layer_type(DifyOutputLayer),
LayerProvider.from_layer_type(DifyAskHumanLayer),
# Inert declaration layer: makes ``dify.drive`` a known type id so runs
# carrying the Skills & Files manifest never fail before the consumption
# work (ENG-387) lands. Deliberately contributes no prompt and no tools.
LayerProvider.from_layer_type(DifyDriveLayer),
LayerProvider.from_factory(
layer_type=DifyExecutionContextLayer,
create=lambda config: DifyExecutionContextLayer.from_config_with_settings(

View File

@ -0,0 +1,58 @@
"""Contract tests for the dify.drive declaration layer (ENG-623)."""
import pytest
from pydantic import ValidationError
from dify_agent.layers.drive import (
DIFY_DRIVE_LAYER_TYPE_ID,
DifyDriveFileConfig,
DifyDriveLayerConfig,
DifyDriveSkillConfig,
)
from dify_agent.layers.drive.layer import DifyDriveLayer
from dify_agent.runtime.compositor_factory import create_default_layer_providers
def test_type_id_is_frozen_contract() -> None:
assert DIFY_DRIVE_LAYER_TYPE_ID == "dify.drive"
assert DifyDriveLayer.type_id == DIFY_DRIVE_LAYER_TYPE_ID
def test_layer_config_round_trips_manifest_entries() -> None:
config = DifyDriveLayerConfig.model_validate(
{
"drive_ref": "agent-019e9112",
"skills": [
{
"name": "Tender Analyzer",
"description": "Parses RFP documents step by step.",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
}
],
"files": [{"name": "sample.pdf", "key": "files/sample.pdf", "size": 1024, "mime_type": "application/pdf"}],
}
)
dumped = config.model_dump(mode="json")
assert dumped["drive_ref"] == "agent-019e9112"
assert dumped["skills"][0]["skill_md_key"] == "tender-analyzer/SKILL.md"
assert dumped["files"][0]["key"] == "files/sample.pdf"
# the declaration is an index only — there is no field that could carry file content
assert "content" not in DifyDriveSkillConfig.model_fields
assert "content" not in DifyDriveFileConfig.model_fields
def test_layer_config_rejects_unknown_fields() -> None:
with pytest.raises(ValidationError):
DifyDriveLayerConfig.model_validate({"drive_ref": "agent-1", "skill_md_body": "# inline content"})
def test_inert_layer_is_registered_and_constructible_from_config() -> None:
providers = create_default_layer_providers()
provider = next(p for p in providers if p.type_id == DIFY_DRIVE_LAYER_TYPE_ID)
layer = provider.create_layer({"drive_ref": "agent-1", "skills": [], "files": []})
assert isinstance(layer, DifyDriveLayer)
assert layer.config.drive_ref == "agent-1"

View File

@ -72,6 +72,7 @@ def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Pat
agent_stub_protocol_module = importlib.import_module("dify_agent.agent_stub.protocol")
agent_stub_cli_main_module = importlib.import_module("dify_agent.agent_stub.cli.main")
shell_module = importlib.import_module("dify_agent.layers.shell")
drive_module = importlib.import_module("dify_agent.layers.drive")
execution_context_module = importlib.import_module("dify_agent.layers.execution_context")
plugin_module = importlib.import_module("dify_agent.layers.dify_plugin")
ask_human_module = importlib.import_module("dify_agent.layers.ask_human")
@ -91,6 +92,7 @@ def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Pat
assert agent_stub_protocol_module.AgentStubConnectRequest is not None
assert agent_stub_cli_main_module.main is not None
assert shell_module.DifyShellLayerConfig is not None
assert drive_module.DifyDriveLayerConfig is not None
assert execution_context_module.DifyExecutionContextLayerConfig is not None
assert plugin_module.DifyPluginLLMLayerConfig is not None
assert ask_human_module.DifyAskHumanLayerConfig is not None

View File

@ -79,6 +79,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
blocked_imports=[
"anthropic",
"dify_agent.adapters.llm",
"dify_agent.layers.drive.layer",
"dify_agent.layers.execution_context.layer",
"dify_agent.layers.ask_human.layer",
"dify_agent.layers.dify_plugin.llm_layer",
@ -98,6 +99,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
],
imports=[
"dify_agent.protocol",
"dify_agent.layers.drive",
"dify_agent.layers.execution_context",
"dify_agent.layers.ask_human",
"dify_agent.layers.dify_plugin",
@ -106,6 +108,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
],
assertions=[
"assert hasattr(dify_agent_protocol, 'PydanticAIStreamRunEvent')",
"assert dify_agent_layers_drive.__all__ == ['DIFY_DRIVE_LAYER_TYPE_ID', 'DifyDriveFileConfig', 'DifyDriveLayerConfig', 'DifyDriveSkillConfig']",
"assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextAgentMode', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig', 'DifyExecutionContextUserFrom']",
"assert dify_agent_layers_ask_human.__all__ == ['AskHumanAction', 'AskHumanActionStyle', 'AskHumanField', 'AskHumanFieldType', 'AskHumanFileField', 'AskHumanFileListField', 'AskHumanParagraphField', 'AskHumanResultStatus', 'AskHumanSelectField', 'AskHumanSelectOption', 'AskHumanSelectedAction', 'AskHumanToolArgs', 'AskHumanToolResult', 'AskHumanUrgency', 'DEFAULT_ASK_HUMAN_TOOL_DESCRIPTION', 'DIFY_ASK_HUMAN_LAYER_TYPE_ID', 'DifyAskHumanLayerConfig']",
"assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginToolCredentialType', 'DifyPluginToolConfig', 'DifyPluginToolOption', 'DifyPluginToolParameter', 'DifyPluginToolParameterForm', 'DifyPluginToolParameterType', 'DifyPluginToolsLayerConfig', 'DifyPluginToolValue']",

View File

@ -389,6 +389,7 @@ export type AgentSandboxProviderConfig = {
}
export type AgentFileRefConfig = {
drive_key?: string | null
file_id?: string | null
id?: string | null
name?: string | null
@ -405,9 +406,14 @@ export type AgentFileRefConfig = {
export type AgentSkillRefConfig = {
description?: string | null
file_id?: string | null
full_archive_file_id?: string | null
full_archive_key?: string | null
id?: string | null
manifest_files?: Array<string> | null
name?: string | null
path?: string | null
skill_md_file_id?: string | null
skill_md_key?: string | null
[key: string]: unknown
}
@ -423,6 +429,7 @@ export type AgentCliToolConfig = {
enabled?: boolean
env?: AgentCliToolEnvConfig
id?: string | null
inferred_from?: string | null
install?: string | null
install_command?: string | null
install_commands?: Array<string>

View File

@ -386,6 +386,7 @@ export const zAgentSoulSandboxConfig = z.object({
* AgentFileRefConfig
*/
export const zAgentFileRefConfig = z.object({
drive_key: z.string().max(512).nullish(),
file_id: z.string().max(255).nullish(),
id: z.string().max(255).nullish(),
name: z.string().max(255).nullish(),
@ -404,9 +405,14 @@ export const zAgentFileRefConfig = z.object({
export const zAgentSkillRefConfig = z.object({
description: z.string().nullish(),
file_id: z.string().max(255).nullish(),
full_archive_file_id: z.string().max(255).nullish(),
full_archive_key: z.string().max(512).nullish(),
id: z.string().max(255).nullish(),
manifest_files: z.array(z.string()).nullish(),
name: z.string().max(255).nullish(),
path: z.string().nullish(),
skill_md_file_id: z.string().max(255).nullish(),
skill_md_key: z.string().max(512).nullish(),
})
/**
@ -544,6 +550,7 @@ export const zAgentCliToolConfig = z.object({
enabled: z.boolean().optional().default(true),
env: zAgentCliToolEnvConfig.optional(),
id: z.string().max(255).nullish(),
inferred_from: z.string().max(255).nullish(),
install: z.string().nullish(),
install_command: z.string().nullish(),
install_commands: z.array(z.string()).optional(),

File diff suppressed because it is too large Load Diff

View File

@ -272,6 +272,37 @@ export type SandboxUploadResponse = {
path: string
}
export type AgentDriveListResponse = {
items?: Array<AgentDriveItemResponse>
}
export type AgentDriveDownloadResponse = {
url: string
}
export type AgentDrivePreviewResponse = {
binary: boolean
key: string
size?: number | null
text?: string | null
truncated: boolean
}
export type AgentDriveDeleteResponse = {
config_version_id?: string | null
removed_keys?: Array<string>
result: string
}
export type AgentDriveFilePayload = {
upload_file_id: string
}
export type AgentDriveFileCommitResponse = {
config_version_id?: string | null
file: AgentDriveFileResponse
}
export type AgentLogResponse = {
files?: Array<unknown>
iterations: Array<AgentIterationLogResponse>
@ -279,11 +310,19 @@ export type AgentLogResponse = {
}
export type AgentSkillStandardizeResponse = {
[key: string]: unknown
manifest: SkillManifest
skill: AgentSkillRefConfig
}
export type AgentSkillUploadResponse = {
[key: string]: unknown
manifest: SkillManifest
skill: AgentSkillRefConfig
}
export type SkillToolInferenceResult = {
cli_tools?: Array<CliToolSuggestion>
inferable: boolean
reason?: string | null
}
export type AnnotationReplyPayload = {
@ -1405,6 +1444,23 @@ export type SandboxToolFileResponse = {
transfer_method?: 'tool_file'
}
export type AgentDriveItemResponse = {
created_at?: number | null
file_kind: string
hash?: string | null
key: string
mime_type?: string | null
size?: number | null
}
export type AgentDriveFileResponse = {
drive_key: string
file_id: string
mime_type?: string | null
name: string
size?: number | null
}
export type AgentIterationLogResponse = {
created_at: string
files?: Array<unknown>
@ -1426,6 +1482,38 @@ export type AgentLogMetaResponse = {
total_tokens: number
}
export type SkillManifest = {
description: string
entry_path: string
files: Array<string>
hash: string
name: string
size: number
}
export type AgentSkillRefConfig = {
description?: string | null
file_id?: string | null
full_archive_file_id?: string | null
full_archive_key?: string | null
id?: string | null
manifest_files?: Array<string> | null
name?: string | null
path?: string | null
skill_md_file_id?: string | null
skill_md_key?: string | null
[key: string]: unknown
}
export type CliToolSuggestion = {
command?: string
description?: string
env_suggestions?: Array<EnvSuggestion>
inferred_from?: string
install_commands?: Array<string>
name: string
}
export type AnnotationEmbeddingModelResponse = {
embedding_model_name?: string | null
embedding_provider_name?: string | null
@ -2018,6 +2106,7 @@ export type AgentCliToolConfig = {
enabled?: boolean
env?: AgentCliToolEnvConfig
id?: string | null
inferred_from?: string | null
install?: string | null
install_command?: string | null
install_commands?: Array<string>
@ -2057,14 +2146,20 @@ export type AgentKnowledgeDatasetConfig = {
export type AgentComposerSkillCandidateResponse = {
description?: string | null
file_id?: string | null
full_archive_file_id?: string | null
full_archive_key?: string | null
id?: string | null
kind?: 'skill'
manifest_files?: Array<string> | null
name?: string | null
path?: string | null
skill_md_file_id?: string | null
skill_md_key?: string | null
[key: string]: unknown
}
export type AgentComposerFileCandidateResponse = {
drive_key?: string | null
file_id?: string | null
id?: string | null
kind?: 'file'
@ -2105,6 +2200,12 @@ export type AgentToolCallResponse = {
}
}
export type EnvSuggestion = {
key: string
reason?: string
secret_likely?: boolean
}
export type SimpleModelConfig = {
model_dict?: JsonValue | null
pre_prompt?: string | null
@ -2302,6 +2403,7 @@ export type AgentSandboxProviderConfig = {
}
export type AgentFileRefConfig = {
drive_key?: string | null
file_id?: string | null
id?: string | null
name?: string | null
@ -2315,15 +2417,6 @@ export type AgentFileRefConfig = {
[key: string]: unknown
}
export type AgentSkillRefConfig = {
description?: string | null
file_id?: string | null
id?: string | null
name?: string | null
path?: string | null
[key: string]: unknown
}
export type AgentSoulDifyToolConfig = {
credential_ref?: AgentSoulDifyToolCredentialRef | null
credential_type?: 'api-key' | 'oauth2' | 'unauthorized'
@ -3045,6 +3138,100 @@ export type PostAppsByAppIdAgentSandboxFilesUploadResponses = {
export type PostAppsByAppIdAgentSandboxFilesUploadResponse
= PostAppsByAppIdAgentSandboxFilesUploadResponses[keyof PostAppsByAppIdAgentSandboxFilesUploadResponses]
export type GetAppsByAppIdAgentDriveFilesData = {
body?: never
path: {
app_id: string
}
query?: {
node_id?: string
prefix?: string
}
url: '/apps/{app_id}/agent/drive/files'
}
export type GetAppsByAppIdAgentDriveFilesResponses = {
200: AgentDriveListResponse
}
export type GetAppsByAppIdAgentDriveFilesResponse
= GetAppsByAppIdAgentDriveFilesResponses[keyof GetAppsByAppIdAgentDriveFilesResponses]
export type GetAppsByAppIdAgentDriveFilesDownloadData = {
body?: never
path: {
app_id: string
}
query: {
key: string
node_id?: string
}
url: '/apps/{app_id}/agent/drive/files/download'
}
export type GetAppsByAppIdAgentDriveFilesDownloadResponses = {
200: AgentDriveDownloadResponse
}
export type GetAppsByAppIdAgentDriveFilesDownloadResponse
= GetAppsByAppIdAgentDriveFilesDownloadResponses[keyof GetAppsByAppIdAgentDriveFilesDownloadResponses]
export type GetAppsByAppIdAgentDriveFilesPreviewData = {
body?: never
path: {
app_id: string
}
query: {
key: string
node_id?: string
}
url: '/apps/{app_id}/agent/drive/files/preview'
}
export type GetAppsByAppIdAgentDriveFilesPreviewResponses = {
200: AgentDrivePreviewResponse
}
export type GetAppsByAppIdAgentDriveFilesPreviewResponse
= GetAppsByAppIdAgentDriveFilesPreviewResponses[keyof GetAppsByAppIdAgentDriveFilesPreviewResponses]
export type DeleteAppsByAppIdAgentFilesData = {
body?: never
path: {
app_id: string
}
query: {
key: string
node_id?: string
}
url: '/apps/{app_id}/agent/files'
}
export type DeleteAppsByAppIdAgentFilesResponses = {
200: AgentDriveDeleteResponse
}
export type DeleteAppsByAppIdAgentFilesResponse
= DeleteAppsByAppIdAgentFilesResponses[keyof DeleteAppsByAppIdAgentFilesResponses]
export type PostAppsByAppIdAgentFilesData = {
body: AgentDriveFilePayload
path: {
app_id: string
}
query?: {
node_id?: string
}
url: '/apps/{app_id}/agent/files'
}
export type PostAppsByAppIdAgentFilesResponses = {
201: AgentDriveFileCommitResponse
}
export type PostAppsByAppIdAgentFilesResponse
= PostAppsByAppIdAgentFilesResponses[keyof PostAppsByAppIdAgentFilesResponses]
export type GetAppsByAppIdAgentLogsData = {
body?: never
path: {
@ -3073,7 +3260,9 @@ export type PostAppsByAppIdAgentSkillsStandardizeData = {
path: {
app_id: string
}
query?: never
query?: {
node_id?: string
}
url: '/apps/{app_id}/agent/skills/standardize'
}
@ -3108,6 +3297,44 @@ export type PostAppsByAppIdAgentSkillsUploadResponses = {
export type PostAppsByAppIdAgentSkillsUploadResponse
= PostAppsByAppIdAgentSkillsUploadResponses[keyof PostAppsByAppIdAgentSkillsUploadResponses]
export type DeleteAppsByAppIdAgentSkillsBySlugData = {
body?: never
path: {
app_id: string
slug: string
}
query?: {
node_id?: string
}
url: '/apps/{app_id}/agent/skills/{slug}'
}
export type DeleteAppsByAppIdAgentSkillsBySlugResponses = {
200: AgentDriveDeleteResponse
}
export type DeleteAppsByAppIdAgentSkillsBySlugResponse
= DeleteAppsByAppIdAgentSkillsBySlugResponses[keyof DeleteAppsByAppIdAgentSkillsBySlugResponses]
export type PostAppsByAppIdAgentSkillsBySlugInferToolsData = {
body?: never
path: {
app_id: string
slug: string
}
query?: {
node_id?: string
}
url: '/apps/{app_id}/agent/skills/{slug}/infer-tools'
}
export type PostAppsByAppIdAgentSkillsBySlugInferToolsResponses = {
200: SkillToolInferenceResult
}
export type PostAppsByAppIdAgentSkillsBySlugInferToolsResponse
= PostAppsByAppIdAgentSkillsBySlugInferToolsResponses[keyof PostAppsByAppIdAgentSkillsBySlugInferToolsResponses]
export type PostAppsByAppIdAnnotationReplyByActionData = {
body: AnnotationReplyPayload
path: {

View File

@ -125,14 +125,38 @@ export const zAgentSandboxUploadPayload = z.object({
})
/**
* AgentSkillStandardizeResponse
* AgentDriveDownloadResponse
*/
export const zAgentSkillStandardizeResponse = z.record(z.string(), z.unknown())
export const zAgentDriveDownloadResponse = z.object({
url: z.string(),
})
/**
* AgentSkillUploadResponse
* AgentDrivePreviewResponse
*/
export const zAgentSkillUploadResponse = z.record(z.string(), z.unknown())
export const zAgentDrivePreviewResponse = z.object({
binary: z.boolean(),
key: z.string(),
size: z.int().nullish(),
text: z.string().nullish(),
truncated: z.boolean(),
})
/**
* AgentDriveDeleteResponse
*/
export const zAgentDriveDeleteResponse = z.object({
config_version_id: z.string().nullish(),
removed_keys: z.array(z.string()).optional(),
result: z.string(),
})
/**
* AgentDriveFilePayload
*/
export const zAgentDriveFilePayload = z.object({
upload_file_id: z.string(),
})
/**
* AnnotationReplyPayload
@ -1053,6 +1077,44 @@ export const zSandboxUploadResponse = z.object({
path: z.string(),
})
/**
* AgentDriveItemResponse
*/
export const zAgentDriveItemResponse = z.object({
created_at: z.int().nullish(),
file_kind: z.string(),
hash: z.string().nullish(),
key: z.string(),
mime_type: z.string().nullish(),
size: z.int().nullish(),
})
/**
* AgentDriveListResponse
*/
export const zAgentDriveListResponse = z.object({
items: z.array(zAgentDriveItemResponse).optional(),
})
/**
* AgentDriveFileResponse
*/
export const zAgentDriveFileResponse = z.object({
drive_key: z.string(),
file_id: z.string(),
mime_type: z.string().nullish(),
name: z.string(),
size: z.int().nullish(),
})
/**
* AgentDriveFileCommitResponse
*/
export const zAgentDriveFileCommitResponse = z.object({
config_version_id: z.string().nullish(),
file: zAgentDriveFileResponse,
})
/**
* AgentLogMetaResponse
*/
@ -1066,6 +1128,52 @@ export const zAgentLogMetaResponse = z.object({
total_tokens: z.int(),
})
/**
* SkillManifest
*
* Validated metadata extracted from a Skill package.
*/
export const zSkillManifest = z.object({
description: z.string(),
entry_path: z.string(),
files: z.array(z.string()),
hash: z.string(),
name: z.string(),
size: z.int(),
})
/**
* AgentSkillRefConfig
*/
export const zAgentSkillRefConfig = z.object({
description: z.string().nullish(),
file_id: z.string().max(255).nullish(),
full_archive_file_id: z.string().max(255).nullish(),
full_archive_key: z.string().max(512).nullish(),
id: z.string().max(255).nullish(),
manifest_files: z.array(z.string()).nullish(),
name: z.string().max(255).nullish(),
path: z.string().nullish(),
skill_md_file_id: z.string().max(255).nullish(),
skill_md_key: z.string().max(512).nullish(),
})
/**
* AgentSkillStandardizeResponse
*/
export const zAgentSkillStandardizeResponse = z.object({
manifest: zSkillManifest,
skill: zAgentSkillRefConfig,
})
/**
* AgentSkillUploadResponse
*/
export const zAgentSkillUploadResponse = z.object({
manifest: zSkillManifest,
skill: zAgentSkillRefConfig,
})
/**
* AnnotationEmbeddingModelResponse
*/
@ -2207,16 +2315,22 @@ export const zAgentKnowledgeDatasetConfig = z.object({
export const zAgentComposerSkillCandidateResponse = z.object({
description: z.string().nullish(),
file_id: z.string().max(255).nullish(),
full_archive_file_id: z.string().max(255).nullish(),
full_archive_key: z.string().max(512).nullish(),
id: z.string().max(255).nullish(),
kind: z.literal('skill').optional().default('skill'),
manifest_files: z.array(z.string()).nullish(),
name: z.string().max(255).nullish(),
path: z.string().nullish(),
skill_md_file_id: z.string().max(255).nullish(),
skill_md_key: z.string().max(512).nullish(),
})
/**
* AgentComposerFileCandidateResponse
*/
export const zAgentComposerFileCandidateResponse = z.object({
drive_key: z.string().max(512).nullish(),
file_id: z.string().max(255).nullish(),
id: z.string().max(255).nullish(),
kind: z.literal('file').optional().default('file'),
@ -2266,6 +2380,36 @@ export const zAgentLogResponse = z.object({
meta: zAgentLogMetaResponse,
})
/**
* EnvSuggestion
*/
export const zEnvSuggestion = z.object({
key: z.string(),
reason: z.string().optional().default(''),
secret_likely: z.boolean().optional().default(false),
})
/**
* CliToolSuggestion
*/
export const zCliToolSuggestion = z.object({
command: z.string().optional().default(''),
description: z.string().optional().default(''),
env_suggestions: z.array(zEnvSuggestion).optional(),
inferred_from: z.string().optional().default(''),
install_commands: z.array(z.string()).optional(),
name: z.string(),
})
/**
* SkillToolInferenceResult
*/
export const zSkillToolInferenceResult = z.object({
cli_tools: z.array(zCliToolSuggestion).optional(),
inferable: z.boolean(),
reason: z.string().nullish(),
})
/**
* SimpleModelConfig
*/
@ -2659,6 +2803,7 @@ export const zAgentSoulSandboxConfig = z.object({
* AgentFileRefConfig
*/
export const zAgentFileRefConfig = z.object({
drive_key: z.string().max(512).nullish(),
file_id: z.string().max(255).nullish(),
id: z.string().max(255).nullish(),
name: z.string().max(255).nullish(),
@ -2671,25 +2816,6 @@ export const zAgentFileRefConfig = z.object({
url: z.string().nullish(),
})
/**
* WorkflowNodeJobMetadata
*/
export const zWorkflowNodeJobMetadata = z.object({
agent_soul: z.record(z.string(), z.unknown()).nullish(),
file_refs: z.array(zAgentFileRefConfig).nullish(),
})
/**
* AgentSkillRefConfig
*/
export const zAgentSkillRefConfig = z.object({
description: z.string().nullish(),
file_id: z.string().max(255).nullish(),
id: z.string().max(255).nullish(),
name: z.string().max(255).nullish(),
path: z.string().nullish(),
})
/**
* AgentSoulSkillsFilesConfig
*/
@ -2698,6 +2824,14 @@ export const zAgentSoulSkillsFilesConfig = z.object({
skills: z.array(zAgentSkillRefConfig).optional(),
})
/**
* WorkflowNodeJobMetadata
*/
export const zWorkflowNodeJobMetadata = z.object({
agent_soul: z.record(z.string(), z.unknown()).nullish(),
file_refs: z.array(zAgentFileRefConfig).nullish(),
})
/**
* AgentCliToolAuthorizationStatus
*
@ -2783,6 +2917,7 @@ export const zAgentCliToolConfig = z.object({
enabled: z.boolean().optional().default(true),
env: zAgentCliToolEnvConfig.optional(),
id: z.string().max(255).nullish(),
inferred_from: z.string().max(255).nullish(),
install: z.string().nullish(),
install_command: z.string().nullish(),
install_commands: z.array(z.string()).optional(),
@ -3766,6 +3901,77 @@ export const zPostAppsByAppIdAgentSandboxFilesUploadPath = z.object({
*/
export const zPostAppsByAppIdAgentSandboxFilesUploadResponse = zSandboxUploadResponse
export const zGetAppsByAppIdAgentDriveFilesPath = z.object({
app_id: z.string(),
})
export const zGetAppsByAppIdAgentDriveFilesQuery = z.object({
node_id: z.string().optional(),
prefix: z.string().optional().default(''),
})
/**
* Drive entries
*/
export const zGetAppsByAppIdAgentDriveFilesResponse = zAgentDriveListResponse
export const zGetAppsByAppIdAgentDriveFilesDownloadPath = z.object({
app_id: z.string(),
})
export const zGetAppsByAppIdAgentDriveFilesDownloadQuery = z.object({
key: z.string().min(1),
node_id: z.string().optional(),
})
/**
* Signed URL
*/
export const zGetAppsByAppIdAgentDriveFilesDownloadResponse = zAgentDriveDownloadResponse
export const zGetAppsByAppIdAgentDriveFilesPreviewPath = z.object({
app_id: z.string(),
})
export const zGetAppsByAppIdAgentDriveFilesPreviewQuery = z.object({
key: z.string().min(1),
node_id: z.string().optional(),
})
/**
* Preview
*/
export const zGetAppsByAppIdAgentDriveFilesPreviewResponse = zAgentDrivePreviewResponse
export const zDeleteAppsByAppIdAgentFilesPath = z.object({
app_id: z.string(),
})
export const zDeleteAppsByAppIdAgentFilesQuery = z.object({
key: z.string().min(1),
node_id: z.string().optional(),
})
/**
* File removed
*/
export const zDeleteAppsByAppIdAgentFilesResponse = zAgentDriveDeleteResponse
export const zPostAppsByAppIdAgentFilesBody = zAgentDriveFilePayload
export const zPostAppsByAppIdAgentFilesPath = z.object({
app_id: z.string(),
})
export const zPostAppsByAppIdAgentFilesQuery = z.object({
node_id: z.string().optional(),
})
/**
* File committed into the agent drive
*/
export const zPostAppsByAppIdAgentFilesResponse = zAgentDriveFileCommitResponse
export const zGetAppsByAppIdAgentLogsPath = z.object({
app_id: z.string(),
})
@ -3784,6 +3990,10 @@ export const zPostAppsByAppIdAgentSkillsStandardizePath = z.object({
app_id: z.string(),
})
export const zPostAppsByAppIdAgentSkillsStandardizeQuery = z.object({
node_id: z.string().optional(),
})
/**
* Skill standardized into drive
*/
@ -3798,6 +4008,34 @@ export const zPostAppsByAppIdAgentSkillsUploadPath = z.object({
*/
export const zPostAppsByAppIdAgentSkillsUploadResponse = zAgentSkillUploadResponse
export const zDeleteAppsByAppIdAgentSkillsBySlugPath = z.object({
app_id: z.string(),
slug: z.string(),
})
export const zDeleteAppsByAppIdAgentSkillsBySlugQuery = z.object({
node_id: z.string().optional(),
})
/**
* Skill removed
*/
export const zDeleteAppsByAppIdAgentSkillsBySlugResponse = zAgentDriveDeleteResponse
export const zPostAppsByAppIdAgentSkillsBySlugInferToolsPath = z.object({
app_id: z.string(),
slug: z.string(),
})
export const zPostAppsByAppIdAgentSkillsBySlugInferToolsQuery = z.object({
node_id: z.string().optional(),
})
/**
* Inference result (draft suggestions, nothing persisted)
*/
export const zPostAppsByAppIdAgentSkillsBySlugInferToolsResponse = zSkillToolInferenceResult
export const zPostAppsByAppIdAnnotationReplyByActionBody = zAnnotationReplyPayload
export const zPostAppsByAppIdAnnotationReplyByActionPath = z.object({

View File

@ -483,8 +483,9 @@ export function PaginationPage({
aria-current={current ? 'page' : undefined}
aria-label={ariaLabel ?? (current ? `Page ${page}, current page` : `Go to page ${page}`)}
className={cn(
'inline-flex h-8 min-w-8 touch-manipulation items-center justify-center rounded-lg px-1 py-2 system-sm-medium tabular-nums text-text-tertiary outline-hidden hover:bg-components-button-ghost-bg-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid',
'inline-flex h-8 min-w-8 touch-manipulation items-center justify-center rounded-lg px-1 py-2 system-sm-medium tabular-nums text-text-tertiary outline-hidden transition-colors hover:bg-components-button-ghost-bg-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid',
current && 'bg-components-button-tertiary-bg text-components-button-tertiary-text hover:bg-components-button-ghost-bg-hover',
'motion-reduce:transition-none',
className,
)}
onClick={(event) => {

View File

@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react'
import ImportFromMarketplaceTemplateModal from '../import-from-marketplace-template-modal'
const mockUseMarketplaceTemplateDetail = vi.fn()
const mockFetchMarketplaceTemplateDSL = vi.fn()
vi.mock('@/service/marketplace-templates', () => ({
useMarketplaceTemplateDetail: (...args: unknown[]) => mockUseMarketplaceTemplateDetail(...args),
fetchMarketplaceTemplateDSL: (...args: unknown[]) => mockFetchMarketplaceTemplateDSL(...args),
}))
describe('ImportFromMarketplaceTemplateModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseMarketplaceTemplateDetail.mockReturnValue({
data: {
data: {
id: 'human-input-writing',
template_name: 'Human Input: Writing Assistant',
overview: 'Send your creative brief, get a high-quality draft, and review before publishing.',
icon: 'technologist',
icon_background: '#D1FAE5',
icon_file_key: '',
publisher_unique_handle: 'langgenius',
usage_count: 261,
categories: ['operations'],
},
},
isLoading: false,
isError: false,
})
})
it('renders marketplace emoji icons without exposing the emoji id as text', () => {
render(
<ImportFromMarketplaceTemplateModal
templateId="human-input-writing"
onClose={vi.fn()}
onConfirm={vi.fn()}
/>,
)
expect(screen.getByText('Human Input: Writing Assistant')).toBeInTheDocument()
expect(screen.queryByText('technologist')).not.toBeInTheDocument()
})
})

View File

@ -6,6 +6,7 @@ import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { MARKETPLACE_API_PREFIX } from '@/config'
import {
fetchMarketplaceTemplateDSL,
@ -100,22 +101,13 @@ const ImportFromMarketplaceTemplateModal = ({
{template && (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
{template.icon_file_key
? (
<img
src={`${MARKETPLACE_API_PREFIX}/templates/${template.id}/icon`}
alt={template.template_name}
className="size-10 rounded-lg object-cover"
/>
)
: (
<div
className="flex size-10 items-center justify-center rounded-lg text-xl"
style={{ background: template.icon_background || '#F3F4F6' }}
>
{template.icon || '📄'}
</div>
)}
<AppIcon
size="large"
iconType={template.icon_file_key ? 'image' : 'emoji'}
icon={template.icon || 'page_facing_up'}
background={template.icon_file_key ? undefined : template.icon_background}
imageUrl={template.icon_file_key ? `${MARKETPLACE_API_PREFIX}/templates/${template.id}/icon` : undefined}
/>
<div className="flex flex-col">
<div className="system-md-semibold text-text-primary">{template.template_name}</div>
<div className="flex items-center gap-1 system-xs-regular text-text-tertiary">