Compare commits

..

6 Commits

74 changed files with 3301 additions and 1672 deletions

View File

@ -263,6 +263,7 @@ class AgentBackendRunRequestBuilder:
RunLayerSpec(
name=DIFY_DRIVE_LAYER_ID,
type=DIFY_DRIVE_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=run_input.drive_config,
)
@ -460,6 +461,7 @@ class AgentBackendRunRequestBuilder:
RunLayerSpec(
name=DIFY_DRIVE_LAYER_ID,
type=DIFY_DRIVE_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=run_input.drive_config,
)

View File

@ -1,4 +1,3 @@
import logging
from typing import Any
from uuid import UUID
@ -30,7 +29,6 @@ from fields.base import ResponseModel
from libs.helper import uuid_value
from libs.login import login_required
from models import Account
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
@ -49,8 +47,6 @@ from services.agent_drive_service import (
)
from services.agent_service import AgentService
logger = logging.getLogger(__name__)
_WORKFLOW_AGENT_DRIVE_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
_AGENT_SKILL_UPLOAD_PARAMS = {
"file": {
@ -130,8 +126,16 @@ class AgentLogResponse(ResponseModel):
files: list[Any] = Field(default_factory=list)
class AgentUploadedSkillResponse(ResponseModel):
name: str
description: str
path: str
skill_md_key: str
archive_key: str | None = None
class AgentSkillUploadResponse(ResponseModel):
skill: AgentSkillRefConfig
skill: AgentUploadedSkillResponse
manifest: SkillManifest
@ -145,13 +149,11 @@ class AgentDriveFileResponse(ResponseModel):
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, AgentDriveDeleteFileByAgentQuery)
@ -161,6 +163,7 @@ register_response_schema_models(
AgentDriveFileCommitResponse,
AgentDriveFileResponse,
AgentLogResponse,
AgentUploadedSkillResponse,
AgentSkillUploadResponse,
SkillToolInferenceResult,
)
@ -242,24 +245,6 @@ def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
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=node_id,
)
return {
"file": {
"name": upload_file.name,
@ -268,7 +253,6 @@ def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
"size": row.get("size"),
"mime_type": row.get("mime_type"),
},
"config_version_id": config_version_id,
}, 201
@ -283,24 +267,17 @@ def _delete_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
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=node_id,
)
removed_keys: list[str] = []
try:
removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, key=key)
result = AgentDriveService().commit(
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
items=[DriveCommitItem(key=key, file_ref=None)],
)
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}
removed_keys = [item["key"] for item in result if item.get("removed")]
return {"result": "success", "removed_keys": removed_keys}
def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, allow_node_id: bool = True):
@ -312,22 +289,20 @@ def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, a
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=node_id,
)
removed_keys: list[str] = []
try:
removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=f"{slug}/")
result = AgentDriveService().commit(
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
items=[
DriveCommitItem(key=f"{slug}/SKILL.md", file_ref=None),
DriveCommitItem(key=f"{slug}/.DIFY-SKILL-FULL.zip", file_ref=None),
],
)
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}
removed_keys = [item["key"] for item in result if item.get("removed")]
return {"result": "success", "removed_keys": removed_keys}
def _infer_skill_tools_for_app(*, app_model: App, slug: str):
@ -455,7 +430,7 @@ class AgentDriveFilesApi(Resource):
return _commit_drive_file_for_app(current_user=current_user, app_model=app_model)
@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(description="Delete one drive file by key via drive commit-null semantics")
@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
@ -487,7 +462,7 @@ class AgentSkillByAgentApi(Resource):
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)"
description="Delete a standardized skill by removing its known drive keys via commit-null"
)
@console_ns.doc(
params={

View File

@ -56,12 +56,30 @@ class AgentDriveItemResponse(ResponseModel):
hash: str | None = None
file_kind: str
created_at: int | None = None
is_skill: bool | None = None
skill_metadata: str | None = None
class AgentDriveListResponse(ResponseModel):
items: list[AgentDriveItemResponse] = Field(default_factory=list)
class AgentDriveSkillItemResponse(ResponseModel):
path: str
skill_md_key: str
archive_key: str | None = None
name: str
description: str
size: int | None = None
mime_type: str | None = None
hash: str | None = None
created_at: int | None = None
class AgentDriveSkillListResponse(ResponseModel):
items: list[AgentDriveSkillItemResponse] = Field(default_factory=list)
class AgentDrivePreviewResponse(ResponseModel):
key: str
size: int | None = None
@ -75,7 +93,11 @@ class AgentDriveDownloadResponse(ResponseModel):
register_response_schema_models(
console_ns, AgentDriveListResponse, AgentDrivePreviewResponse, AgentDriveDownloadResponse
console_ns,
AgentDriveDownloadResponse,
AgentDriveListResponse,
AgentDrivePreviewResponse,
AgentDriveSkillListResponse,
)
@ -119,6 +141,25 @@ class AgentDriveListByAgentApi(Resource):
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
@console_ns.route("/agent/<uuid:agent_id>/drive/skills")
class AgentDriveSkillListByAgentApi(Resource):
@console_ns.doc("list_agent_drive_skills_by_agent")
@console_ns.doc(description="List drive-backed skills for an Agent App")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id))
except AgentDriveError as exc:
return _handle(exc)
return {"items": items}
@console_ns.route("/agent/<uuid:agent_id>/drive/files/preview")
class AgentDrivePreviewByAgentApi(Resource):
@console_ns.doc("preview_agent_drive_file_by_agent")
@ -182,6 +223,28 @@ class AgentDriveListApi(Resource):
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/skills")
class AgentDriveSkillListApi(Resource):
@console_ns.doc("list_agent_drive_skills")
@console_ns.doc(description="List drive-backed skills for the bound agent")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)})
@console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_WORKFLOW_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().list_skills(tenant_id=app_model.tenant_id, agent_id=agent_id)
except AgentDriveError as exc:
return _handle(exc)
return {"items": items}
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/preview")
class AgentDrivePreviewApi(Resource):
@console_ns.doc("preview_agent_drive_file")
@ -230,6 +293,8 @@ __all__ = [
"AgentDriveDownloadByAgentApi",
"AgentDriveListApi",
"AgentDriveListByAgentApi",
"AgentDriveSkillListApi",
"AgentDriveSkillListByAgentApi",
"AgentDrivePreviewApi",
"AgentDrivePreviewByAgentApi",
]

View File

@ -1,10 +1,10 @@
"""Inner API for the agent drive (agent 网盘) control plane — ENG-591.
"""Inner API for the agent drive (agent 网盘) control plane.
Two endpoints, called by the dify-agent server (not the sandbox) with the inner
API key. The drive ref is the URL segment ``agent-<agent_id>``; the path-like
file key travels in the query/body, never as a URL path segment (so its ``/``
characters do not collide with routing). Drive-owned semantics: tenant scoped,
no user-level FileAccessScope.
These endpoints are called by the dify-agent server (not the sandbox) with the
inner API key. The drive ref is the URL segment ``agent-<agent_id>``; the
path-like file key travels in the query/body, never as a URL path segment (so
its ``/`` characters do not collide with routing). Drive-owned semantics:
tenant scoped, no user-level FileAccessScope.
"""
from flask import request
@ -56,6 +56,24 @@ class AgentDriveManifestApi(Resource):
return {"items": items}
@inner_api_ns.route("/drive/<string:drive_ref>/skills")
class AgentDriveSkillsApi(Resource):
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("agent_drive_skills")
@inner_api_ns.doc(description="List the skill catalog of an agent drive")
def get(self, drive_ref: str):
try:
agent_id = parse_agent_drive_ref(drive_ref)
tenant_id = (request.args.get("tenant_id") or "").strip()
if not tenant_id:
raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400)
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=agent_id)
except AgentDriveError as exc:
return _error_response(exc)
return {"items": items}
@inner_api_ns.route("/drive/<string:drive_ref>/commit")
class AgentDriveCommitApi(Resource):
@setup_required

View File

@ -36,6 +36,7 @@ from core.workflow.nodes.agent_v2.plugin_tools_builder import (
)
from core.workflow.nodes.agent_v2.runtime_request_builder import (
append_runtime_warnings,
build_drive_aware_soul_mention_resolver,
build_ask_human_layer_config,
build_drive_layer_config,
build_knowledge_layer_config,
@ -123,9 +124,19 @@ class AgentAppRuntimeRequestBuilder:
}
drive_config = None
soul_prompt_resolver = build_soul_mention_resolver(agent_soul)
if dify_config.AGENT_DRIVE_MANIFEST_ENABLED:
drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent_id)
drive_config, drive_warnings = build_drive_layer_config(
agent_soul,
tenant_id=context.dify_context.tenant_id,
agent_id=context.agent_id,
)
append_runtime_warnings(metadata, drive_warnings)
soul_prompt_resolver = build_drive_aware_soul_mention_resolver(
agent_soul,
tenant_id=context.dify_context.tenant_id,
agent_id=context.agent_id,
)
knowledge_config = build_knowledge_layer_config(agent_soul)
request = self._request_builder.build_for_agent_app(
@ -154,9 +165,7 @@ class AgentAppRuntimeRequestBuilder:
),
# ENG-616: expand slash-menu mention tokens to canonical names so
# no frontend-internal {{#…#}} marker ever reaches the model.
agent_soul_prompt=expand_prompt_mentions(
agent_soul.prompt.system_prompt, build_soul_mention_resolver(agent_soul)
).strip()
agent_soul_prompt=expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
or None,
user_prompt=context.user_query,
tools=tools_layer,

View File

@ -16,9 +16,6 @@ SUPPORTED_AGENT_BACKEND_FEATURES = frozenset(
"knowledge",
"env",
"sandbox",
# ENG-623: exposed at runtime as the dify.drive declaration layer
# (an index the agent pulls through the back proxy).
"skills_files",
# ENG-635: human involvement is exposed at runtime as the dify.ask_human
# deferred tool; a call pauses via the existing HITL form mechanism.
"human",
@ -32,11 +29,7 @@ RESERVED_AGENT_BACKEND_FEATURES = frozenset(
)
def build_runtime_feature_manifest(
agent_soul: AgentSoulConfig,
*,
drive_manifest_enabled: bool = False,
) -> dict[str, Any]:
def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> 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)
@ -54,38 +47,10 @@ def build_runtime_feature_manifest(
}
)
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["knowledge"] = (
"supported_by_knowledge_layer" if list_configured_knowledge_dataset_ids(agent_soul) else "not_configured"
)
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

@ -7,7 +7,6 @@ from typing import Any, Literal, Protocol, assert_never, cast
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.layers.ask_human import DifyAskHumanLayerConfig
from dify_agent.layers.drive import (
DifyDriveFileConfig,
DifyDriveLayerConfig,
DifyDriveSkillConfig,
)
@ -54,10 +53,13 @@ from models.agent_config_entities import (
effective_declared_outputs as _effective_declared_outputs,
)
from models.provider_ids import ModelProviderID
from services.agent_drive_service import AgentDriveService, decode_drive_mention_ref
from services.agent.prompt_mentions import (
MentionKind,
build_node_job_mention_resolver,
build_soul_mention_resolver,
expand_prompt_mentions,
parse_prompt_mentions,
)
from .output_failure_orchestrator import retry_idempotency_key
@ -153,9 +155,6 @@ class WorkflowAgentRuntimeRequestBuilder:
expand_prompt_mentions(node_job.workflow_prompt, build_node_job_mention_resolver(node_job)).strip()
or "Run this workflow Agent Node for the current run."
)
soul_prompt = expand_prompt_mentions(
agent_soul.prompt.system_prompt, build_soul_mention_resolver(agent_soul)
).strip()
user_prompt = workflow_context_prompt.strip() or "Use the current workflow context."
credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model)
try:
@ -182,9 +181,20 @@ class WorkflowAgentRuntimeRequestBuilder:
}
drive_config: DifyDriveLayerConfig | None = None
soul_prompt_resolver = build_soul_mention_resolver(agent_soul)
if dify_config.AGENT_DRIVE_MANIFEST_ENABLED:
drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent.id)
drive_config, drive_warnings = build_drive_layer_config(
agent_soul,
tenant_id=context.dify_context.tenant_id,
agent_id=context.agent.id,
)
append_runtime_warnings(metadata, drive_warnings)
soul_prompt_resolver = build_drive_aware_soul_mention_resolver(
agent_soul,
tenant_id=context.dify_context.tenant_id,
agent_id=context.agent.id,
)
soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
knowledge_config = build_knowledge_layer_config(agent_soul)
request = self._request_builder.build_for_workflow_node(
@ -292,10 +302,7 @@ 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,
drive_manifest_enabled=dify_config.AGENT_DRIVE_MANIFEST_ENABLED,
),
"runtime_support": build_runtime_feature_manifest(agent_soul),
}
def _build_workflow_context_prompt(
@ -603,76 +610,109 @@ def append_runtime_warnings(metadata: dict[str, Any], warnings: list[dict[str, s
existing.extend(warnings)
def build_drive_aware_soul_mention_resolver(
agent_soul: AgentSoulConfig,
*,
tenant_id: str,
agent_id: str,
):
"""Resolve skill/file mentions against the agent drive and everything else via Agent Soul."""
base_resolver = build_soul_mention_resolver(agent_soul)
drive_service = AgentDriveService()
skill_catalog = drive_service.list_skills(tenant_id=tenant_id, agent_id=agent_id)
skill_names_by_key = {skill["skill_md_key"]: skill["name"] for skill in skill_catalog}
drive_keys = {item["key"] for item in drive_service.manifest(tenant_id=tenant_id, agent_id=agent_id)}
def _resolve(mention: object) -> str | None:
if not hasattr(mention, "kind") or not hasattr(mention, "ref_id"):
return None
kind = cast(MentionKind, mention.kind)
ref_id = cast(str, mention.ref_id)
label = cast(str | None, getattr(mention, "label", None))
if kind == MentionKind.SKILL:
decoded_key = decode_drive_mention_ref(ref_id)
return skill_names_by_key.get(decoded_key) or label or decoded_key
if kind == MentionKind.FILE:
decoded_key = decode_drive_mention_ref(ref_id)
if decoded_key in drive_keys:
return decoded_key.rsplit("/", 1)[-1]
return label or decoded_key
return base_resolver(cast(Any, mention))
return _resolve
def build_drive_layer_config(
agent_soul: AgentSoulConfig,
*,
tenant_id: str,
agent_id: str | None,
) -> tuple[DifyDriveLayerConfig | None, list[dict[str, str]]]:
"""Catalog the soul's drive-backed Skills & Files into the dify.drive declaration.
"""Derive drive runtime catalog + prompt-mentioned eager-pull keys from the drive."""
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]] = []
mentioned_drive_refs = [
decode_drive_mention_ref(mention.ref_id)
for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt)
if mention.kind in {MentionKind.SKILL, MentionKind.FILE}
]
ordered_mentions = list(dict.fromkeys(ref for ref in mentioned_drive_refs if ref))
if not agent_id:
if not ordered_mentions:
return None, []
return None, [
{
"section": "agent_soul.prompt.system_prompt",
"code": "drive_ref_dangling",
"message": "drive mentions are configured but the run has no bound agent to address a drive by.",
}
]
drive_service = AgentDriveService()
skills_catalog = drive_service.list_skills(tenant_id=tenant_id, agent_id=agent_id)
manifest_items = drive_service.manifest(tenant_id=tenant_id, agent_id=agent_id)
manifest_by_key = {item["key"]: item for item in manifest_items}
skill_keys = {skill["skill_md_key"] for skill in skills_catalog}
warnings: list[dict[str, str]] = []
mentioned_skill_keys: list[str] = []
mentioned_file_keys: list[str] = []
for drive_key in ordered_mentions:
if drive_key in skill_keys:
mentioned_skill_keys.append(drive_key)
continue
if drive_key in manifest_by_key:
mentioned_file_keys.append(drive_key)
continue
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.",
"section": "agent_soul.prompt.system_prompt",
"code": "mention_target_missing",
"message": f"drive mention '{drive_key}' has no matching drive entry.",
}
)
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,
)
skills = [
DifyDriveSkillConfig(
path=skill["path"],
name=skill["name"],
description=skill["description"],
skill_md_key=skill["skill_md_key"],
archive_key=skill["archive_key"],
)
for skill in skills_catalog
]
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:
if not skills and not mentioned_skill_keys and not mentioned_file_keys:
return None, warnings
return DifyDriveLayerConfig(drive_ref=f"agent-{agent_id}", skills=skills, files=files), warnings
return (
DifyDriveLayerConfig(
drive_ref=f"agent-{agent_id}",
skills=skills,
mentioned_skill_keys=mentioned_skill_keys,
mentioned_file_keys=mentioned_file_keys,
),
warnings,
)
def _cli_tool_enabled(item: object) -> bool:

View File

@ -35,7 +35,6 @@ class WorkflowAgentNodeValidator:
"soul",
"prompt",
"system_prompt",
"skills_files",
"skills",
"files",
"tools",

View File

@ -1,5 +1,5 @@
from datetime import datetime
from typing import Annotated, Literal
from typing import Literal
from pydantic import Field, field_validator
@ -16,10 +16,8 @@ from models.agent import (
)
from models.agent_config_entities import (
AgentCliToolConfig,
AgentFileRefConfig,
AgentHumanContactConfig,
AgentKnowledgeDatasetConfig,
AgentSkillRefConfig,
AgentSoulConfig,
DeclaredOutputConfig,
DeclaredOutputType,
@ -391,20 +389,6 @@ class AgentComposerDifyToolCandidateResponse(ResponseModel):
tools_count: int | None = None
class AgentComposerSkillCandidateResponse(AgentSkillRefConfig):
kind: Literal["skill"] = "skill"
class AgentComposerFileCandidateResponse(AgentFileRefConfig):
kind: Literal["file"] = "file"
AgentComposerSkillFileCandidateResponse = Annotated[
AgentComposerSkillCandidateResponse | AgentComposerFileCandidateResponse,
Field(discriminator="kind"),
]
class AgentComposerNodeJobCandidatesResponse(ResponseModel):
previous_node_outputs: list[WorkflowPreviousNodeOutputRef] = Field(default_factory=list)
declare_output_types: list[DeclaredOutputType] = Field(default_factory=list)
@ -412,7 +396,6 @@ class AgentComposerNodeJobCandidatesResponse(ResponseModel):
class AgentComposerSoulCandidatesResponse(ResponseModel):
skills_files: list[AgentComposerSkillFileCandidateResponse] = Field(default_factory=list)
dify_tools: list[AgentComposerDifyToolCandidateResponse] = Field(default_factory=list)
cli_tools: list[AgentCliToolConfig] = Field(default_factory=list)
knowledge_datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)

View File

@ -7,7 +7,7 @@ Create Date: 2026-06-05 11:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
from alembic import context, op
# revision identifiers, used by Alembic.
revision = "b7c2d9e8a1f4"
@ -17,10 +17,23 @@ depends_on = None
def upgrade():
if _has_last_opened_at_column():
return
with op.batch_alter_table("tenant_account_joins", schema=None) as batch_op:
batch_op.add_column(sa.Column("last_opened_at", sa.DateTime(), nullable=True))
def downgrade():
if not _has_last_opened_at_column():
return
with op.batch_alter_table("tenant_account_joins", schema=None) as batch_op:
batch_op.drop_column("last_opened_at")
def _has_last_opened_at_column() -> bool:
if context.is_offline_mode():
# Offline SQL generation cannot inspect the target schema. Assume the
# linear migration path so generated SQL stays explicit.
return False
inspector = sa.inspect(op.get_bind())
return "last_opened_at" in {column["name"] for column in inspector.get_columns("tenant_account_joins")}

View File

@ -0,0 +1,82 @@
"""agent drive skill metadata refactor
Revision ID: b2515f9d4c2a
Revises: 4f7b2c8d9a10
Create Date: 2026-06-18 23:00:00.000000
"""
from __future__ import annotations
import json
from typing import Any
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
from sqlalchemy.engine.mock import MockConnection
# revision identifiers, used by Alembic.
revision = "b2515f9d4c2a"
down_revision = "4f7b2c8d9a10"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"agent_drive_files",
sa.Column("is_skill", sa.Boolean(), nullable=False, server_default=sa.text("false")),
)
op.add_column(
"agent_drive_files",
sa.Column("skill_metadata", sa.Text().with_variant(mysql.LONGTEXT(), "mysql"), nullable=True),
)
op.create_index(
"agent_drive_files_tenant_agent_is_skill_key_idx",
"agent_drive_files",
["tenant_id", "agent_id", "is_skill", "key"],
)
_remove_skills_files_from_snapshots()
def downgrade() -> None:
op.drop_index("agent_drive_files_tenant_agent_is_skill_key_idx", table_name="agent_drive_files")
op.drop_column("agent_drive_files", "skill_metadata")
op.drop_column("agent_drive_files", "is_skill")
def _remove_skills_files_from_snapshots() -> None:
connection = op.get_bind()
if connection is None or isinstance(connection, MockConnection):
return
snapshots = sa.table(
"agent_config_snapshots",
sa.column("id", sa.String()),
sa.column("config_snapshot", sa.Text()),
)
rows = connection.execute(sa.select(snapshots.c.id, snapshots.c.config_snapshot)).fetchall()
for row in rows:
cleaned = _strip_skills_files(row.config_snapshot)
if cleaned is None:
continue
connection.execute(
snapshots.update()
.where(snapshots.c.id == row.id)
.values(config_snapshot=json.dumps(cleaned, separators=(",", ":"), sort_keys=True))
)
def _strip_skills_files(raw_snapshot: Any) -> dict[str, Any] | None:
if raw_snapshot is None:
return None
if isinstance(raw_snapshot, str):
snapshot = json.loads(raw_snapshot)
elif isinstance(raw_snapshot, dict):
snapshot = dict(raw_snapshot)
else:
snapshot = dict(raw_snapshot)
if not isinstance(snapshot, dict) or "skills_files" not in snapshot:
return None
snapshot.pop("skills_files", None)
return snapshot

View File

@ -430,14 +430,17 @@ class AgentDriveFile(DefaultFieldsMixin, Base):
synced. ``value_owned_by_drive`` gates physical cleanup: only drive-owned values
(created by the agent runtime or Skill standardization, not shared with other
business records) have their storage object + record deleted when the KV entry is
overwritten or removed; otherwise only the KV row is dropped. Lifecycle never relies
on ``UploadFile.used/used_by`` (not a reliable refcount).
overwritten or removed; otherwise only the KV row is dropped. Skills are represented
by the canonical ``<path>/SKILL.md`` row with ``is_skill=True`` and a serialized
``skill_metadata`` string. Lifecycle never relies on ``UploadFile.used/used_by``
(not a reliable refcount).
"""
__tablename__ = "agent_drive_files"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="agent_drive_file_pkey"),
UniqueConstraint("tenant_id", "agent_id", "key", name="agent_drive_file_scope_key_unique"),
Index("agent_drive_files_tenant_agent_is_skill_key_idx", "tenant_id", "agent_id", "is_skill", "key"),
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
@ -453,6 +456,8 @@ class AgentDriveFile(DefaultFieldsMixin, Base):
value_owned_by_drive: Mapped[bool] = mapped_column(
sa.Boolean, nullable=False, default=False, server_default=sa.text("false")
)
is_skill: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=False, server_default=sa.text("false"))
skill_metadata: Mapped[str | None] = mapped_column(LongText, nullable=True)
size: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True)
hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
mime_type: Mapped[str | None] = mapped_column(String(255), nullable=True)

View File

@ -361,11 +361,6 @@ class AgentSoulPromptConfig(BaseModel):
system_prompt: str = ""
class AgentSoulSkillsFilesConfig(BaseModel):
files: list[AgentFileRefConfig] = Field(default_factory=list)
skills: list[AgentSkillRefConfig] = Field(default_factory=list)
class AgentSoulDifyToolCredentialRef(BaseModel):
"""Reference to a stored Dify Plugin Tool credential.
@ -514,7 +509,6 @@ class AgentSoulConfig(BaseModel):
schema_version: int = 1
prompt: AgentSoulPromptConfig = Field(default_factory=AgentSoulPromptConfig)
skills_files: AgentSoulSkillsFilesConfig = Field(default_factory=AgentSoulSkillsFilesConfig)
tools: AgentSoulToolsConfig = Field(default_factory=AgentSoulToolsConfig)
knowledge: AgentSoulKnowledgeConfig = Field(default_factory=AgentSoulKnowledgeConfig)
human: AgentSoulHumanConfig = Field(default_factory=AgentSoulHumanConfig)

View File

@ -137,9 +137,6 @@ def soul_candidates(
soul = agent_soul or AgentSoulConfig()
truncated = False
skills_files = [{"kind": "skill", **skill.model_dump(exclude_none=True)} for skill in soul.skills_files.skills]
skills_files += [{"kind": "file", **file.model_dump(exclude_none=True)} for file in soul.skills_files.files]
cli_tools = [tool.model_dump(exclude_none=True) for tool in soul.tools.cli_tools if tool.enabled]
dataset_ids = [dataset.id for dataset in soul.knowledge.datasets if dataset.id]
@ -162,7 +159,6 @@ def soul_candidates(
dify_tools = workspace_tools_loader()
lists = {
"skills_files": skills_files,
"dify_tools": dify_tools,
"cli_tools": cli_tools,
"knowledge_datasets": knowledge_datasets,

View File

@ -21,7 +21,6 @@ from models.agent import (
WorkflowAgentNodeBinding,
)
from models.agent_config_entities import (
AgentFileRefConfig,
DeclaredOutputConfig,
)
from models.agent_config_entities import (
@ -34,7 +33,6 @@ from services.agent.errors import (
AgentNameConflictError,
AgentNotFoundError,
AgentVersionNotFoundError,
InvalidComposerConfigError,
)
from services.entities.agent_entities import (
AgentSoulConfig,
@ -106,29 +104,6 @@ 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.NODE_JOB_ONLY,
ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION,
ComposerSaveStrategy.SAVE_AS_NEW_VERSION,
)
and (
payload.save_strategy != ComposerSaveStrategy.NODE_JOB_ONLY
or binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT
)
):
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(
@ -176,7 +151,11 @@ class AgentComposerService:
version_id=version_id,
)
state = cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
state["validation"] = cls.collect_validation_findings(tenant_id=tenant_id, payload=payload)
state["validation"] = cls.collect_validation_findings(
tenant_id=tenant_id,
payload=payload,
agent_id=binding.agent_id,
)
return state
@classmethod
@ -250,9 +229,6 @@ 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,
@ -281,7 +257,11 @@ class AgentComposerService:
db.session.commit()
state = cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id)
state["validation"] = cls.collect_validation_findings(tenant_id=tenant_id, payload=payload)
state["validation"] = cls.collect_validation_findings(
tenant_id=tenant_id,
payload=payload,
agent_id=agent.id,
)
return state
@classmethod
@ -292,11 +272,7 @@ class AgentComposerService:
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.
"""
"""ENG-617 soft findings, with DB-backed dataset and drive mention checks."""
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
mentioned_ids: set[str] = set()
@ -312,136 +288,14 @@ class AgentComposerService:
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)
cls._drive_mention_findings(
tenant_id=tenant_id,
agent_id=agent_id,
prompt=payload.agent_soul.prompt.system_prompt,
)
)
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)."""
@ -468,49 +322,25 @@ class AgentComposerService:
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(
def _drive_mention_findings(
cls,
*,
tenant_id: str,
agent_id: str,
agent_soul: AgentSoulConfig,
prompt: str,
) -> list[dict[str, str | None]]:
"""Drive-backed refs whose keys have no row in the agent drive (ENG-623 §4.4).
"""Soft warnings for missing drive-backed prompt mentions."""
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
from services.agent_drive_service import decode_drive_mention_ref
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")
for mention in parse_prompt_mentions(prompt):
if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}:
continue
decoded_key = decode_drive_mention_ref(mention.ref_id)
if not decoded_key:
continue
wanted_keys[decoded_key] = (mention.kind.value, mention.label or decoded_key)
if not wanted_keys:
return []
@ -524,28 +354,20 @@ class AgentComposerService:
)
)
findings: list[dict[str, str | None]] = []
for key, (code, display) in wanted_keys.items():
for key, (kind, display) in wanted_keys.items():
if key in existing_keys:
continue
kind = "skill" if code == "skill_ref_dangling" else "file"
findings.append(
{
"code": code,
"code": "mention_target_missing",
"surface": "agent_soul",
"kind": kind,
"id": key,
"message": f"{code}: {kind} '{display}' has no drive entry for key '{key}'.",
"message": f"{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]:
"""Slash-menu data source for the workflow Agent node composer (ENG-615)."""

View File

@ -191,6 +191,8 @@ class ComposerConfigValidator:
}
)
continue
if mention.kind in {MentionKind.SKILL, MentionKind.FILE}:
continue
if resolved is None:
warnings.append(
{

View File

@ -4,13 +4,14 @@ Slash-menu insertions are stored inline in the plain-string prompt as tokens:
[§<kind>:<id>[:<label>]§]
``kind`` is a fixed lowercase word; ``id`` points at an item in the Agent config
lists (mentions are pointers — the entity itself lives in ``skills_files`` /
``tools`` / ``knowledge.datasets`` / ``human.contacts`` /
``previous_node_output_refs`` / ``declared_outputs``); ``label`` is an optional
plain-text fallback only (the backend always re-resolves by id, so renames never
break references). A single ``:`` separates all three fields; ``label`` is the
trailing remainder and may itself contain ``:``.
``kind`` is a fixed lowercase word; ``id`` points at an item in the Agent
runtime context. For prompt-owned entities that means Agent Soul lists such as
``tools`` / ``knowledge.datasets`` / ``human.contacts`` and workflow job lists
such as ``previous_node_output_refs`` / ``declared_outputs``. For drive-backed
``skill`` / ``file`` mentions the field stores a URL-encoded drive key and is
resolved against ``agent_drive_files`` at runtime. ``label`` is an optional
plain-text fallback only. A single ``:`` separates all three fields; ``label``
is the trailing remainder and may itself contain ``:``.
The ``[§…§]`` wrapper uses the section sign ``§`` (U+00A7), which never appears
in Dify template syntax (``{{var}}`` / ``{{#a.b#}}``) nor in normal prompt text,
@ -55,7 +56,11 @@ MENTION_PATTERN = re.compile(
_RESIDUAL_MENTION_PATTERN = re.compile(r"\[§([A-Za-z_][A-Za-z0-9_]*:[^§]*?)§\]")
MAX_MENTIONS_PER_PROMPT = 200
MAX_MENTION_FIELD_LENGTH = 255
# Drive keys are validated up to 512 Unicode code points before URL encoding.
# Worst case, one code point becomes 4 UTF-8 bytes and each byte becomes a
# 3-character ``%XX`` escape, so a valid encoded drive key can reach 6144 chars.
MAX_MENTION_REF_ID_LENGTH = 6144
MAX_MENTION_LABEL_LENGTH = 255
# Reserved ``tool`` mention id suffix: ``<provider>/*`` means "every tool of this
# provider" (a provider hosts many tools, like an MCP server). Single tools use
@ -102,7 +107,7 @@ def parse_prompt_mentions(prompt: str) -> list[PromptMention]:
for match in MENTION_PATTERN.finditer(prompt or ""):
ref_id = match.group(2)
label = match.group(3)
if len(ref_id) > MAX_MENTION_FIELD_LENGTH or (label is not None and len(label) > MAX_MENTION_FIELD_LENGTH):
if len(ref_id) > MAX_MENTION_REF_ID_LENGTH or (label is not None and len(label) > MAX_MENTION_LABEL_LENGTH):
continue
mentions.append(
PromptMention(
@ -127,8 +132,8 @@ def expand_prompt_mentions(prompt: str, resolver: MentionResolver) -> str:
def _replace(match: re.Match[str]) -> str:
ref_id = match.group(2)
label = match.group(3) or None
fallback = (label or ref_id)[:MAX_MENTION_FIELD_LENGTH]
if len(ref_id) > MAX_MENTION_FIELD_LENGTH or (label is not None and len(label) > MAX_MENTION_FIELD_LENGTH):
fallback = (label or ref_id)[:MAX_MENTION_LABEL_LENGTH]
if len(ref_id) > MAX_MENTION_REF_ID_LENGTH or (label is not None and len(label) > MAX_MENTION_LABEL_LENGTH):
return fallback
mention = PromptMention(
kind=MentionKind(match.group(1)),
@ -141,7 +146,7 @@ def expand_prompt_mentions(prompt: str, resolver: MentionResolver) -> str:
resolved = resolver(mention)
if resolved is None or not resolved.strip():
return fallback
return resolved[:MAX_MENTION_FIELD_LENGTH]
return resolved[:MAX_MENTION_LABEL_LENGTH]
return scrub_mention_markers(MENTION_PATTERN.sub(_replace, prompt))
@ -163,27 +168,19 @@ def scrub_mention_markers(text: str) -> str:
# inner is ``kind:id[:label]``; prefer the label, else the id.
parts = match.group(1).split(":", 2)
if len(parts) >= 3 and parts[2].strip():
return parts[2].strip()[:MAX_MENTION_FIELD_LENGTH]
return parts[2].strip()[:MAX_MENTION_LABEL_LENGTH]
if len(parts) >= 2 and parts[1].strip():
return parts[1].strip()[:MAX_MENTION_FIELD_LENGTH]
return match.group(1)[:MAX_MENTION_FIELD_LENGTH]
return parts[1].strip()[:MAX_MENTION_LABEL_LENGTH]
return match.group(1)[:MAX_MENTION_LABEL_LENGTH]
return _RESIDUAL_MENTION_PATTERN.sub(_degrade, text)
def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
"""Resolve soul-surface mentions to canonical display names from the soul config."""
"""Resolve non-drive soul-surface mentions to canonical display names."""
def _resolve(mention: PromptMention) -> str | None:
match mention.kind:
case MentionKind.SKILL:
for skill in agent_soul.skills_files.skills:
if mention.ref_id in (skill.id, skill.name):
return skill.name or skill.id
case MentionKind.FILE:
for file in agent_soul.skills_files.files:
if mention.ref_id in (file.id, file.name):
return file.name or file.id
case MentionKind.TOOL:
for tool in agent_soul.tools.dify_tools:
prefixes = {prefix for prefix in (tool.provider, tool.provider_id, tool.plugin_id) if prefix}
@ -273,7 +270,8 @@ def _selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> tuple[str, str] |
__all__ = [
"ALL_PROVIDER_TOOLS_SUFFIX",
"MAX_MENTIONS_PER_PROMPT",
"MAX_MENTION_FIELD_LENGTH",
"MAX_MENTION_LABEL_LENGTH",
"MAX_MENTION_REF_ID_LENGTH",
"MENTION_PATTERN",
"NODE_JOB_PROMPT_ALLOWED_KINDS",
"SOUL_PROMPT_ALLOWED_KINDS",

View File

@ -4,11 +4,12 @@ A Skill is a ``.zip`` / ``.skill`` archive that must contain a ``SKILL.md`` entr
file (Anthropic Skills convention: YAML frontmatter with ``name`` + ``description``,
followed by markdown instructions). This service validates the archive (extension,
size, zip integrity, zip-slip safety, SKILL.md presence/encoding/fields) and
extracts a manifest the API can bind to an Agent config version's skill list.
extracts a manifest consumed by drive standardization.
It does NOT execute or load the skill — the agent backend owns execution. It also
does not (here) standardize the package into the agent drive; that is ENG-594 (S6),
which consumes the manifest produced here.
does not persist anything into Agent Soul or bind anything to config versions;
``SkillStandardizeService`` consumes the manifest and commits the canonical drive
rows instead.
"""
from __future__ import annotations
@ -22,8 +23,6 @@ import zipfile
import yaml
from pydantic import BaseModel
from models.agent_config_entities import AgentSkillRefConfig
# Bounds — generous but finite so a hostile upload can't exhaust memory/disk.
_MAX_ARCHIVE_BYTES = 50 * 1024 * 1024
_MAX_UNCOMPRESSED_BYTES = 200 * 1024 * 1024
@ -58,22 +57,6 @@ class SkillManifest(BaseModel):
size: int # total uncompressed bytes
hash: str # sha256 of the archive bytes
def to_skill_ref(self, *, file_id: str, path: str | None = None) -> AgentSkillRefConfig:
"""Build a config skill ref. ``path`` is the stable drive path (set by S6)."""
return AgentSkillRefConfig.model_validate(
{
"id": self.hash,
"name": self.name,
"description": self.description,
"file_id": file_id,
"path": path,
"size": self.size,
"hash": self.hash,
"entry_path": self.entry_path,
"manifest_files": self.files,
}
)
class SkillPackageService:
"""Validate Skill archives and extract their manifest."""

View File

@ -9,10 +9,11 @@ to the agent drive (Agent Files §5.4 / §4):
Both are stored as ``ToolFile`` records and bound via ``AgentDriveService.commit``
with ``value_owned_by_drive=True`` (the drive owns their lifecycle). The returned
skill ref records the stable drive paths + file ids (not just the raw upload id),
so the Composer can reload the bound skill list. The console ``/skills/upload``
endpoints delegate to this service so "upload" now always means drive-backed skill
normalization.
payload is the slim drive-derived skill DTO the UI needs to work with the drive
catalog — ``name``, ``description``, ``path``, ``skill_md_key``, and
``archive_key`` — plus the extracted manifest for upload feedback. The console
``/skills/upload`` endpoints delegate to this service so "upload" now always means
drive-backed skill normalization rather than Agent Soul binding.
"""
from __future__ import annotations
@ -21,9 +22,8 @@ import re
from typing import Any
from core.tools.tool_file_manager import ToolFileManager
from models.agent_config_entities import AgentSkillRefConfig
from services.agent.skill_package_service import SkillPackageService
from services.agent_drive_service import AgentDriveService, DriveCommitItem, DriveFileRef
from services.agent_drive_service import AgentDriveService, DriveCommitItem, DriveFileRef, DriveSkillMetadata
_FULL_ARCHIVE_NAME = ".DIFY-SKILL-FULL.zip"
_SKILL_MD_NAME = "SKILL.md"
@ -91,6 +91,8 @@ class SkillStandardizeService:
key=skill_md_key,
file_ref=DriveFileRef(kind="tool_file", id=md_tool_file.id),
value_owned_by_drive=True,
is_skill=True,
skill_metadata=DriveSkillMetadata(name=manifest.name, description=manifest.description),
),
DriveCommitItem(
key=archive_key,
@ -100,26 +102,20 @@ class SkillStandardizeService:
],
)
skill_ref = AgentSkillRefConfig.model_validate(
{
"id": manifest.hash,
"name": manifest.name,
"description": manifest.description,
"file_id": archive_tool_file.id,
"path": slug,
"size": manifest.size,
"hash": manifest.hash,
"entry_path": skill_md_key,
"skill_md_file_id": md_tool_file.id,
"skill_md_key": skill_md_key,
"full_archive_file_id": archive_tool_file.id,
"full_archive_key": archive_key,
# ENG-371: zip member listing — strong signals (scripts/*.sh) for infer-tools.
"manifest_files": manifest.files,
}
drive_skill = next(
skill
for skill in self._drive.list_skills(tenant_id=tenant_id, agent_id=agent_id)
if skill["skill_md_key"] == skill_md_key
)
return {
"skill": skill_ref.model_dump(exclude_none=True),
"skill": {
"name": drive_skill["name"],
"description": drive_skill["description"],
"path": drive_skill["path"],
"skill_md_key": drive_skill["skill_md_key"],
"archive_key": drive_skill["archive_key"],
},
"manifest": manifest.model_dump(),
}

View File

@ -19,15 +19,11 @@ 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__)
@ -97,12 +93,8 @@ class SkillToolInferenceService:
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:
@ -138,37 +130,6 @@ class SkillToolInferenceService:
)
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:

View File

@ -1,4 +1,4 @@
"""Agent 网盘 (agent drive) service — list/manifest + commit with lifecycle (ENG-591).
"""Agent 网盘 (agent drive) service — manifest/catalog + commit lifecycle.
The agent drive is a per-agent path-like KV index over existing UploadFile /
ToolFile records (see ``AgentDriveFile``). This service is the control plane:
@ -8,27 +8,31 @@ ToolFile records (see ``AgentDriveFile``). This service is the control plane:
``FileAccessScope`` (Agent Files §3.1.2). We reuse the standard
``file_factory.build_from_mapping`` + ``resolve_file_url`` rebuild, which always
filters by ``tenant_id`` in the builders, so omitting the scope is safe.
* ``commit`` binds a batch of existing file refs to keys. Source ToolFiles must
* ``commit`` is the single mutation entry point for writes and removals.
``file_ref=None`` removes an exact key idempotently; otherwise the service
binds the referenced UploadFile/ToolFile to the key. Source ToolFiles must
belong to the current run user. Overwriting a key whose previous value is
``value_owned_by_drive`` physically cleans the old value (storage + record),
unless another drive entry still references it. Re-committing the same
``key -> file_ref`` is idempotent.
``key -> file_ref`` is idempotent and still refreshes skill metadata.
"""
from __future__ import annotations
import json
import logging
import re
import urllib.parse
from typing import TypedDict
from typing import Any, Literal
from urllib.parse import unquote
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict, field_validator
from sqlalchemy import func, select
from sqlalchemy.exc import DataError, SQLAlchemyError
from sqlalchemy.orm import Session
from core.app.file_access.controller import DatabaseFileAccessController
from core.app.workflow.file_runtime import DifyWorkflowFileRuntime
from core.db.session_factory import session_factory
from extensions.ext_storage import storage
from factories import file_factory
@ -41,6 +45,8 @@ logger = logging.getLogger(__name__)
_MAX_KEY_LENGTH = 512
_DRIVE_REF_PREFIX = "agent-"
_SKILL_MD_SUFFIX = "/SKILL.md"
_SKILL_ARCHIVE_NAME = ".DIFY-SKILL-FULL.zip"
class AgentDriveError(Exception):
@ -58,16 +64,57 @@ class AgentDriveError(Exception):
class DriveFileRef(BaseModel):
model_config = ConfigDict(extra="forbid")
kind: Literal["upload_file", "tool_file"]
id: str
class DriveSkillMetadata(BaseModel):
"""Validated skill catalog metadata stored as a JSON string on the drive row."""
model_config = ConfigDict(extra="forbid")
name: str
description: str = ""
@field_validator("name")
@classmethod
def _validate_name(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("skill metadata name must not be blank")
return normalized
class DriveCommitItem(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str
file_ref: DriveFileRef
file_ref: DriveFileRef | None = None
# Drive-owned values may be physically cleaned on overwrite/removal; refs to
# files shared with other business records should set this False.
value_owned_by_drive: bool = True
is_skill: bool = False
skill_metadata: DriveSkillMetadata | None = None
class AgentDriveSkillInfo(TypedDict):
path: str
skill_md_key: str
archive_key: str | None
name: str
description: str
size: int | None
mime_type: str | None
hash: str | None
created_at: int | None
def decode_drive_mention_ref(ref_id: str) -> str:
"""Decode the prompt token's URL-encoded drive-key field."""
return unquote(ref_id or "")
def parse_agent_drive_ref(drive_ref: str) -> str:
@ -132,6 +179,8 @@ class AgentDriveService:
"mime_type": row.mime_type,
"file_kind": row.file_kind.value,
"file_id": row.file_id,
"is_skill": row.is_skill,
"skill_metadata": row.skill_metadata,
"created_at": int(row.created_at.timestamp()) if row.created_at else None,
}
if include_download_url:
@ -171,51 +220,50 @@ 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).
def list_skills(self, *, tenant_id: str, agent_id: str) -> list[AgentDriveSkillInfo]:
"""Return the drive-backed skill catalog derived from canonical ``SKILL.md`` rows."""
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,
skill_rows = list(
session.scalars(
select(AgentDriveFile)
.where(
AgentDriveFile.tenant_id == tenant_id,
AgentDriveFile.agent_id == agent_id,
AgentDriveFile.is_skill.is_(True),
)
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
.order_by(AgentDriveFile.key)
)
)
archive_keys = {
key
for key in session.scalars(
select(AgentDriveFile.key).where(
AgentDriveFile.tenant_id == tenant_id,
AgentDriveFile.agent_id == agent_id,
AgentDriveFile.key.in_([self._skill_archive_key(row.key) for row in skill_rows]),
)
)
}
skills: list[AgentDriveSkillInfo] = []
for row in skill_rows:
metadata = self._parse_skill_metadata(row.key, row.skill_metadata)
skills.append(
{
"path": self._skill_path_from_key(row.key),
"skill_md_key": row.key,
"archive_key": self._skill_archive_key(row.key) if self._skill_archive_key(row.key) in archive_keys else None,
"name": metadata.name,
"description": metadata.description,
"size": row.size,
"mime_type": row.mime_type,
"hash": row.hash,
"created_at": int(row.created_at.timestamp()) if row.created_at else None,
}
)
return skills
def _commit_one(
self,
@ -228,9 +276,19 @@ class AgentDriveService:
pending_storage_deletes: list[str],
) -> dict[str, Any]:
key = normalize_drive_key(item.key)
if item.file_ref is None:
return self._remove_one(
session,
tenant_id=tenant_id,
agent_id=agent_id,
key=key,
pending_storage_deletes=pending_storage_deletes,
)
skill_metadata = self._validate_skill_commit_fields(key=key, item=item)
file_kind = AgentDriveFileKind(item.file_ref.kind)
file_id = item.file_ref.id
size, mime_type = self._validate_source(
size, mime_type, file_hash = self._validate_source(
session, tenant_id=tenant_id, user_id=user_id, file_kind=file_kind, file_id=file_id
)
@ -245,6 +303,11 @@ class AgentDriveService:
# Idempotent re-commit of the same value: leave it (do not clean).
if existing.file_kind == file_kind and existing.file_id == file_id:
existing.value_owned_by_drive = item.value_owned_by_drive
existing.is_skill = item.is_skill
existing.skill_metadata = skill_metadata
existing.size = size
existing.mime_type = mime_type
existing.hash = file_hash
return self._row_dict(existing)
# Overwrite: clean the previous drive-owned value if no longer referenced.
if existing.value_owned_by_drive:
@ -259,8 +322,11 @@ class AgentDriveService:
existing.file_kind = file_kind
existing.file_id = file_id
existing.value_owned_by_drive = item.value_owned_by_drive
existing.is_skill = item.is_skill
existing.skill_metadata = skill_metadata
existing.size = size
existing.mime_type = mime_type
existing.hash = file_hash
return self._row_dict(existing)
row = AgentDriveFile(
@ -271,13 +337,55 @@ class AgentDriveService:
file_kind=file_kind,
file_id=file_id,
value_owned_by_drive=item.value_owned_by_drive,
is_skill=item.is_skill,
skill_metadata=skill_metadata,
size=size,
hash=file_hash,
mime_type=mime_type,
created_by=user_id,
)
session.add(row)
return self._row_dict(row)
def _remove_one(
self,
session: Session,
*,
tenant_id: str,
agent_id: str,
key: str,
pending_storage_deletes: list[str],
) -> dict[str, Any]:
existing = session.scalar(
select(AgentDriveFile).where(
AgentDriveFile.tenant_id == tenant_id,
AgentDriveFile.agent_id == agent_id,
AgentDriveFile.key == key,
)
)
if existing is None:
return {"key": key, "removed": True, "noop": True}
result = {
"key": key,
"removed": True,
"file_kind": existing.file_kind.value,
"file_id": existing.file_id,
"value_owned_by_drive": existing.value_owned_by_drive,
"is_skill": existing.is_skill,
"skill_metadata": existing.skill_metadata,
}
if existing.value_owned_by_drive:
self._cleanup_value(
session,
tenant_id=tenant_id,
file_kind=existing.file_kind,
file_id=existing.file_id,
exclude_row_id=existing.id,
pending_storage_deletes=pending_storage_deletes,
)
session.delete(existing)
return result
@staticmethod
def _row_dict(row: AgentDriveFile) -> dict[str, Any]:
return {
@ -287,8 +395,67 @@ class AgentDriveService:
"size": row.size,
"mime_type": row.mime_type,
"value_owned_by_drive": row.value_owned_by_drive,
"is_skill": row.is_skill,
"skill_metadata": row.skill_metadata,
}
@staticmethod
def _skill_path_from_key(key: str) -> str:
if not key.endswith(_SKILL_MD_SUFFIX):
raise AgentDriveError(
"invalid_skill_key",
"skill rows must use the canonical '<path>/SKILL.md' key",
status_code=500,
)
path = key[: -len(_SKILL_MD_SUFFIX)]
if not path:
raise AgentDriveError(
"invalid_skill_key",
"skill rows must use the canonical '<path>/SKILL.md' key",
status_code=500,
)
return path
@classmethod
def _skill_archive_key(cls, key: str) -> str:
return f"{cls._skill_path_from_key(key)}/{_SKILL_ARCHIVE_NAME}"
@classmethod
def _validate_skill_commit_fields(cls, *, key: str, item: DriveCommitItem) -> str | None:
if not item.is_skill:
if item.skill_metadata is not None:
raise AgentDriveError(
"invalid_skill_metadata",
"skill metadata is only allowed for canonical skill rows",
status_code=400,
)
return None
cls._skill_path_from_key(key)
if item.skill_metadata is None:
raise AgentDriveError(
"invalid_skill_metadata",
"skill metadata is required for canonical skill rows",
status_code=400,
)
return json.dumps(item.skill_metadata.model_dump(mode="json"), separators=(",", ":"), sort_keys=True)
@staticmethod
def _parse_skill_metadata(key: str, raw_metadata: str | None) -> DriveSkillMetadata:
if raw_metadata is None:
raise AgentDriveError(
"invalid_skill_metadata",
f"skill row '{key}' is missing required metadata",
status_code=500,
)
try:
return DriveSkillMetadata.model_validate(json.loads(raw_metadata))
except (ValueError, TypeError) as exc:
raise AgentDriveError(
"invalid_skill_metadata",
f"skill row '{key}' has invalid stored metadata",
status_code=500,
) from exc
@staticmethod
def _assert_agent_belongs_to_tenant(session: Session, *, tenant_id: str, agent_id: str) -> None:
try:
@ -309,7 +476,7 @@ class AgentDriveService:
user_id: str,
file_kind: AgentDriveFileKind,
file_id: str,
) -> tuple[int | None, str | None]:
) -> tuple[int | None, str | None, str | None]:
"""Verify the source file exists for the tenant (and user, for ToolFile).
Malformed ids (e.g. a non-UUID hitting a UUID column) are treated as a
@ -328,7 +495,7 @@ class AgentDriveService:
raise AgentDriveError(
"source_not_found", "source ToolFile not found for this tenant/user", status_code=404
)
return tool_file.size, tool_file.mimetype
return tool_file.size, tool_file.mimetype, None
upload_file = session.scalar(
select(UploadFile).where(UploadFile.id == file_id, UploadFile.tenant_id == tenant_id)
)
@ -337,7 +504,7 @@ class AgentDriveService:
raise AgentDriveError("source_not_found", "source file ref is invalid", status_code=404) from exc
if upload_file is None:
raise AgentDriveError("source_not_found", "source UploadFile not found for this tenant", status_code=404)
return upload_file.size, upload_file.mime_type
return upload_file.size, upload_file.mime_type, upload_file.hash
def _cleanup_value(
self,
@ -402,6 +569,11 @@ class AgentDriveService:
else:
mapping = {"transfer_method": "local_file", "upload_file_id": file_id}
controller = DatabaseFileAccessController()
# Keep workflow runtime wiring lazy: importing this service is part of
# Agent v2 node bootstrap, while ``core.app.workflow`` re-exports the
# node factory. A module-level import here would close that cycle.
from core.app.workflow.file_runtime import DifyWorkflowFileRuntime
runtime = DifyWorkflowFileRuntime(file_access_controller=controller)
try:
if file_kind == AgentDriveFileKind.UPLOAD_FILE:
@ -506,9 +678,12 @@ class AgentDriveService:
__all__ = [
"AgentDriveError",
"AgentDriveSkillInfo",
"AgentDriveService",
"DriveCommitItem",
"DriveFileRef",
"DriveSkillMetadata",
"decode_drive_mention_ref",
"normalize_drive_key",
"parse_agent_drive_ref",
]

View File

@ -18,6 +18,8 @@ from controllers.console.app.agent_drive_inspector import (
AgentDriveDownloadByAgentApi,
AgentDriveListApi,
AgentDriveListByAgentApi,
AgentDriveSkillListApi,
AgentDriveSkillListByAgentApi,
AgentDrivePreviewApi,
AgentDrivePreviewByAgentApi,
)
@ -82,6 +84,39 @@ def test_list_by_agent_filters_value_pointers_out_of_console_payload():
assert drive.return_value.manifest.call_args.kwargs["agent_id"] == "agent-1"
def test_skill_list_by_agent_calls_list_skills_and_returns_catalog_shape():
raw = _raw(AgentDriveSkillListByAgentApi.get)
with app.test_request_context("/"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.list_skills.return_value = [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
"name": "Tender Analyzer",
"description": "Parses RFPs.",
}
]
body = raw(AgentDriveSkillListByAgentApi(), "tenant-1", "agent-1")
assert body == {
"items": [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
"name": "Tender Analyzer",
"description": "Parses RFPs.",
}
]
}
resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
assert drive.return_value.list_skills.call_args.kwargs == {"tenant_id": "tenant-1", "agent_id": "agent-1"}
def test_list_resolves_workflow_node_binding_agent():
raw = _raw(AgentDriveListApi.get)
with app.test_request_context("/?node_id=agent-node-1"):
@ -97,6 +132,21 @@ def test_list_resolves_workflow_node_binding_agent():
assert composer.resolve_workflow_node_agent_id.call_args.kwargs["node_id"] == "agent-node-1"
def test_skill_list_resolves_workflow_node_binding_agent():
raw = _raw(AgentDriveSkillListApi.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.list_skills.return_value = []
raw(AgentDriveSkillListApi(), _APP)
assert drive.return_value.list_skills.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)

View File

@ -152,27 +152,20 @@ def test_files_commit_validates_upload_and_returns_drive_ref():
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_by_agent_commit_uses_agent_route_and_ignores_node_id():
@ -184,20 +177,16 @@ def test_files_by_agent_commit_uses_agent_route_and_ignores_node_id():
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.pdf", "size": 5, "mime_type": "application/pdf"}
]
composer.add_drive_file_ref.return_value = "ver-2"
body, status = raw(AgentDriveFilesByAgentApi(), "tenant-1", _USER, "agent-1")
assert status == 201
assert body["config_version_id"] == "ver-2"
resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
assert composer.add_drive_file_ref.call_args.kwargs["node_id"] is None
def test_files_commit_404_when_upload_not_in_tenant():
@ -234,13 +223,10 @@ def test_files_commit_resolves_workflow_node_agent():
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():
@ -250,17 +236,13 @@ def test_files_delete_updates_soul_then_drive():
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"]
drive.return_value.commit.side_effect = lambda **kw: calls.append("drive") or [{"key": "files/sample.pdf", "removed": True}]
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"
assert calls == ["drive"]
assert body == {"result": "success", "removed_keys": ["files/sample.pdf"]}
def test_files_by_agent_delete_uses_agent_route_and_ignores_node_id():
@ -268,16 +250,13 @@ def test_files_by_agent_delete_uses_agent_route_and_ignores_node_id():
with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentDriveService") as drive,
):
composer.remove_drive_refs.return_value = "ver-2"
drive.return_value.delete.return_value = ["files/sample.pdf"]
drive.return_value.commit.return_value = [{"key": "files/sample.pdf", "removed": True}]
body = raw(AgentDriveFilesByAgentApi(), "tenant-1", _USER, "agent-1")
assert body["config_version_id"] == "ver-2"
assert body == {"result": "success", "removed_keys": ["files/sample.pdf"]}
resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
assert composer.remove_drive_refs.call_args.kwargs["node_id"] is None
def test_files_delete_resolves_workflow_node_agent():
@ -290,13 +269,11 @@ def test_files_delete_resolves_workflow_node_agent():
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"]
drive.return_value.commit.return_value = [{"key": "files/sample.pdf", "removed": True}]
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"
assert body == {"result": "success", "removed_keys": ["files/sample.pdf"]}
assert drive.return_value.commit.call_args.kwargs["agent_id"] == "wf-agent-1"
def test_files_delete_survives_drive_failure():
@ -305,14 +282,15 @@ def test_files_delete_survives_drive_failure():
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"}
drive.return_value.commit.side_effect = RuntimeError("storage down")
try:
raw(AgentDriveFilesApi(), _USER, _APP)
except RuntimeError as exc:
assert str(exc) == "storage down"
else:
raise AssertionError("expected RuntimeError")
def test_skill_delete_uses_slug_prefix_and_is_idempotent():
@ -321,17 +299,18 @@ def test_skill_delete_uses_slug_prefix_and_is_idempotent():
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 = []
drive.return_value.commit.return_value = [
{"key": "tender-analyzer/SKILL.md", "removed": True},
{"key": "tender-analyzer/.DIFY-SKILL-FULL.zip", "removed": True},
]
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"
assert body == {
"result": "success",
"removed_keys": ["tender-analyzer/SKILL.md", "tender-analyzer/.DIFY-SKILL-FULL.zip"],
}
def test_skill_delete_by_agent_uses_agent_route():
@ -339,16 +318,13 @@ def test_skill_delete_by_agent_uses_agent_route():
with _json_ctx(method="DELETE", query_string="node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentDriveService") as drive,
):
composer.remove_drive_refs.return_value = "ver-2"
drive.return_value.delete.return_value = ["tender-analyzer/SKILL.md"]
drive.return_value.commit.return_value = [{"key": "tender-analyzer/SKILL.md", "removed": True}]
body = raw(AgentSkillByAgentApi(), "tenant-1", _USER, "agent-1", "tender-analyzer")
assert body["config_version_id"] == "ver-2"
assert body == {"result": "success", "removed_keys": ["tender-analyzer/SKILL.md"]}
resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
assert composer.remove_drive_refs.call_args.kwargs["node_id"] is None
def test_skill_delete_rejects_path_like_slug():

View File

@ -13,7 +13,7 @@ from unittest.mock import patch
import pytest
from flask import Flask
from controllers.inner_api.plugin.agent_drive import AgentDriveCommitApi, AgentDriveManifestApi
from controllers.inner_api.plugin.agent_drive import AgentDriveCommitApi, AgentDriveManifestApi, AgentDriveSkillsApi
from services.agent_drive_service import AgentDriveError
_MOD = "controllers.inner_api.plugin.agent_drive"
@ -52,6 +52,41 @@ def test_manifest_bad_drive_ref_is_400():
assert body["code"] == "invalid_drive_ref"
def test_skills_requires_tenant_id_and_returns_items():
raw = _raw(AgentDriveSkillsApi.get)
with app.test_request_context("/"):
body, status = raw(AgentDriveSkillsApi(), "agent-agent-1")
assert status == 400
assert body["code"] == "missing_tenant_id"
with app.test_request_context("/?tenant_id=tenant-1"):
with patch(f"{_MOD}.AgentDriveService") as svc:
svc.return_value.list_skills.return_value = [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": None,
"name": "Tender Analyzer",
"description": "Parses RFPs.",
}
]
result = raw(AgentDriveSkillsApi(), "agent-agent-1")
assert result == {
"items": [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": None,
"name": "Tender Analyzer",
"description": "Parses RFPs.",
}
]
}
assert svc.return_value.list_skills.call_args.kwargs == {"tenant_id": "tenant-1", "agent_id": "agent-1"}
def test_commit_parses_body_and_returns_items():
raw = _raw(AgentDriveCommitApi.post)
payload = {
@ -90,6 +125,6 @@ def test_commit_maps_service_error():
assert body["code"] == "source_not_found"
@pytest.mark.parametrize("api_cls", [AgentDriveManifestApi, AgentDriveCommitApi])
@pytest.mark.parametrize("api_cls", [AgentDriveManifestApi, AgentDriveSkillsApi, AgentDriveCommitApi])
def test_endpoints_have_handlers(api_cls):
assert callable(getattr(api_cls(), "get", None) or getattr(api_cls(), "post", None))

View File

@ -226,19 +226,8 @@ class TestAgentAppRuntimeRequestBuilder:
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",
}
)
]
soul.prompt.system_prompt = "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§]"
return soul
@ -247,6 +236,28 @@ class TestAgentAppDriveLayer:
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": None,
"name": "Tender Analyzer",
"description": "Parses RFPs.",
"size": 1,
"mime_type": "text/markdown",
"hash": None,
"created_at": 1,
}
],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
{"key": "tender-analyzer/SKILL.md", "is_skill": True}
],
)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
@ -256,8 +267,10 @@ class TestAgentAppDriveLayer:
drive = next(layer for layer in result.request.composition.layers if layer.name == "drive")
assert drive.type == "dify.drive"
assert drive.deps == {"execution_context": "execution_context"}
assert drive.config.drive_ref == "agent-agent-1"
assert [skill.skill_md_key for skill in drive.config.skills] == ["tender-analyzer/SKILL.md"]
assert drive.config.mentioned_skill_keys == ["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
@ -269,3 +282,148 @@ class TestAgentAppDriveLayer:
)
result = builder.build(_ctx(_soul_with_model_and_skill()))
assert all(layer.name != "drive" for layer in result.request.composition.layers)
def test_agent_app_runtime_expands_skill_and_file_mentions_in_agent_soul_prompt(
self,
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": None,
"name": "Tender Analyzer",
"description": "Parses RFPs.",
"size": 1,
"mime_type": "text/markdown",
"hash": None,
"created_at": 1,
}
],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
{"key": "tender-analyzer/SKILL.md", "is_skill": True},
{"key": "files/sample.pdf", "is_skill": False},
],
)
soul = _soul_with_model()
soul.prompt.system_prompt = "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]."
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(soul))
prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
assert prompt_layer.config.prefix == "Use Tender Analyzer and sample.pdf."
assert "" not in prompt_layer.config.prefix
def test_agent_app_runtime_missing_drive_mentions_fall_back_to_label_then_decoded_key(
self,
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
)
soul = _soul_with_model()
soul.prompt.system_prompt = (
"Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], "
"and [§file:files%2Fmissing.txt§]."
)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(soul))
prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
assert prompt_layer.config.prefix == "Use Ghost Skill, Ghost File, and files/missing.txt."
assert "" not in prompt_layer.config.prefix
def test_agent_app_runtime_expands_drive_mentions_in_agent_soul_prompt(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": None,
"name": "Tender Analyzer",
"description": "Parses RFPs.",
"size": 1,
"mime_type": "text/markdown",
"hash": None,
"created_at": 1,
}
],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
{"key": "tender-analyzer/SKILL.md", "is_skill": True},
{"key": "files/sample.pdf", "is_skill": False},
],
)
soul = _soul_with_model()
soul.prompt.system_prompt = "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]"
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(soul))
prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
assert prompt_layer.config.prefix == "Use Tender Analyzer and sample.pdf"
assert "" not in prompt_layer.config.prefix
def test_agent_app_runtime_missing_drive_mentions_fall_back_without_marker_leak(
self,
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
)
soul = _soul_with_model()
soul.prompt.system_prompt = (
"Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], "
"and [§file:files%2Fno-label.txt§]."
)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(soul))
prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
assert prompt_layer.config.prefix == "Use Ghost Skill, Ghost File, and files/no-label.txt."
assert "" not in prompt_layer.config.prefix

View File

@ -834,57 +834,77 @@ def test_mentions_expand_in_soul_and_job_prompts_without_token_leak():
def _soul_with_drive_skill() -> AgentSoulConfig:
return AgentSoulConfig(
prompt={"system_prompt": "You are careful."},
prompt={"system_prompt": "You are careful. Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]."},
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():
def _mock_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
"name": "Tender Analyzer",
"description": "Parses RFPs.",
"size": 123,
"mime_type": "text/markdown",
"hash": "hash-1",
"created_at": 1,
}
],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
{"key": "tender-analyzer/SKILL.md", "is_skill": True},
{"key": "tender-analyzer/.DIFY-SKILL-FULL.zip", "is_skill": False},
{"key": "files/sample.pdf", "is_skill": False},
],
)
def test_build_drive_layer_config_catalogs_drive_skills_and_mentions(monkeypatch: pytest.MonkeyPatch):
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")
_mock_drive_catalog(monkeypatch)
config, warnings = build_drive_layer_config(_soul_with_drive_skill(), tenant_id="tenant-1", 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"]
assert config.mentioned_skill_keys == ["tender-analyzer/SKILL.md"]
assert config.mentioned_file_keys == ["files/sample.pdf"]
assert warnings == []
def test_build_drive_layer_config_skips_when_nothing_configured():
def test_build_drive_layer_config_skips_when_nothing_configured(monkeypatch: pytest.MonkeyPatch):
from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
)
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, [])
assert build_drive_layer_config(soul, tenant_id="tenant-1", 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)
config, warnings = build_drive_layer_config(_soul_with_drive_skill(), tenant_id="tenant-1", agent_id=None)
assert config is None
assert [w["code"] for w in warnings] == ["skill_ref_dangling"]
assert [w["code"] for w in warnings] == ["drive_ref_dangling"]
def test_workflow_run_request_contains_drive_layer_when_flag_enabled(monkeypatch: pytest.MonkeyPatch):
@ -892,6 +912,7 @@ def test_workflow_run_request_contains_drive_layer_when_flag_enabled(monkeypatch
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
_mock_drive_catalog(monkeypatch)
context = _context()
context.snapshot.config_snapshot = _soul_with_drive_skill()
@ -904,21 +925,21 @@ def test_workflow_run_request_contains_drive_layer_when_flag_enabled(monkeypatch
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["deps"] == {"execution_context": "execution_context"}
assert drive["config"]["drive_ref"] == "agent-agent-1"
assert drive["config"]["skills"] == [
{
"path": "tender-analyzer",
"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
assert drive["config"]["mentioned_skill_keys"] == ["tender-analyzer/SKILL.md"]
assert drive["config"]["mentioned_file_keys"] == ["files/sample.pdf"]
warnings = result.metadata["runtime_support"]["unsupported_runtime_warnings"]
assert any(w["code"] == "skill_ref_dangling" for w in warnings)
assert warnings == []
# the drive layer is non-sensitive and must survive into persistable specs
from dify_agent.protocol import extract_runtime_layer_specs
@ -926,6 +947,51 @@ def test_workflow_run_request_contains_drive_layer_when_flag_enabled(monkeypatch
assert any(spec.name == "drive" and spec.type == "dify.drive" for spec in specs)
def test_workflow_runtime_expands_drive_mentions_in_agent_soul_prompt(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
_mock_drive_catalog(monkeypatch)
context = _context()
context.snapshot.config_snapshot = _soul_with_drive_skill()
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
soul_prompt = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
assert soul_prompt.config.prefix == "You are careful. Use Tender Analyzer and sample.pdf."
assert "" not in soul_prompt.config.prefix
def test_workflow_runtime_missing_drive_mentions_fall_back_to_label_then_decoded_key(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
)
context = _context()
context.snapshot.config_snapshot = AgentSoulConfig(
prompt={
"system_prompt": (
"Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], "
"and [§file:files%2Fno-label.txt§]."
)
},
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
)
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
soul_prompt = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
assert soul_prompt.config.prefix == "Use Ghost Skill, Ghost File, and files/no-label.txt."
assert "" not in soul_prompt.config.prefix
def test_workflow_run_request_has_no_drive_layer_when_flag_disabled():
context = _context()
context.snapshot.config_snapshot = _soul_with_drive_skill()
@ -934,20 +1000,20 @@ def test_workflow_run_request_has_no_drive_layer_when_flag_disabled():
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)
assert result.metadata["runtime_support"]["unsupported_runtime_warnings"] == []
def test_build_drive_layer_config_all_refs_dangling_yields_no_config():
def test_build_drive_layer_config_missing_mentions_warn_but_keep_skill_catalog(monkeypatch: pytest.MonkeyPatch):
from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config
_mock_drive_catalog(monkeypatch)
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"}]},
prompt={"system_prompt": "Use [§skill:ghost%2FSKILL.md:Ghost§]"},
)
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"]
config, warnings = build_drive_layer_config(soul, tenant_id="tenant-1", agent_id="agent-1")
assert config is not None
assert [w["code"] for w in warnings] == ["mention_target_missing"]
# ── ENG-635: ask_human layer gating + feature manifest ───────────────────────

View File

@ -0,0 +1,123 @@
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
_MIGRATION_PATH = Path(
"/home/beautyyu/Development/worktrees/dify-2515-refactor-skills-preview/"
"api/migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py"
)
def _load_migration_module():
spec = importlib.util.spec_from_file_location("agent_drive_skill_metadata_refactor", _MIGRATION_PATH)
if spec is None or spec.loader is None:
raise RuntimeError("failed to load migration module")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _create_pre_upgrade_schema(engine: sa.Engine) -> None:
metadata = sa.MetaData()
sa.Table(
"agent_drive_files",
metadata,
sa.Column("tenant_id", sa.String(36), nullable=False),
sa.Column("agent_id", sa.String(36), nullable=False),
sa.Column("key", sa.String(512), nullable=False),
sa.Column("file_kind", sa.String(32), nullable=False),
sa.Column("file_id", sa.String(36), nullable=False),
sa.Column("value_owned_by_drive", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("size", sa.BigInteger(), nullable=True),
sa.Column("hash", sa.String(255), nullable=True),
sa.Column("mime_type", sa.String(255), nullable=True),
sa.Column("created_by", sa.String(36), nullable=True),
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.UniqueConstraint("tenant_id", "agent_id", "key", name="agent_drive_file_scope_key_unique"),
)
sa.Table(
"agent_config_snapshots",
metadata,
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("config_snapshot", sa.Text(), nullable=False),
)
metadata.create_all(engine)
def _run_migration_step(module: object, engine: sa.Engine, step_name: str) -> None:
with engine.begin() as connection:
context = MigrationContext.configure(connection)
operations = Operations(context)
original_op = module.op
module.op = operations
try:
getattr(module, step_name)()
finally:
module.op = original_op
def test_upgrade_adds_skill_columns_and_index_and_strips_snapshot_data() -> None:
engine = sa.create_engine("sqlite:///:memory:")
_create_pre_upgrade_schema(engine)
snapshot = {
"prompt": {"system_prompt": "Use [§skill:legacy:Legacy§]"},
"skills_files": {"skills": [{"name": "Legacy"}], "files": [{"name": "u.pdf"}]},
}
with engine.begin() as connection:
connection.execute(
sa.text("INSERT INTO agent_config_snapshots (id, config_snapshot) VALUES (:id, :config_snapshot)"),
{"id": "snap-1", "config_snapshot": json.dumps(snapshot)},
)
module = _load_migration_module()
_run_migration_step(module, engine, "upgrade")
inspector = sa.inspect(engine)
columns = {column["name"] for column in inspector.get_columns("agent_drive_files")}
assert {"is_skill", "skill_metadata"}.issubset(columns)
indexes = {index["name"] for index in inspector.get_indexes("agent_drive_files")}
assert "agent_drive_files_tenant_agent_is_skill_key_idx" in indexes
with engine.begin() as connection:
stored_snapshot = connection.execute(
sa.text("SELECT config_snapshot FROM agent_config_snapshots WHERE id = :id"),
{"id": "snap-1"},
).scalar_one()
assert "skills_files" not in json.loads(stored_snapshot)
def test_downgrade_drops_skill_columns_and_index_without_reconstructing_legacy_data() -> None:
engine = sa.create_engine("sqlite:///:memory:")
_create_pre_upgrade_schema(engine)
with engine.begin() as connection:
connection.execute(
sa.text("INSERT INTO agent_config_snapshots (id, config_snapshot) VALUES (:id, :config_snapshot)"),
{"id": "snap-1", "config_snapshot": json.dumps({"prompt": {"system_prompt": "hello"}})},
)
module = _load_migration_module()
_run_migration_step(module, engine, "upgrade")
_run_migration_step(module, engine, "downgrade")
inspector = sa.inspect(engine)
columns = {column["name"] for column in inspector.get_columns("agent_drive_files")}
assert "is_skill" not in columns
assert "skill_metadata" not in columns
indexes = {index["name"] for index in inspector.get_indexes("agent_drive_files")}
assert "agent_drive_files_tenant_agent_is_skill_key_idx" not in indexes
with engine.begin() as connection:
stored_snapshot = connection.execute(
sa.text("SELECT config_snapshot FROM agent_config_snapshots WHERE id = :id"),
{"id": "snap-1"},
).scalar_one()
assert "skills_files" not in json.loads(stored_snapshot)

View File

@ -118,10 +118,6 @@ def test_previous_outputs_capped_and_flagged():
def _soul() -> AgentSoulConfig:
return AgentSoulConfig.model_validate(
{
"skills_files": {
"skills": [{"id": "sk-1", "name": "tender-analyzer"}],
"files": [{"id": "f-1", "name": "qna_report.pdf"}],
},
"tools": {
"cli_tools": [
{"id": "ct-1", "name": "ffmpeg"},
@ -144,7 +140,6 @@ def test_soul_candidates_lists_configured_items_only():
)
assert truncated is False
assert [item["kind"] for item in lists["skills_files"]] == ["skill", "file"]
assert [item["name"] for item in lists["cli_tools"]] == ["ffmpeg"]
# the stable mention id flows through so the frontend can mint [§cli_tool:<id>§]
assert [item["id"] for item in lists["cli_tools"]] == ["ct-1"]
@ -158,35 +153,19 @@ def test_soul_candidates_lists_configured_items_only():
assert lists["dify_tools"][0]["id"] == "tavily/tavily_search"
def test_candidates_response_preserves_skill_and_file_candidate_shapes():
def test_candidates_response_omits_legacy_skill_file_candidates():
response = AgentComposerCandidatesResponse.model_validate(
{
"variant": "agent_app",
"allowed_node_job_candidates": {},
"allowed_soul_candidates": {
"skills_files": [
{"kind": "skill", "id": "sk-1", "name": "tender-analyzer", "path": "skills/tender.md"},
{
"kind": "file",
"id": "f-1",
"name": "qna_report.pdf",
"transfer_method": "local_file",
"reference": "upload-1",
"url": "https://files.example/qna_report.pdf",
},
]
"cli_tools": [],
},
"capabilities": {"human_roster_available": False},
}
).model_dump(mode="json")
skill, file = response["allowed_soul_candidates"]["skills_files"]
assert skill["kind"] == "skill"
assert skill["path"] == "skills/tender.md"
assert file["kind"] == "file"
assert file["transfer_method"] == "local_file"
assert file["reference"] == "upload-1"
assert file["url"] == "https://files.example/qna_report.pdf"
assert "skills_files" not in response["allowed_soul_candidates"]
def test_soul_candidates_empty_config_yields_empty_lists():

View File

@ -171,7 +171,6 @@ def test_configured_but_deleted_dataset_surfaces_as_placeholder():
def test_unresolved_non_knowledge_mentions_warn_target_missing():
findings = _findings(_soul_payload("use [§skill:nope:Ghost Skill§] and [§human:missing§]"))
codes = [(w["code"], w["kind"]) for w in findings["warnings"]]
assert ("mention_target_missing", "skill") in codes
assert ("mention_target_missing", "human") in codes
assert findings["knowledge_retrieval_placeholder"] == []

View File

@ -7,11 +7,13 @@ guarantees no mention-shaped marker survives to the model.
from __future__ import annotations
from urllib.parse import quote
import pytest
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
from services.agent.prompt_mentions import (
MAX_MENTION_FIELD_LENGTH,
MAX_MENTION_REF_ID_LENGTH,
NODE_JOB_PROMPT_ALLOWED_KINDS,
SOUL_PROMPT_ALLOWED_KINDS,
MentionKind,
@ -26,11 +28,11 @@ from services.agent.prompt_mentions import (
def test_parse_extracts_kind_id_and_optional_label():
prompt = "Use [§skill:abc-1:tender-analyzer§] then ask [§human:c-1§]."
prompt = "Use [§skill:tender-analyzer%2FSKILL.md:tender-analyzer§] then ask [§human:c-1§]."
mentions = parse_prompt_mentions(prompt)
assert [(m.kind, m.ref_id, m.label) for m in mentions] == [
(MentionKind.SKILL, "abc-1", "tender-analyzer"),
(MentionKind.SKILL, "tender-analyzer%2FSKILL.md", "tender-analyzer"),
(MentionKind.HUMAN, "c-1", None),
]
assert prompt[mentions[0].start : mentions[0].end] == mentions[0].raw
@ -48,10 +50,16 @@ def test_parse_ignores_legacy_template_forms_and_unknown_kinds():
def test_parse_skips_oversized_id_or_label():
long_id = "x" * (MAX_MENTION_FIELD_LENGTH + 1)
long_id = "x" * (MAX_MENTION_REF_ID_LENGTH + 1)
assert parse_prompt_mentions(f"[§skill:{long_id}§]") == []
def test_parse_accepts_long_unicode_encoded_drive_key_within_drive_limit():
encoded_drive_key = quote("" * 512)
mentions = parse_prompt_mentions(f"[§skill:{encoded_drive_key}:Long Skill§]")
assert [(mention.kind, mention.ref_id) for mention in mentions] == [(MentionKind.SKILL, encoded_drive_key)]
# ── expand + scrub ────────────────────────────────────────────────────────────
@ -88,10 +96,6 @@ def test_expand_empty_prompt_is_noop():
def soul() -> AgentSoulConfig:
return AgentSoulConfig.model_validate(
{
"skills_files": {
"skills": [{"id": "sk-1", "name": "tender-analyzer"}],
"files": [{"id": "f-1", "name": "qna_report.pdf"}],
},
"tools": {
"dify_tools": [
{
@ -112,16 +116,15 @@ def soul() -> AgentSoulConfig:
def test_soul_resolver_resolves_each_kind(soul: AgentSoulConfig):
resolver = build_soul_mention_resolver(soul)
prompt = (
"Use [§skill:sk-1§] with [§file:f-1§], search via "
"[§tool:tavily/tavily_search:tavily§], run [§cli_tool:ct-1:ffmpeg§], "
"Use [§tool:tavily/tavily_search:tavily§], run [§cli_tool:ct-1:ffmpeg§], "
"ground in [§knowledge:ds-1§], ask [§human:c-1§]."
)
expanded = expand_prompt_mentions(prompt, resolver)
assert expanded == (
"Use tender-analyzer with qna_report.pdf, search via tavily_search, "
"run ffmpeg, ground in 产品手册, ask EMAIL · David Hayes."
"Use tavily_search, run ffmpeg, "
"ground in 产品手册, ask EMAIL · David Hayes."
)

View File

@ -121,16 +121,3 @@ def test_read_member_bytes_roundtrip_and_errors():
with pytest.raises(SkillPackageError) as bad_zip:
service.read_member_bytes(content=b"not a zip", member_path="SKILL.md")
assert bad_zip.value.code == "invalid_archive"
def test_to_skill_ref_carries_metadata():
manifest = _extract({"SKILL.md": _SKILL_MD.encode()})
ref = manifest.to_skill_ref(file_id="upload-1", path="pdf-toolkit/.DIFY-SKILL-FULL.zip")
assert ref.name == "PDF Toolkit"
assert ref.file_id == "upload-1"
assert ref.path == "pdf-toolkit/.DIFY-SKILL-FULL.zip"
assert ref.id == manifest.hash
dumped = ref.model_dump()
assert dumped["hash"] == manifest.hash
assert dumped["entry_path"] == "SKILL.md"

View File

@ -42,6 +42,19 @@ def test_standardize_creates_two_drive_owned_toolfiles_and_commits():
]
drive = MagicMock()
drive.commit.return_value = []
drive.list_skills.return_value = [
{
'path': 'pdf-toolkit',
'skill_md_key': 'pdf-toolkit/SKILL.md',
'archive_key': 'pdf-toolkit/.DIFY-SKILL-FULL.zip',
'name': 'PDF Toolkit',
'description': 'Work with PDFs.',
'size': len(_SKILL_MD),
'mime_type': 'text/markdown',
'hash': None,
'created_at': None,
},
]
service = SkillStandardizeService(tool_file_manager=tool_files, drive_service=drive)
result = service.standardize(
@ -67,14 +80,15 @@ def test_standardize_creates_two_drive_owned_toolfiles_and_commits():
assert [item.key for item in items] == ["pdf-toolkit/SKILL.md", "pdf-toolkit/.DIFY-SKILL-FULL.zip"]
assert all(item.value_owned_by_drive for item in items)
assert [item.file_ref.id for item in items] == ["md-tool-file", "zip-tool-file"]
assert items[0].is_skill is True
assert items[0].skill_metadata is not None
assert items[0].skill_metadata.name == "PDF Toolkit"
assert items[1].is_skill is False
# The returned skill ref carries stable drive paths + file ids.
# The returned upload response carries only the drive-derived fields the UI needs.
skill = result["skill"]
assert skill["path"] == "pdf-toolkit"
assert skill["name"] == "PDF Toolkit"
assert skill["full_archive_file_id"] == "zip-tool-file"
assert skill["skill_md_file_id"] == "md-tool-file"
assert skill["archive_key"] == "pdf-toolkit/.DIFY-SKILL-FULL.zip"
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"]
assert result["manifest"]["files"] == ["SKILL.md", "scripts/run.py"]

View File

@ -29,13 +29,8 @@ def _service(preview=_SKILL_MD_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: pytest.MonkeyPatch):
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",'
@ -53,9 +48,8 @@ def test_infer_returns_suggestions_with_inferred_from(monkeypatch: pytest.Monkey
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: pytest.MonkeyPatch):
def test_infer_threads_skill_md_into_the_prompt(monkeypatch):
service, _ = _service()
_patch_soul_files(monkeypatch, ["scripts/run.sh"])
captured: dict[str, str] = {}
def fake_invoke(*, tenant_id, user_prompt):
@ -65,22 +59,20 @@ def test_infer_threads_manifest_files_into_the_prompt(monkeypatch: pytest.Monkey
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 "Files inside the skill package" not in captured["prompt"]
assert "ffmpeg" in captured["prompt"] # SKILL.md body present
def test_infer_not_inferable_passes_reason_through(monkeypatch: pytest.MonkeyPatch):
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: pytest.MonkeyPatch):
def test_infer_retries_once_then_422(monkeypatch):
service, _ = _service()
_patch_soul_files(monkeypatch, [])
calls: list[int] = []
def bad_invoke(**kwargs):
@ -96,9 +88,8 @@ def test_infer_retries_once_then_422(monkeypatch: pytest.MonkeyPatch):
assert exc_info.value.status_code == 422
def test_infer_repairs_slightly_malformed_json(monkeypatch: pytest.MonkeyPatch):
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")
@ -123,10 +114,10 @@ def test_binary_skill_md_maps_to_404():
assert exc_info.value.code == "skill_not_found"
# ── real-path coverage: _invoke / _manifest_files_from_soul / passthrough ────
# ── real-path coverage: _invoke / passthrough ────────────────────────────────
def test_invoke_maps_missing_default_model_to_400(monkeypatch: pytest.MonkeyPatch):
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
@ -140,7 +131,7 @@ def test_invoke_maps_missing_default_model_to_400(monkeypatch: pytest.MonkeyPatc
assert exc_info.value.status_code == 400
def test_invoke_maps_model_failure_to_422_and_success_returns_text(monkeypatch: pytest.MonkeyPatch):
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()
@ -171,55 +162,3 @@ def test_load_skill_md_passes_through_non_missing_drive_errors():
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: pytest.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: pytest.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: pytest.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: pytest.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

@ -15,7 +15,7 @@ from sqlalchemy import delete, select
from core.db.session_factory import session_factory
from extensions.storage.storage_type import StorageType
from models.agent import Agent, AgentDriveFile, AgentScope, AgentSource
from models.agent import Agent, AgentDriveFile, AgentDriveFileKind, AgentScope, AgentSource
from models.enums import CreatorUserRole
from models.model import UploadFile
from models.tools import ToolFile
@ -23,6 +23,7 @@ from services.agent_drive_service import (
AgentDriveError,
AgentDriveService,
DriveCommitItem,
DriveSkillMetadata,
normalize_drive_key,
parse_agent_drive_ref,
)
@ -132,6 +133,123 @@ def test_commit_then_manifest_lists_the_entry():
assert AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, prefix="other/") == []
def test_commit_skill_row_persists_metadata_and_lists_catalog() -> None:
tf = _seed_tool_file(name="SKILL.md")
AgentDriveService().commit(
tenant_id=TENANT,
user_id=USER,
agent_id=AGENT,
items=[
DriveCommitItem(
key="tender-analyzer/SKILL.md",
file_ref={"kind": "tool_file", "id": tf},
is_skill=True,
skill_metadata=DriveSkillMetadata(name="Tender Analyzer", description="Parses RFPs."),
)
],
)
with session_factory.create_session() as session:
row = session.scalar(select(AgentDriveFile).where(AgentDriveFile.key == "tender-analyzer/SKILL.md"))
assert row is not None
assert row.is_skill is True
assert row.skill_metadata == '{"description":"Parses RFPs.","name":"Tender Analyzer"}'
skills = AgentDriveService().list_skills(tenant_id=TENANT, agent_id=AGENT)
assert len(skills) == 1
assert skills[0]["path"] == "tender-analyzer"
assert skills[0]["skill_md_key"] == "tender-analyzer/SKILL.md"
assert skills[0]["archive_key"] is None
assert skills[0]["name"] == "Tender Analyzer"
assert skills[0]["description"] == "Parses RFPs."
assert skills[0]["size"] == 5
assert skills[0]["mime_type"] == "text/plain"
def test_commit_rejects_skill_row_without_skill_metadata() -> None:
tf = _seed_tool_file(name="SKILL.md")
with pytest.raises(AgentDriveError) as exc_info:
AgentDriveService().commit(
tenant_id=TENANT,
user_id=USER,
agent_id=AGENT,
items=[
DriveCommitItem(
key="tender-analyzer/SKILL.md",
file_ref={"kind": "tool_file", "id": tf},
is_skill=True,
)
],
)
assert exc_info.value.code == "invalid_skill_metadata"
@pytest.mark.parametrize("raw_metadata", [None, '{"description":"oops"}'])
def test_list_skills_raises_controlled_error_for_invalid_stored_metadata(raw_metadata: str | None) -> None:
tf = _seed_tool_file(name="SKILL.md")
with session_factory.create_session() as session:
session.add(
AgentDriveFile(
id="44444444-4444-4444-4444-444444444444",
tenant_id=TENANT,
agent_id=AGENT,
key="broken-skill/SKILL.md",
file_kind=AgentDriveFileKind.TOOL_FILE,
file_id=tf,
value_owned_by_drive=True,
is_skill=True,
skill_metadata=raw_metadata,
size=5,
mime_type="text/plain",
created_by=USER,
)
)
session.commit()
with pytest.raises(AgentDriveError) as exc_info:
AgentDriveService().list_skills(tenant_id=TENANT, agent_id=AGENT)
assert exc_info.value.code == "invalid_skill_metadata"
def test_commit_rejects_non_skill_row_with_skill_metadata() -> None:
tf = _seed_tool_file()
with pytest.raises(AgentDriveError, match="skill metadata"):
AgentDriveService().commit(
tenant_id=TENANT,
user_id=USER,
agent_id=AGENT,
items=[
DriveCommitItem(
key="files/report.txt",
file_ref={"kind": "tool_file", "id": tf},
skill_metadata=DriveSkillMetadata(name="Bad", description=""),
)
],
)
def test_commit_rejects_non_canonical_skill_key() -> None:
tf = _seed_tool_file(name="README.md")
with pytest.raises(AgentDriveError, match="canonical"):
AgentDriveService().commit(
tenant_id=TENANT,
user_id=USER,
agent_id=AGENT,
items=[
DriveCommitItem(
key="tender-analyzer/README.md",
file_ref={"kind": "tool_file", "id": tf},
is_skill=True,
skill_metadata=DriveSkillMetadata(name="Tender Analyzer", description=""),
)
],
)
def test_commit_rejects_tool_file_not_owned_by_user():
other = _seed_tool_file(user_id="99999999-9999-9999-9999-999999999999")
with pytest.raises(AgentDriveError) as exc_info:
@ -246,6 +364,49 @@ def test_recommit_same_value_is_idempotent_and_keeps_value():
assert len(rows) == 1
def test_recommit_same_skill_value_updates_metadata_without_cleaning_backing_file() -> None:
tf = _seed_tool_file(name="SKILL.md")
AgentDriveService().commit(
tenant_id=TENANT,
user_id=USER,
agent_id=AGENT,
items=[
DriveCommitItem(
key="tender-analyzer/SKILL.md",
file_ref={"kind": "tool_file", "id": tf},
value_owned_by_drive=True,
is_skill=True,
skill_metadata=DriveSkillMetadata(name="Tender Analyzer", description="v1"),
)
],
)
with patch("services.agent_drive_service.storage") as storage_mock:
AgentDriveService().commit(
tenant_id=TENANT,
user_id=USER,
agent_id=AGENT,
items=[
DriveCommitItem(
key="tender-analyzer/SKILL.md",
file_ref={"kind": "tool_file", "id": tf},
value_owned_by_drive=False,
is_skill=True,
skill_metadata=DriveSkillMetadata(name="Tender Analyzer v2", description="v2"),
)
],
)
storage_mock.delete.assert_not_called()
with session_factory.create_session() as session:
row = session.scalar(select(AgentDriveFile).where(AgentDriveFile.key == "tender-analyzer/SKILL.md"))
assert row is not None
assert row.file_id == tf
assert row.value_owned_by_drive is False
assert row.skill_metadata == '{"description":"v2","name":"Tender Analyzer v2"}'
assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is not None
def _seed_upload_file(*, name: str = "u.txt") -> str:
upload = UploadFile(
tenant_id=TENANT,
@ -318,7 +479,7 @@ def test_manifest_includes_internal_download_url():
with (
patch("services.agent_drive_service.file_factory.build_from_mapping", return_value=object()),
patch("services.agent_drive_service.DifyWorkflowFileRuntime") as runtime_cls,
patch("core.app.workflow.file_runtime.DifyWorkflowFileRuntime") as runtime_cls,
):
runtime_cls.return_value.resolve_file_url.return_value = "http://internal/files/x?sign=1"
items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, include_download_url=True)
@ -348,16 +509,21 @@ def test_delete_by_key_cleans_drive_owned_value():
_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")
removed = AgentDriveService().commit(
tenant_id=TENANT,
user_id=USER,
agent_id=AGENT,
items=[DriveCommitItem(key="files/doomed.txt", file_ref=None)],
)
storage_mock.delete.assert_called_once()
assert removed == ["files/doomed.txt"]
assert removed == [{"key": "files/doomed.txt", "file_kind": "tool_file", "file_id": tf, "value_owned_by_drive": True, "is_skill": False, "skill_metadata": None, "removed": True}]
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():
def test_commit_null_batch_removes_multiple_skill_keys():
md = _seed_tool_file(name="SKILL.md")
zf = _seed_tool_file(name="full.zip")
_commit("tender-analyzer/SKILL.md", md, owned=True)
@ -366,9 +532,17 @@ def test_delete_by_prefix_removes_all_skill_keys():
_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/")
removed = AgentDriveService().commit(
tenant_id=TENANT,
user_id=USER,
agent_id=AGENT,
items=[
DriveCommitItem(key="tender-analyzer/SKILL.md", file_ref=None),
DriveCommitItem(key="tender-analyzer/.DIFY-SKILL-FULL.zip", file_ref=None),
],
)
assert sorted(removed) == ["tender-analyzer/.DIFY-SKILL-FULL.zip", "tender-analyzer/SKILL.md"]
assert sorted(item["key"] for item in 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
@ -378,28 +552,30 @@ def test_delete_by_prefix_removes_all_skill_keys():
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_commit_null_is_idempotent_for_missing_keys():
removed = AgentDriveService().commit(
tenant_id=TENANT,
user_id=USER,
agent_id=AGENT,
items=[DriveCommitItem(key="files/never-there.txt", file_ref=None)],
)
assert removed == [{"key": "files/never-there.txt", "removed": True, "noop": True}]
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():
def test_commit_null_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")
removed = AgentDriveService().commit(
tenant_id=TENANT,
user_id=USER,
agent_id=AGENT,
items=[DriveCommitItem(key="files/shared.txt", file_ref=None)],
)
storage_mock.delete.assert_not_called()
assert removed == ["files/shared.txt"]
assert removed[0]["key"] == "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
@ -501,7 +677,7 @@ def test_upload_file_download_url_uses_attachment_filename():
upload_file_id = _seed_upload_file(name="report.pdf")
_commit_upload("files/report.pdf", upload_file_id)
with patch("services.agent_drive_service.DifyWorkflowFileRuntime") as runtime_cls:
with patch("core.app.workflow.file_runtime.DifyWorkflowFileRuntime") as runtime_cls:
runtime_cls.return_value.resolve_upload_file_url.return_value = "https://files.example/report.pdf"
url = AgentDriveService().download_url(tenant_id=TENANT, agent_id=AGENT, key="files/report.pdf")

View File

@ -11,6 +11,7 @@ ToolFile ids back into the drive.
from __future__ import annotations
import stat
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from tempfile import TemporaryDirectory
@ -81,11 +82,12 @@ def list_drive_from_environment(prefix: str, json_output: bool) -> str | AgentSt
return _format_manifest(response)
def pull_drive_from_environment(prefix: str, drive_base: str = "/mnt/drive") -> list[Path]:
def pull_drive_from_environment(targets: list[str] | None = None, drive_base: str = "/mnt/drive") -> list[Path]:
"""Pull drive files into one local drive base via signed download URLs.
Args:
prefix: Optional drive-key prefix forwarded to the manifest request.
targets: Optional drive-key targets or prefixes. An empty list preserves
the historical whole-drive pull by using ``[""]``.
drive_base: Local base directory that receives downloaded drive files.
Returns:
@ -117,16 +119,28 @@ def pull_drive_from_environment(prefix: str, drive_base: str = "/mnt/drive") ->
"""
environment = read_agent_stub_environment()
response = request_agent_stub_drive_manifest_sync(
url=environment.url,
auth_jwe=environment.auth_jwe,
prefix=prefix,
include_download_url=True,
)
manifest_targets = targets or [""]
with ThreadPoolExecutor(max_workers=min(len(manifest_targets), 4)) as executor:
responses = list(
executor.map(
lambda target: request_agent_stub_drive_manifest_sync(
url=environment.url,
auth_jwe=environment.auth_jwe,
prefix=target,
include_download_url=True,
),
manifest_targets,
)
)
base_path = Path(drive_base).expanduser().resolve()
base_path.mkdir(parents=True, exist_ok=True)
written_paths: list[Path] = []
for item in response.items:
deduplicated_items = {
item.key: item
for response in responses
for item in response.items
}
for item in [deduplicated_items[key] for key in sorted(deduplicated_items)]:
download_url = item.download_url
if not isinstance(download_url, str) or not download_url:
raise AgentStubValidationError(f"drive manifest item is missing download_url: {item.key}")

View File

@ -78,11 +78,15 @@ def drive_list(
@drive_app.command("pull")
def drive_pull(
path_prefix: str = typer.Argument("", metavar="PATH_PREFIX"),
targets: list[str] = typer.Argument(None, metavar="TARGET"),
drive_base: str = typer.Option("/mnt/drive", "--drive-base", help="Local base directory for pulled drive files."),
) -> None:
"""Pull drive files into one local directory tree."""
_run_drive_pull(path_prefix=path_prefix, drive_base=drive_base)
"""Pull one or more drive keys/prefixes into one local directory tree.
Passing no ``TARGET`` preserves the historical whole-drive behavior by
pulling from the empty prefix.
"""
_run_drive_pull(targets=targets, drive_base=drive_base)
@drive_app.command("push")
@ -207,9 +211,9 @@ def _run_drive_list(*, path_prefix: str, json_output: bool) -> None:
typer.echo(response)
def _run_drive_pull(*, path_prefix: str, drive_base: str) -> None:
def _run_drive_pull(*, targets: list[str] | None, drive_base: str) -> None:
try:
response = pull_drive_from_environment(prefix=path_prefix, drive_base=drive_base)
response = pull_drive_from_environment(targets=targets, drive_base=drive_base)
except MissingAgentStubEnvironmentError as exc:
typer.echo(str(exc), err=True)
raise SystemExit(2) from exc

View File

@ -233,8 +233,10 @@ class AgentStubDriveCommitItem(BaseModel):
"""One drive key to file binding committed through the Agent Stub."""
key: str
file_ref: AgentStubDriveFileRef
file_ref: AgentStubDriveFileRef | None = None
value_owned_by_drive: bool = True
is_skill: bool = False
skill_metadata: dict[str, str] | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
@ -254,11 +256,14 @@ class AgentStubDriveItem(BaseModel):
size: int | None = None
hash: str | None = None
mime_type: str | None = None
file_kind: Literal["upload_file", "tool_file"]
file_id: str
file_kind: Literal["upload_file", "tool_file"] | None = None
file_id: str | None = None
created_at: int | None = None
download_url: str | None = None
value_owned_by_drive: bool | None = None
removed: bool | None = None
is_skill: bool | None = None
skill_metadata: str | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")

View File

@ -1,4 +1,4 @@
"""Client-safe exports for the Dify drive declaration layer DTOs.
"""Client-safe exports for the Dify drive runtime catalog DTOs.
The layer implementation lives in the sibling ``layer`` module. Keep this
package root import-safe for client code that only builds run requests.
@ -6,14 +6,12 @@ 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

@ -1,11 +1,9 @@
"""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 drive layer carries the runtime drive catalog plus the prompt-mentioned
targets that must be pulled eagerly when the layer enters. It is still config
only: skills are declared as metadata, not content, and plain files are listed
only when the prompt explicitly mentions their drive keys.
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).
@ -22,7 +20,7 @@ DIFY_DRIVE_LAYER_TYPE_ID: Final[str] = "dify.drive"
class DifyDriveSkillConfig(BaseModel):
"""Runtime declaration of one standardized skill — an index, not content."""
"""Runtime declaration of one standardized skill — metadata, not content."""
model_config = ConfigDict(extra="forbid")
@ -33,35 +31,24 @@ class DifyDriveSkillConfig(BaseModel):
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
path: str
class DifyDriveLayerConfig(LayerConfig):
"""Config-only declaration layer: API writes the catalog, the agent pulls
the listed entries through the back proxy using ``drive_ref``."""
"""Drive runtime catalog plus eager-pull instructions for mentioned targets."""
# "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
drive_base: str = "/mnt/drive"
skills: list[DifyDriveSkillConfig] = Field(default_factory=list)
files: list[DifyDriveFileConfig] = Field(default_factory=list)
mentioned_skill_keys: list[str] = Field(default_factory=list)
mentioned_file_keys: list[str] = Field(default_factory=list)
__all__ = [
"DIFY_DRIVE_LAYER_TYPE_ID",
"DifyDriveFileConfig",
"DifyDriveLayerConfig",
"DifyDriveSkillConfig",
]

View File

@ -1,34 +1,328 @@
"""Inert Dify drive declaration layer.
"""Runtime Dify drive layer with eager pull for prompt-mentioned targets.
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.
The API backend sends the full drive skill catalog plus the ordered drive keys
mentioned in the prompt. When the layer enters a run context it eagerly pulls
those mentioned skills/files from the Dify inner drive bridge, materializes them
under ``drive_base``, and contributes a concise prompt block describing what was
loaded and what other skills remain available for lazy pull.
"""
from dataclasses import dataclass
from typing import ClassVar
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
from tempfile import TemporaryDirectory
from typing import Any, ClassVar, cast
from uuid import uuid4
from zipfile import BadZipFile, ZipFile, ZipInfo
import httpx
from typing_extensions import Self, override
from agenton.layers import EmptyRuntimeState, NoLayerDeps, PlainLayer
from agenton.layers import EmptyRuntimeState, Layer, LayerDeps, PlainLayer, PydanticAIPrompt
from dify_agent.layers.drive.configs import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig
_SKILL_ARCHIVE_FILENAME = ".DIFY-SKILL-FULL.zip"
_DOWNLOAD_CONCURRENCY = 4
class DifyDriveLayerError(RuntimeError):
"""Raised when one eager-pull drive operation fails."""
class DifyDriveDeps(LayerDeps):
execution_context: Layer # pyright: ignore[reportUninitializedInstanceVariable]
@dataclass(frozen=True, slots=True)
class _DriveManifestItem:
key: str
download_url: str
size: int | None = None
@dataclass(slots=True)
class DifyDriveLayer(PlainLayer[NoLayerDeps, DifyDriveLayerConfig, EmptyRuntimeState]):
"""Config-only carrier of the drive Skills & Files manifest."""
class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntimeState]):
"""Drive runtime layer that eagerly materializes prompt-mentioned drive targets."""
type_id: ClassVar[str | None] = DIFY_DRIVE_LAYER_TYPE_ID
config: DifyDriveLayerConfig
dify_api_inner_url: str
dify_api_inner_api_key: str
_loaded_skill_bodies: dict[str, str] = field(default_factory=dict)
_pulled_file_paths: dict[str, str] = field(default_factory=dict)
@classmethod
@override
def from_config(cls, config: DifyDriveLayerConfig) -> Self:
return cls(config=config)
del config
raise TypeError("DifyDriveLayer requires server-side Dify API settings and must use a provider factory.")
@classmethod
def from_config_with_settings(
cls,
config: DifyDriveLayerConfig,
*,
dify_api_inner_url: str,
dify_api_inner_api_key: str,
) -> Self:
return cls(
config=DifyDriveLayerConfig.model_validate(config),
dify_api_inner_url=dify_api_inner_url.rstrip("/"),
dify_api_inner_api_key=dify_api_inner_api_key,
)
@property
@override
def prefix_prompts(self) -> list[PydanticAIPrompt[object]]:
return [self.build_prompt_context]
@override
async def on_context_create(self) -> None:
await self._pull_mentioned_targets()
@override
async def on_context_resume(self) -> None:
await self._pull_mentioned_targets()
def build_prompt_context(self) -> str:
sections: list[str] = []
loaded_skill_sections: list[str] = []
for skill_key in self.config.mentioned_skill_keys:
body = self._loaded_skill_bodies.get(skill_key)
if body is None:
continue
skill = next((item for item in self.config.skills if item.skill_md_key == skill_key), None)
if skill is None:
continue
loaded_skill_sections.append(
f"Path: {skill.path}\nName: {skill.name}\nSKILL.md:\n{body}"
)
if loaded_skill_sections:
sections.append("Loaded mentioned skills:\n\n" + "\n\n".join(loaded_skill_sections))
mentioned_files = [
f"- {key} -> {self._pulled_file_paths[key]}"
for key in self.config.mentioned_file_keys
if key in self._pulled_file_paths
]
if mentioned_files:
sections.append("Mentioned files pulled to local drive:\n" + "\n".join(mentioned_files))
other_skills = [
f"- {skill.path}: {skill.name}{skill.description}"
for skill in self.config.skills
if skill.skill_md_key not in set(self.config.mentioned_skill_keys)
]
if other_skills:
sections.append("Other available skills:\n" + "\n".join(other_skills))
if not sections:
return ""
sections.append(
"Additional drive skills/files can be pulled lazily later with the Agent Stub drive commands if needed."
)
return "\n\n".join(sections)
async def _pull_mentioned_targets(self) -> None:
self._loaded_skill_bodies = {}
self._pulled_file_paths = {}
targets: list[tuple[str, bool]] = [
(self._skill_prefix(skill_key), False) for skill_key in self.config.mentioned_skill_keys
] + [(file_key, True) for file_key in self.config.mentioned_file_keys]
if not targets:
return
tenant_id = self._require_tenant_id()
manifest_items = await self._fetch_manifest_items(tenant_id=tenant_id, targets=targets)
written_paths = await self._download_items(manifest_items)
self._pulled_file_paths = written_paths
for file_key in self.config.mentioned_file_keys:
if file_key not in written_paths:
raise DifyDriveLayerError(f"missing pulled file for mentioned drive key {file_key}")
for skill_key in self.config.mentioned_skill_keys:
skill_path = written_paths.get(skill_key)
if skill_path is None:
raise DifyDriveLayerError(f"missing pulled SKILL.md for mentioned skill {skill_key}")
try:
self._loaded_skill_bodies[skill_key] = Path(skill_path).read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
raise DifyDriveLayerError(f"failed to load pulled SKILL.md for mentioned skill {skill_key}") from exc
async def _fetch_manifest_items(
self,
*,
tenant_id: str,
targets: list[tuple[str, bool]],
) -> list[_DriveManifestItem]:
semaphore = asyncio.Semaphore(_DOWNLOAD_CONCURRENCY)
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True, trust_env=False) as client:
async def fetch_one(target: tuple[str, bool]) -> list[_DriveManifestItem]:
prefix, exact = target
try:
async with semaphore:
response = await client.get(
f"{self.dify_api_inner_url}/inner/api/drive/{self.config.drive_ref}/manifest",
params={
"tenant_id": tenant_id,
"prefix": prefix,
"include_download_url": "true",
},
headers={"X-Inner-Api-Key": self.dify_api_inner_api_key},
)
except (httpx.InvalidURL, httpx.TimeoutException, httpx.RequestError) as exc:
raise DifyDriveLayerError(f"drive manifest request failed for {prefix}") from exc
if response.is_error:
raise DifyDriveLayerError(f"drive manifest request failed for {prefix}: {response.status_code}")
try:
payload = response.json()
except ValueError as exc:
raise DifyDriveLayerError(f"drive manifest response is invalid for {prefix}") from exc
items = payload.get("items") if isinstance(payload, dict) else None
if not isinstance(items, list):
raise DifyDriveLayerError(f"drive manifest response is invalid for {prefix}")
manifest_items: list[_DriveManifestItem] = []
for item in items:
if not isinstance(item, dict):
continue
key = item.get("key")
download_url = item.get("download_url")
if not isinstance(key, str) or not isinstance(download_url, str) or not download_url:
raise DifyDriveLayerError(f"drive manifest item is missing download_url for {prefix}")
if exact and key != prefix:
continue
manifest_items.append(_DriveManifestItem(key=key, download_url=download_url, size=item.get("size")))
return manifest_items
grouped_items = await asyncio.gather(*(fetch_one(target) for target in targets))
deduplicated: dict[str, _DriveManifestItem] = {}
for items in grouped_items:
for item in items:
deduplicated.setdefault(item.key, item)
return [deduplicated[key] for key in sorted(deduplicated)]
async def _download_items(self, items: list[_DriveManifestItem]) -> dict[str, str]:
base_path = Path(self.config.drive_base).expanduser().resolve()
try:
base_path.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise DifyDriveLayerError(f"failed to prepare drive base {base_path}") from exc
semaphore = asyncio.Semaphore(_DOWNLOAD_CONCURRENCY)
archive_paths: list[Path] = []
canonical_skill_dirs = {item.key.rsplit("/", 1)[0] for item in items if item.key.endswith("/SKILL.md")}
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True, trust_env=False) as client:
async def download_one(item: _DriveManifestItem) -> tuple[str, str]:
try:
async with semaphore:
response = await client.get(item.download_url)
except (httpx.InvalidURL, httpx.TimeoutException, httpx.RequestError) as exc:
raise DifyDriveLayerError(f"drive download failed for {item.key}") from exc
if response.is_error:
raise DifyDriveLayerError(f"drive download failed for {item.key}: {response.status_code}")
payload = response.content
if item.size is not None and len(payload) != item.size:
raise DifyDriveLayerError(f"downloaded drive file size mismatch for {item.key}")
try:
destination = _resolve_drive_destination(base_path, item.key)
destination.parent.mkdir(parents=True, exist_ok=True)
temp_path = destination.with_name(f"{destination.name}.tmp-{uuid4().hex}")
temp_path.write_bytes(payload)
temp_path.replace(destination)
except OSError as exc:
raise DifyDriveLayerError(f"failed to materialize drive file {item.key}") from exc
if destination.name == _SKILL_ARCHIVE_FILENAME:
archive_paths.append(destination)
return item.key, str(destination)
pairs = await asyncio.gather(*(download_one(item) for item in items))
for archive_path in sorted(archive_paths):
archive_skill_dir = archive_path.parent.relative_to(base_path).as_posix()
skip_entry_names = {"SKILL.md"} if archive_skill_dir in canonical_skill_dirs else set()
_extract_skill_archive(archive_path, skip_entry_names=skip_entry_names)
return {key: path for key, path in pairs}
def _require_tenant_id(self) -> str:
execution_context = self.deps.execution_context.config
tenant_id = getattr(execution_context, "tenant_id", None)
if not isinstance(tenant_id, str) or not tenant_id.strip():
raise DifyDriveLayerError("DifyDriveLayer requires execution_context.tenant_id")
return cast(str, tenant_id).strip()
@staticmethod
def _skill_prefix(skill_key: str) -> str:
return f"{skill_key.rsplit('/', 1)[0]}/"
__all__ = ["DifyDriveLayer"]
def _resolve_drive_destination(base_path: Path, drive_key: str) -> Path:
destination = (base_path / Path(drive_key)).resolve()
try:
destination.relative_to(base_path)
except ValueError as exc:
raise DifyDriveLayerError(f"drive key resolves outside the drive base: {drive_key}") from exc
return destination
def _extract_skill_archive(archive_path: Path, *, skip_entry_names: set[str]) -> None:
target_dir = archive_path.parent.resolve()
try:
with TemporaryDirectory(dir=target_dir, prefix=".dify-skill-extract-") as staging_dir_name:
staging_dir = Path(staging_dir_name).resolve()
with ZipFile(archive_path) as archive:
for zip_info in archive.infolist():
if zip_info.filename.replace("\\", "/").rstrip("/") in skip_entry_names:
continue
destination = _resolve_zip_entry_destination(staging_dir, zip_info.filename)
if _is_zip_symlink(zip_info):
raise DifyDriveLayerError(
f"skill archive contains unsupported symlink entry: {zip_info.filename}"
)
if zip_info.is_dir():
destination.mkdir(parents=True, exist_ok=True)
continue
destination.parent.mkdir(parents=True, exist_ok=True)
with archive.open(zip_info) as source_file:
temp_path = destination.with_name(f"{destination.name}.tmp-{uuid4().hex}")
temp_path.write_bytes(source_file.read())
temp_path.replace(destination)
for staged_path in sorted(staging_dir.rglob("*")):
if staged_path.is_dir():
continue
relative_path = staged_path.relative_to(staging_dir)
destination = (target_dir / relative_path).resolve()
destination.parent.mkdir(parents=True, exist_ok=True)
staged_path.replace(destination)
except DifyDriveLayerError:
raise
except (BadZipFile, OSError) as exc:
raise DifyDriveLayerError(f"downloaded skill archive is invalid: {archive_path.name}") from exc
def _resolve_zip_entry_destination(target_dir: Path, entry_name: str) -> Path:
normalized_name = entry_name.replace("\\", "/")
pure_path = PurePosixPath(normalized_name)
if not normalized_name or normalized_name.startswith("/") or pure_path.is_absolute():
raise DifyDriveLayerError(f"skill archive contains unsafe absolute path: {entry_name}")
if any(part in {"", ".", ".."} for part in pure_path.parts):
raise DifyDriveLayerError(f"skill archive contains unsafe path traversal entry: {entry_name}")
destination = (target_dir / Path(*pure_path.parts)).resolve()
try:
destination.relative_to(target_dir)
except ValueError as exc:
raise DifyDriveLayerError(f"skill archive entry resolves outside the skill directory: {entry_name}") from exc
return destination
def _is_zip_symlink(zip_info: ZipInfo) -> bool:
file_mode = zip_info.external_attr >> 16
return (file_mode & 0o170000) == 0o120000
__all__ = ["DifyDriveLayer", "DifyDriveLayerError"]

View File

@ -6,7 +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/knowledge business-layer family:
- ``dify.drive`` for the inert Skills & Files drive declaration,
- ``dify.drive`` for drive-backed skill catalog + eager pull,
- ``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,
@ -38,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 import DifyDriveLayerConfig
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
@ -89,10 +90,14 @@ 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=DifyDriveLayer,
create=lambda config: DifyDriveLayer.from_config_with_settings(
DifyDriveLayerConfig.model_validate(config),
dify_api_inner_url=dify_api_inner_url,
dify_api_inner_api_key=dify_api_inner_api_key,
),
),
LayerProvider.from_factory(
layer_type=DifyExecutionContextLayer,
create=lambda config: DifyExecutionContextLayer.from_config_with_settings(

View File

@ -128,7 +128,7 @@ def test_pull_drive_from_environment_writes_files_under_drive_base(
lambda **_kwargs: b"hello world",
)
results = pull_drive_from_environment(prefix="skills/", drive_base=str(tmp_path))
results = pull_drive_from_environment(targets=["skills/"], drive_base=str(tmp_path))
assert results == [tmp_path / "skills" / "example" / "SKILL.md"]
assert results[0].read_bytes() == b"hello world"
@ -169,7 +169,7 @@ def test_pull_drive_from_environment_auto_extracts_skill_archive(
lambda **_kwargs: archive_bytes,
)
results = pull_drive_from_environment(prefix="skills/foo", drive_base=str(tmp_path))
results = pull_drive_from_environment(targets=["skills/foo"], drive_base=str(tmp_path))
archive_path = tmp_path / "skills" / "foo" / ".DIFY-SKILL-FULL.zip"
assert results == [archive_path]
@ -202,7 +202,7 @@ def test_pull_drive_from_environment_rejects_traversal_keys(
)
with pytest.raises(AgentStubValidationError, match="outside the drive base"):
_ = pull_drive_from_environment(prefix="", drive_base=str(tmp_path))
_ = pull_drive_from_environment(targets=[""], drive_base=str(tmp_path))
def test_pull_drive_from_environment_rejects_skill_archive_path_traversal(
@ -239,7 +239,7 @@ def test_pull_drive_from_environment_rejects_skill_archive_path_traversal(
)
with pytest.raises(AgentStubValidationError, match="path traversal"):
_ = pull_drive_from_environment(prefix="skills/foo", drive_base=str(tmp_path))
_ = pull_drive_from_environment(targets=["skills/foo"], drive_base=str(tmp_path))
assert not (tmp_path / "skills" / "foo" / "SKILL.md").exists()
@ -276,7 +276,7 @@ def test_pull_drive_from_environment_rejects_skill_archive_absolute_entry(
)
with pytest.raises(AgentStubValidationError, match="absolute path"):
_ = pull_drive_from_environment(prefix="skills/foo", drive_base=str(tmp_path))
_ = pull_drive_from_environment(targets=["skills/foo"], drive_base=str(tmp_path))
def test_pull_drive_from_environment_rejects_skill_archive_symlink_entry(
@ -314,7 +314,7 @@ def test_pull_drive_from_environment_rejects_skill_archive_symlink_entry(
)
with pytest.raises(AgentStubValidationError, match="symlink entry"):
_ = pull_drive_from_environment(prefix="skills/foo", drive_base=str(tmp_path))
_ = pull_drive_from_environment(targets=["skills/foo"], drive_base=str(tmp_path))
def test_pull_drive_from_environment_rejects_invalid_skill_archive(
@ -347,7 +347,7 @@ def test_pull_drive_from_environment_rejects_invalid_skill_archive(
)
with pytest.raises(AgentStubTransferError, match="downloaded skill archive is invalid"):
_ = pull_drive_from_environment(prefix="skills/foo", drive_base=str(tmp_path))
_ = pull_drive_from_environment(targets=["skills/foo"], drive_base=str(tmp_path))
def test_pull_drive_from_environment_rejects_missing_download_url(
@ -373,7 +373,7 @@ def test_pull_drive_from_environment_rejects_missing_download_url(
)
with pytest.raises(AgentStubValidationError, match="missing download_url"):
_ = pull_drive_from_environment(prefix="skills/", drive_base=str(tmp_path))
_ = pull_drive_from_environment(targets=["skills/"], drive_base=str(tmp_path))
def test_pull_drive_from_environment_rejects_size_mismatch(
@ -404,7 +404,84 @@ def test_pull_drive_from_environment_rejects_size_mismatch(
)
with pytest.raises(AgentStubTransferError, match="size mismatch"):
_ = pull_drive_from_environment(prefix="skills/", drive_base=str(tmp_path))
_ = pull_drive_from_environment(targets=["skills/"], drive_base=str(tmp_path))
def test_pull_drive_from_environment_requests_multiple_targets_and_deduplicates_overlaps(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
captured_prefixes: list[str] = []
def fake_manifest(**kwargs):
captured_prefixes.append(kwargs["prefix"])
if kwargs["prefix"] == "skills/foo":
return AgentStubDriveManifestResponse(
items=[
AgentStubDriveItem(
key="skills/foo/SKILL.md",
size=5,
hash=None,
mime_type="text/markdown",
file_kind="tool_file",
file_id="tool-file-1",
download_url="https://files.example.com/skill-md",
)
]
)
return AgentStubDriveManifestResponse(
items=[
AgentStubDriveItem(
key="skills/foo/SKILL.md",
size=5,
hash=None,
mime_type="text/markdown",
file_kind="tool_file",
file_id="tool-file-1",
download_url="https://files.example.com/skill-md",
),
AgentStubDriveItem(
key="files/a.txt",
size=1,
hash=None,
mime_type="text/plain",
file_kind="tool_file",
file_id="tool-file-2",
download_url="https://files.example.com/a-txt",
),
]
)
downloaded_urls: list[str] = []
monkeypatch.setattr("dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync", fake_manifest)
monkeypatch.setattr(
"dify_agent.agent_stub.cli._drive.download_file_bytes_from_signed_url_sync",
lambda *, download_url: downloaded_urls.append(download_url) or (b"hello" if download_url.endswith("skill-md") else b"a"),
)
results = pull_drive_from_environment(targets=["skills/foo", "files/a.txt"], drive_base=str(tmp_path))
assert captured_prefixes == ["skills/foo", "files/a.txt"]
assert results == [tmp_path / "files" / "a.txt", tmp_path / "skills" / "foo" / "SKILL.md"]
assert downloaded_urls == ["https://files.example.com/a-txt", "https://files.example.com/skill-md"]
def test_pull_drive_from_environment_without_targets_preserves_whole_drive_pull(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub")
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
captured_prefixes: list[str] = []
monkeypatch.setattr(
"dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync",
lambda **kwargs: captured_prefixes.append(kwargs["prefix"]) or AgentStubDriveManifestResponse(items=[]),
)
assert pull_drive_from_environment(drive_base=str(tmp_path)) == []
assert captured_prefixes == [""]
def test_push_drive_from_environment_commits_single_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
@ -448,6 +525,8 @@ def test_push_drive_from_environment_commits_single_file(monkeypatch: pytest.Mon
"key": "files/report.pdf",
"file_ref": {"kind": "tool_file", "id": "tool-file-1"},
"value_owned_by_drive": True,
"is_skill": False,
"skill_metadata": None,
}

View File

@ -252,7 +252,10 @@ def test_cli_drive_pull_prints_downloaded_paths(
) -> None:
monkeypatch.setattr(
"dify_agent.agent_stub.cli.main.pull_drive_from_environment",
lambda *, prefix, drive_base: [Path(drive_base) / prefix / "SKILL.md", Path(drive_base) / prefix / "helper.py"],
lambda *, targets, drive_base: [
Path(drive_base) / targets[0] / "SKILL.md",
Path(drive_base) / targets[0] / "helper.py",
],
)
with pytest.raises(SystemExit) as exc_info:
@ -266,6 +269,31 @@ def test_cli_drive_pull_prints_downloaded_paths(
]
def test_cli_drive_pull_forwards_multiple_targets(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
captured_kwargs: dict[str, object] = {}
def fake_pull_drive_from_environment(*, targets, drive_base):
captured_kwargs["targets"] = targets
captured_kwargs["drive_base"] = drive_base
return [Path(drive_base) / "skills" / "foo" / "SKILL.md"]
monkeypatch.setattr(
"dify_agent.agent_stub.cli.main.pull_drive_from_environment",
fake_pull_drive_from_environment,
)
with pytest.raises(SystemExit) as exc_info:
main(["drive", "pull", "skills/foo", "files/a.txt", "--drive-base", "/tmp/drive"])
captured = capsys.readouterr()
assert exc_info.value.code == 0
assert captured_kwargs == {"targets": ["skills/foo", "files/a.txt"], "drive_base": "/tmp/drive"}
assert captured.out.strip() == "/tmp/drive/skills/foo/SKILL.md"
def test_cli_drive_push_prints_commit_json(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],

View File

@ -5,12 +5,10 @@ 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:
@ -22,25 +20,26 @@ def test_layer_config_round_trips_manifest_entries() -> None:
config = DifyDriveLayerConfig.model_validate(
{
"drive_ref": "agent-019e9112",
"drive_base": "/mnt/drive",
"skills": [
{
"path": "tender-analyzer",
"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"}],
"mentioned_skill_keys": ["tender-analyzer/SKILL.md"],
"mentioned_file_keys": ["files/sample.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 dumped["mentioned_file_keys"] == ["files/sample.pdf"]
assert "content" not in DifyDriveSkillConfig.model_fields
assert "content" not in DifyDriveFileConfig.model_fields
def test_layer_config_rejects_unknown_fields() -> None:
@ -48,11 +47,12 @@ def test_layer_config_rejects_unknown_fields() -> None:
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": []})
def test_drive_layer_is_registered_and_constructible_from_config() -> None:
layer = DifyDriveLayer.from_config_with_settings(
DifyDriveLayerConfig(drive_ref="agent-1", skills=[], mentioned_skill_keys=[], mentioned_file_keys=[]),
dify_api_inner_url="https://api.example.com",
dify_api_inner_api_key="secret",
)
assert isinstance(layer, DifyDriveLayer)
assert layer.config.drive_ref == "agent-1"

View File

@ -0,0 +1,197 @@
"""Behavior tests for the runtime Dify drive layer."""
from __future__ import annotations
from pathlib import Path
import pytest
from agenton.layers import EmptyRuntimeState, LayerConfig, NoLayerDeps, PlainLayer
from dify_agent.layers.drive import DifyDriveLayerConfig, DifyDriveSkillConfig
from dify_agent.layers.drive.layer import DifyDriveLayer, DifyDriveLayerError, _DriveManifestItem
class _FakeExecutionContextConfig(LayerConfig):
tenant_id: str
class _FakeExecutionContextLayer(PlainLayer[NoLayerDeps, _FakeExecutionContextConfig, EmptyRuntimeState]):
type_id = None
def __init__(self, tenant_id: str) -> None:
self.config = _FakeExecutionContextConfig(tenant_id=tenant_id)
def _build_layer(tmp_path: Path) -> DifyDriveLayer:
layer = DifyDriveLayer.from_config_with_settings(
DifyDriveLayerConfig(
drive_ref="agent-1",
drive_base=str(tmp_path),
skills=[
DifyDriveSkillConfig(
path="tender-analyzer",
name="Tender Analyzer",
description="Parses RFPs.",
skill_md_key="tender-analyzer/SKILL.md",
archive_key="tender-analyzer/.DIFY-SKILL-FULL.zip",
),
DifyDriveSkillConfig(
path="other-skill",
name="Other Skill",
description="Fallback catalog entry.",
skill_md_key="other-skill/SKILL.md",
archive_key=None,
),
],
mentioned_skill_keys=["tender-analyzer/SKILL.md"],
mentioned_file_keys=["files/report.pdf"],
),
dify_api_inner_url="https://api.example.com",
dify_api_inner_api_key="secret",
)
layer.bind_deps({"execution_context": _FakeExecutionContextLayer("tenant-1")})
return layer
@pytest.mark.anyio
async def test_on_context_create_loads_mentioned_targets_into_prompt(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
layer = _build_layer(tmp_path)
async def _fetch_manifest_items(*, tenant_id: str, targets: list[tuple[str, bool]]) -> list[_DriveManifestItem]:
assert tenant_id == "tenant-1"
assert targets == [("tender-analyzer/", False), ("files/report.pdf", True)]
return [
_DriveManifestItem(key="tender-analyzer/SKILL.md", download_url="https://files/skill-md"),
_DriveManifestItem(key="files/report.pdf", download_url="https://files/report"),
]
async def _download_items(items: list[_DriveManifestItem]) -> dict[str, str]:
assert {item.key for item in items} == {"files/report.pdf", "tender-analyzer/SKILL.md"}
skill_path = tmp_path / "tender-analyzer" / "SKILL.md"
skill_path.parent.mkdir(parents=True, exist_ok=True)
skill_path.write_text("# Tender Analyzer\nUse carefully.\n", encoding="utf-8")
file_path = tmp_path / "files" / "report.pdf"
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_bytes(b"pdf")
return {
"tender-analyzer/SKILL.md": str(skill_path),
"files/report.pdf": str(file_path),
}
monkeypatch.setattr(layer, "_fetch_manifest_items", _fetch_manifest_items)
monkeypatch.setattr(layer, "_download_items", _download_items)
await layer.on_context_create()
prompt = layer.build_prompt_context()
assert "Loaded mentioned skills" in prompt
assert "# Tender Analyzer\nUse carefully." in prompt
assert f"files/report.pdf -> {tmp_path / 'files' / 'report.pdf'}" in prompt
assert "Other available skills" in prompt
assert "other-skill: Other Skill — Fallback catalog entry." in prompt
@pytest.mark.anyio
async def test_on_context_resume_loads_mentioned_targets_into_prompt(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
layer = _build_layer(tmp_path)
async def _fetch_manifest_items(*, tenant_id: str, targets: list[tuple[str, bool]]) -> list[_DriveManifestItem]:
assert tenant_id == "tenant-1"
assert targets == [("tender-analyzer/", False), ("files/report.pdf", True)]
return [
_DriveManifestItem(key="tender-analyzer/SKILL.md", download_url="https://files/skill-md"),
_DriveManifestItem(key="files/report.pdf", download_url="https://files/report"),
]
async def _download_items(items: list[_DriveManifestItem]) -> dict[str, str]:
assert {item.key for item in items} == {"files/report.pdf", "tender-analyzer/SKILL.md"}
skill_path = tmp_path / "tender-analyzer" / "SKILL.md"
skill_path.parent.mkdir(parents=True, exist_ok=True)
skill_path.write_text("# Tender Analyzer\nUse carefully.\n", encoding="utf-8")
file_path = tmp_path / "files" / "report.pdf"
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_bytes(b"pdf")
return {
"tender-analyzer/SKILL.md": str(skill_path),
"files/report.pdf": str(file_path),
}
monkeypatch.setattr(layer, "_fetch_manifest_items", _fetch_manifest_items)
monkeypatch.setattr(layer, "_download_items", _download_items)
await layer.on_context_resume()
prompt = layer.build_prompt_context()
assert "Loaded mentioned skills" in prompt
assert "# Tender Analyzer\nUse carefully." in prompt
assert f"files/report.pdf -> {tmp_path / 'files' / 'report.pdf'}" in prompt
assert "Other available skills" in prompt
assert "other-skill: Other Skill — Fallback catalog entry." in prompt
@pytest.mark.anyio
async def test_on_context_create_raises_when_mentioned_file_is_missing(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
layer = _build_layer(tmp_path)
async def _fetch_manifest_items(*, tenant_id: str, targets: list[tuple[str, bool]]) -> list[_DriveManifestItem]:
del tenant_id, targets
return [_DriveManifestItem(key="tender-analyzer/SKILL.md", download_url="https://files/skill-md")]
async def _download_items(items: list[_DriveManifestItem]) -> dict[str, str]:
del items
skill_path = tmp_path / "tender-analyzer" / "SKILL.md"
skill_path.parent.mkdir(parents=True, exist_ok=True)
skill_path.write_text("# Tender Analyzer\nUse carefully.\n", encoding="utf-8")
return {"tender-analyzer/SKILL.md": str(skill_path)}
monkeypatch.setattr(layer, "_fetch_manifest_items", _fetch_manifest_items)
monkeypatch.setattr(layer, "_download_items", _download_items)
with pytest.raises(DifyDriveLayerError, match="missing pulled file"):
await layer.on_context_create()
@pytest.mark.anyio
async def test_on_context_resume_raises_when_mentioned_targets_are_missing(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
layer = _build_layer(tmp_path)
async def _fetch_manifest_items(*, tenant_id: str, targets: list[tuple[str, bool]]) -> list[_DriveManifestItem]:
del tenant_id, targets
return []
async def _download_items(items: list[_DriveManifestItem]) -> dict[str, str]:
assert items == []
return {}
monkeypatch.setattr(layer, "_fetch_manifest_items", _fetch_manifest_items)
monkeypatch.setattr(layer, "_download_items", _download_items)
with pytest.raises(DifyDriveLayerError, match="missing pulled file"):
await layer.on_context_resume()
@pytest.mark.anyio
async def test_on_context_create_raises_when_manifest_is_empty_for_mentioned_targets(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
layer = _build_layer(tmp_path)
async def _fetch_manifest_items(*, tenant_id: str, targets: list[tuple[str, bool]]) -> list[_DriveManifestItem]:
del tenant_id, targets
return []
async def _download_items(items: list[_DriveManifestItem]) -> dict[str, str]:
assert items == []
return {}
monkeypatch.setattr(layer, "_fetch_manifest_items", _fetch_manifest_items)
monkeypatch.setattr(layer, "_download_items", _download_items)
with pytest.raises(DifyDriveLayerError, match="missing pulled file"):
await layer.on_context_create()

View File

@ -111,7 +111,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_drive.__all__ == ['DIFY_DRIVE_LAYER_TYPE_ID', '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

@ -257,9 +257,17 @@ export type SandboxUploadResponse = {
path: string
}
export type AgentUploadedSkillResponse = {
archive_key?: string | null
description: string
name: string
path: string
skill_md_key: string
}
export type AgentSkillUploadResponse = {
manifest: SkillManifest
skill: AgentSkillRefConfig
skill: AgentUploadedSkillResponse
}
export type SkillToolInferenceResult = {
@ -449,7 +457,6 @@ export type AgentSoulConfig = {
prompt?: AgentSoulPromptConfig
sandbox?: AgentSoulSandboxConfig
schema_version?: number
skills_files?: AgentSoulSkillsFilesConfig
tools?: AgentSoulToolsConfig
}
@ -499,14 +506,6 @@ export type AgentComposerSoulCandidatesResponse = {
dify_tools?: Array<AgentComposerDifyToolCandidateResponse>
human_contacts?: Array<AgentHumanContactConfig>
knowledge_datasets?: Array<AgentKnowledgeDatasetConfig>
skills_files?: Array<
| ({
kind: 'skill'
} & AgentComposerSkillCandidateResponse)
| ({
kind: 'file'
} & AgentComposerFileCandidateResponse)
>
}
export type ComposerCandidateCapabilities = {
@ -873,11 +872,6 @@ export type AgentSoulSandboxConfig = {
provider?: string | null
}
export type AgentSoulSkillsFilesConfig = {
files?: Array<AgentFileRefConfig>
skills?: Array<AgentSkillRefConfig>
}
export type AgentSoulToolsConfig = {
cli_tools?: Array<AgentCliToolConfig>
dify_tools?: Array<AgentSoulDifyToolConfig>
@ -1000,37 +994,6 @@ export type AgentKnowledgeDatasetConfig = {
[key: string]: unknown
}
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'
name?: string | null
reference?: string | null
remote_url?: string | null
tenant_id?: string | null
transfer_method?: string | null
type?: string | null
upload_file_id?: string | null
url?: string | null
[key: string]: unknown
}
export type AgentModerationProviderConfig = {
api_based_extension_id?: string | null
inputs_config?: AgentModerationIoConfig | null

View File

@ -551,12 +551,23 @@ export const zAgentSkillRefConfig = z.object({
skill_md_key: z.string().max(512).nullish(),
})
/**
* AgentUploadedSkillResponse
*/
export const zAgentUploadedSkillResponse = z.object({
archive_key: z.string().nullish(),
description: z.string(),
name: z.string(),
path: z.string(),
skill_md_key: z.string(),
})
/**
* AgentSkillUploadResponse
*/
export const zAgentSkillUploadResponse = z.object({
manifest: zSkillManifest,
skill: zAgentSkillRefConfig,
skill: zAgentUploadedSkillResponse,
})
/**
@ -909,41 +920,6 @@ export const zAgentKnowledgeDatasetConfig = z.object({
name: z.string().max(255).nullish(),
})
/**
* AgentComposerSkillCandidateResponse
*/
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'),
name: z.string().max(255).nullish(),
reference: z.string().max(255).nullish(),
remote_url: z.string().nullish(),
tenant_id: z.string().max(255).nullish(),
transfer_method: z.string().max(64).nullish(),
type: z.string().max(64).nullish(),
upload_file_id: z.string().max(255).nullish(),
url: z.string().nullish(),
})
/**
* SimpleAccount
*/
@ -1280,14 +1256,6 @@ export const zAgentFileRefConfig = z.object({
url: z.string().nullish(),
})
/**
* AgentSoulSkillsFilesConfig
*/
export const zAgentSoulSkillsFilesConfig = z.object({
files: z.array(zAgentFileRefConfig).optional(),
skills: z.array(zAgentSkillRefConfig).optional(),
})
/**
* WorkflowNodeJobMetadata
*/
@ -1449,22 +1417,6 @@ export const zAgentComposerSoulCandidatesResponse = z.object({
dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(),
human_contacts: z.array(zAgentHumanContactConfig).optional(),
knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(),
skills_files: z
.array(
z.union([
z
.object({
kind: z.literal('skill'),
})
.and(zAgentComposerSkillCandidateResponse),
z
.object({
kind: z.literal('file'),
})
.and(zAgentComposerFileCandidateResponse),
]),
)
.optional(),
})
/**
@ -1684,7 +1636,6 @@ export const zAgentSoulConfig = z.object({
prompt: zAgentSoulPromptConfig.optional(),
sandbox: zAgentSoulSandboxConfig.optional(),
schema_version: z.int().optional().default(1),
skills_files: zAgentSoulSkillsFilesConfig.optional(),
tools: zAgentSoulToolsConfig.optional(),
})

View File

@ -214,9 +214,17 @@ export type AgentLogResponse = {
meta: AgentLogMetaResponse
}
export type AgentUploadedSkillResponse = {
archive_key?: string | null
description: string
name: string
path: string
skill_md_key: string
}
export type AgentSkillUploadResponse = {
manifest: SkillManifest
skill: AgentSkillRefConfig
skill: AgentUploadedSkillResponse
}
export type SkillToolInferenceResult = {
@ -1750,7 +1758,6 @@ export type AgentSoulConfig = {
prompt?: AgentSoulPromptConfig
sandbox?: AgentSoulSandboxConfig
schema_version?: number
skills_files?: AgentSoulSkillsFilesConfig
tools?: AgentSoulToolsConfig
}
@ -1847,14 +1854,6 @@ export type AgentComposerSoulCandidatesResponse = {
dify_tools?: Array<AgentComposerDifyToolCandidateResponse>
human_contacts?: Array<AgentHumanContactConfig>
knowledge_datasets?: Array<AgentKnowledgeDatasetConfig>
skills_files?: Array<
| ({
kind: 'skill'
} & AgentComposerSkillCandidateResponse)
| ({
kind: 'file'
} & AgentComposerFileCandidateResponse)
>
}
export type ComposerCandidateCapabilities = {
@ -2116,11 +2115,6 @@ export type AgentSoulSandboxConfig = {
provider?: string | null
}
export type AgentSoulSkillsFilesConfig = {
files?: Array<AgentFileRefConfig>
skills?: Array<AgentSkillRefConfig>
}
export type AgentSoulToolsConfig = {
cli_tools?: Array<AgentCliToolConfig>
dify_tools?: Array<AgentSoulDifyToolConfig>
@ -2254,37 +2248,6 @@ export type AgentKnowledgeDatasetConfig = {
[key: string]: unknown
}
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'
name?: string | null
reference?: string | null
remote_url?: string | null
tenant_id?: string | null
transfer_method?: string | null
type?: string | null
upload_file_id?: string | null
url?: string | null
[key: string]: unknown
}
export type CheckResultView = {
passed: boolean
reason?: string | null

View File

@ -992,12 +992,23 @@ export const zAgentSkillRefConfig = z.object({
skill_md_key: z.string().max(512).nullish(),
})
/**
* AgentUploadedSkillResponse
*/
export const zAgentUploadedSkillResponse = z.object({
archive_key: z.string().nullish(),
description: z.string(),
name: z.string(),
path: z.string(),
skill_md_key: z.string(),
})
/**
* AgentSkillUploadResponse
*/
export const zAgentSkillUploadResponse = z.object({
manifest: zSkillManifest,
skill: zAgentSkillRefConfig,
skill: zAgentUploadedSkillResponse,
})
/**
@ -2579,41 +2590,6 @@ export const zAgentKnowledgeDatasetConfig = z.object({
name: z.string().max(255).nullish(),
})
/**
* AgentComposerSkillCandidateResponse
*/
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'),
name: z.string().max(255).nullish(),
reference: z.string().max(255).nullish(),
remote_url: z.string().nullish(),
tenant_id: z.string().max(255).nullish(),
transfer_method: z.string().max(64).nullish(),
type: z.string().max(64).nullish(),
upload_file_id: z.string().max(255).nullish(),
url: z.string().nullish(),
})
/**
* CheckResultView
*
@ -2832,14 +2808,6 @@ export const zAgentFileRefConfig = z.object({
url: z.string().nullish(),
})
/**
* AgentSoulSkillsFilesConfig
*/
export const zAgentSoulSkillsFilesConfig = z.object({
files: z.array(zAgentFileRefConfig).optional(),
skills: z.array(zAgentSkillRefConfig).optional(),
})
/**
* WorkflowNodeJobMetadata
*/
@ -2994,22 +2962,6 @@ export const zAgentComposerSoulCandidatesResponse = z.object({
dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(),
human_contacts: z.array(zAgentHumanContactConfig).optional(),
knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(),
skills_files: z
.array(
z.union([
z
.object({
kind: z.literal('skill'),
})
.and(zAgentComposerSkillCandidateResponse),
z
.object({
kind: z.literal('file'),
})
.and(zAgentComposerFileCandidateResponse),
]),
)
.optional(),
})
/**
@ -3306,7 +3258,6 @@ export const zAgentSoulConfig = z.object({
prompt: zAgentSoulPromptConfig.optional(),
sandbox: zAgentSoulSandboxConfig.optional(),
schema_version: z.int().optional().default(1),
skills_files: zAgentSoulSkillsFilesConfig.optional(),
tools: zAgentSoulToolsConfig.optional(),
})

View File

@ -1,4 +1,4 @@
import type { ReactNode } from 'react'
import type { ComponentProps, ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
Drawer,
@ -11,6 +11,7 @@ import {
type DrawerSide = 'right' | 'left' | 'bottom' | 'top'
type DrawerSwipeDirection = 'right' | 'left' | 'down' | 'up'
type DrawerOpenChange = NonNullable<ComponentProps<typeof Drawer>['onOpenChange']>
type CompletedDrawerProps = {
open: boolean
@ -46,6 +47,16 @@ export function CompletedDrawer({
panelContentClassName,
modal = false,
}: CompletedDrawerProps) {
const handleOpenChange: DrawerOpenChange = (nextOpen, eventDetails) => {
if (nextOpen)
return
if (eventDetails.reason === 'focus-out' || eventDetails.reason === 'outside-press')
return
onClose()
}
if (!open)
return null
@ -55,7 +66,7 @@ export function CompletedDrawer({
modal={modal}
swipeDirection={SIDE_TO_SWIPE_DIRECTION[side]}
disablePointerDismissal
onOpenChange={nextOpen => !nextOpen && onClose()}
onOpenChange={handleOpenChange}
>
<DrawerPortal>
{modal && (

View File

@ -79,6 +79,7 @@ function AgentOrchestrateDrawerPanelContent({
<AgentOrchestratePanel
agentId={agentId}
appId={isInline ? appId : undefined}
nodeId={isInline ? nodeId : undefined}
activeConfigSnapshot={activeConfigSnapshot}
agentSoulConfig={agentSoulConfig as AgentSoulConfig}
agentName={composerState?.agent?.name}

View File

@ -0,0 +1,60 @@
import { type } from '@orpc/contract'
import { base } from '../base'
type AgentDriveSkillItem = {
path: string
skill_md_key: string
archive_key?: string | null
name: string
description: string
size?: number | null
mime_type?: string | null
hash?: string | null
created_at?: number | null
}
const agentDriveSkillsByAgentContract = base
.route({
path: '/agent/{agent_id}/drive/skills',
method: 'GET',
})
.input(type<{
params: {
agent_id: string
}
}>())
.output(type<{ items: AgentDriveSkillItem[] }>())
const agentDriveSkillsByAppContract = base
.route({
path: '/apps/{app_id}/agent/drive/skills',
method: 'GET',
})
.input(type<{
params: {
app_id: string
}
query?: {
node_id?: string
}
}>())
.output(type<{ items: AgentDriveSkillItem[] }>())
export const agentDriveContracts = {
byAgentId: {
drive: {
skills: {
get: agentDriveSkillsByAgentContract,
},
},
},
byAppId: {
agent: {
drive: {
skills: {
get: agentDriveSkillsByAppContract,
},
},
},
},
}

View File

@ -2,6 +2,7 @@ import type { InferContractRouterInputs } from '@orpc/contract'
import { contract as communityContract } from '@dify/contracts/api/console/orpc.gen'
import { contract as enterpriseContract } from '@dify/contracts/enterprise/orpc.gen'
import { rbacAccessConfigContract } from './console/access-control'
import { agentDriveContracts } from './console/agent-drive'
import {
appDeleteContract,
appListContract,
@ -114,9 +115,26 @@ export const consoleRouterContract = {
workflowOnlineUsers: workflowOnlineUsersContract,
byAppId: {
...communityContract.apps.byAppId,
agent: {
...communityContract.apps.byAppId.agent,
...agentDriveContracts.byAppId.agent,
drive: {
...communityContract.apps.byAppId.agent.drive,
...agentDriveContracts.byAppId.agent.drive,
},
},
},
},
agent: {
...communityContract.agent,
byAgentId: {
...communityContract.agent.byAgentId,
drive: {
...communityContract.agent.byAgentId.drive,
...agentDriveContracts.byAgentId.drive,
},
},
},
agent: communityContract.agent,
explore: {
...communityContract.explore,
apps: exploreAppsContract,

View File

@ -50,27 +50,6 @@ describe('agent composer store conversions', () => {
prompt: {
system_prompt: 'Be precise.',
},
skills_files: {
files: [
{
id: 'file-1',
name: 'guide.md',
type: 'markdown',
},
],
skills: [
{
id: 'skill-1',
file_id: 'archive-file-1',
full_archive_file_id: 'archive-file-1',
full_archive_key: 'research-skill/.DIFY-SKILL-FULL.zip',
name: 'Research Skill',
path: 'research-skill',
skill_md_file_id: 'skill-md-file-1',
skill_md_key: 'research-skill/SKILL.md',
},
],
},
tools: {
cli_tools: [
{
@ -119,25 +98,6 @@ describe('agent composer store conversions', () => {
provider: 'openai',
plugin_id: 'openai',
},
skills: [
{
fileId: 'archive-file-1',
fullArchiveFileId: 'archive-file-1',
fullArchiveKey: 'research-skill/.DIFY-SKILL-FULL.zip',
id: 'skill-1',
name: 'Research Skill',
path: 'research-skill',
skillMdFileId: 'skill-md-file-1',
skillMdKey: 'research-skill/SKILL.md',
},
],
files: [
{
id: 'file-1',
name: 'guide.md',
icon: 'markdown',
},
],
knowledgeRetrievals: [
expect.objectContaining({
id: 'dataset-1',
@ -181,7 +141,7 @@ describe('agent composer store conversions', () => {
used_in_agent_nodes: true,
})
expect(publishConfig.skills_files).toMatchObject(baseConfig.skills_files!)
expect(publishConfig).not.toHaveProperty('skills_files')
expect(publishConfig.tools?.dify_tools).toEqual([
expect.objectContaining({
provider: 'DuckDuckGo',

View File

@ -1,10 +1,8 @@
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
import type {
AgentCliTool,
AgentFileNode,
AgentKnowledgeRetrievalItem,
AgentProviderTool,
AgentSkill,
AgentSoulConfigFormState,
AgentTool,
EnvVariable,
@ -16,79 +14,8 @@ import { defaultAgentSoulConfigFormState } from './form-state'
type AgentSoulDifyToolConfig = NonNullable<NonNullable<AgentSoulConfig['tools']>['dify_tools']>[number]
type AgentSoulCliToolConfig = NonNullable<NonNullable<AgentSoulConfig['tools']>['cli_tools']>[number]
type AgentSoulToolRuntimeParameterValue = NonNullable<AgentSoulDifyToolConfig['runtime_parameters']>[string]
type AgentSoulFileRefConfig = NonNullable<NonNullable<AgentSoulConfig['skills_files']>['files']>[number]
type AgentSoulEnvVariableConfig = NonNullable<NonNullable<AgentSoulConfig['env']>['variables']>[number]
const flattenFileNodes = (files: AgentFileNode[]): AgentFileNode[] => files.flatMap(file => [
file,
...flattenFileNodes(file.children ?? []),
])
const toSkillRefs = (skills: AgentSkill[]) => skills.map(skill => ({
description: skill.description,
file_id: skill.fileId,
full_archive_file_id: skill.fullArchiveFileId,
full_archive_key: skill.fullArchiveKey,
id: skill.id,
manifest_files: skill.files,
name: skill.name,
path: skill.path,
skill_md_file_id: skill.skillMdFileId,
skill_md_key: skill.skillMdKey,
}))
const toSkillFormState = (config?: AgentSoulConfig): AgentSkill[] => (
config?.skills_files?.skills ?? []
).flatMap((skill) => {
const id = skill.id ?? skill.file_id ?? skill.path
if (!id)
return []
return [{
description: skill.description ?? undefined,
fileId: skill.file_id ?? undefined,
files: skill.manifest_files ?? undefined,
fullArchiveFileId: skill.full_archive_file_id ?? undefined,
fullArchiveKey: skill.full_archive_key ?? undefined,
id,
name: skill.name ?? id,
path: skill.path ?? undefined,
skillMdFileId: skill.skill_md_file_id ?? undefined,
skillMdKey: skill.skill_md_key ?? undefined,
}]
})
const toFileIcon = (file: AgentSoulFileRefConfig): AgentFileNode['icon'] => {
const type = file.type?.toLowerCase()
if (type === 'image' || type === 'pdf' || type === 'markdown' || type === 'json' || type === 'table' || type === 'archive' || type === 'code' || type === 'text' || type === 'folder')
return type
return 'file'
}
const toFileFormState = (config?: AgentSoulConfig): AgentFileNode[] => (
config?.skills_files?.files ?? []
).flatMap((file) => {
const id = file.id ?? file.file_id ?? file.upload_file_id ?? file.reference ?? file.remote_url ?? file.url
if (!id)
return []
return [{
id,
name: file.name ?? id,
icon: toFileIcon(file),
driveKey: file.drive_key ?? undefined,
}]
})
const toFileRefs = (files: AgentFileNode[]) => flattenFileNodes(files).map(file => ({
...(file.driveKey ? { drive_key: file.driveKey } : {}),
id: file.id,
name: file.name,
type: file.icon,
}))
const getKnowledgeRetrievalName = (item: AgentKnowledgeRetrievalItem) => item.name ?? item.nameKey ?? item.id
const toKnowledgeDatasets = (knowledgeRetrievals: AgentKnowledgeRetrievalItem[]) => knowledgeRetrievals.flatMap((item) => {
@ -448,34 +375,31 @@ export const formStateToAgentSoulConfig = ({
baseConfig?: AgentSoulConfig
formState: AgentSoulConfigFormState
currentModel?: DefaultModel
}): AgentSoulConfig => ({
...baseConfig,
prompt: {
...baseConfig?.prompt,
system_prompt: formState.prompt,
},
model: currentModel
? {
...baseConfig?.model,
model_provider: currentModel.provider,
model: currentModel.model,
plugin_id: getModelProviderPluginId(currentModel, baseConfig?.model),
}
: baseConfig?.model,
skills_files: {
...baseConfig?.skills_files,
skills: toSkillRefs(formState.skills),
files: toFileRefs(formState.files),
},
tools: {
...baseConfig?.tools,
dify_tools: toDifyToolConfigs(formState.tools, formState.toolSettings),
cli_tools: toCliToolConfigs(formState.tools),
},
app_features: formState.appFeatures ?? baseConfig?.app_features,
knowledge: toKnowledgeConfig(baseConfig?.knowledge, formState.knowledgeRetrievals),
env: toEnvConfig(formState.envVariables),
})
}): AgentSoulConfig => {
return {
...baseConfig,
prompt: {
...baseConfig?.prompt,
system_prompt: formState.prompt,
},
model: currentModel
? {
...baseConfig?.model,
model_provider: currentModel.provider,
model: currentModel.model,
plugin_id: getModelProviderPluginId(currentModel, baseConfig?.model),
}
: baseConfig?.model,
tools: {
...baseConfig?.tools,
dify_tools: toDifyToolConfigs(formState.tools, formState.toolSettings),
cli_tools: toCliToolConfigs(formState.tools),
},
app_features: formState.appFeatures ?? baseConfig?.app_features,
knowledge: toKnowledgeConfig(baseConfig?.knowledge, formState.knowledgeRetrievals),
env: toEnvConfig(formState.envVariables),
}
}
export const agentSoulConfigToFormState = (
config?: AgentSoulConfig,
@ -488,8 +412,6 @@ export const agentSoulConfigToFormState = (
prompt: config?.prompt?.system_prompt ?? '',
model: toDraftModel(config),
appFeatures: config?.app_features,
skills: toSkillFormState(config),
files: toFileFormState(config),
tools: [
...providerToolState.tools,
...toCliToolFormState(config),

View File

@ -24,14 +24,10 @@ export type EnvVariable = {
export type AgentSkill = {
description?: string
files?: string[]
fileId?: string
fullArchiveFileId?: string
fullArchiveKey?: string
archiveKey?: string
id: string
name: string
path?: string
skillMdFileId?: string
skillMdKey?: string
}
@ -100,8 +96,6 @@ export type AgentSoulConfigFormState = {
prompt: string
model?: DefaultModel
appFeatures?: AgentSoulAppFeaturesConfig
skills: AgentSkill[]
files: AgentFileNode[]
tools: AgentTool[]
knowledgeRetrievals: AgentKnowledgeRetrievalItem[]
envVariables: EnvVariable[]
@ -110,8 +104,6 @@ export type AgentSoulConfigFormState = {
export const defaultAgentSoulConfigFormState: AgentSoulConfigFormState = {
prompt: '',
skills: [],
files: [],
tools: [],
knowledgeRetrievals: [],
envVariables: [],

View File

@ -1,17 +0,0 @@
import type { AgentFileNode } from '../form-state'
import type { DraftFieldUpdate } from './utils'
import { atom } from 'jotai'
import { agentComposerDraftAtom } from '../store'
import { resolveDraftFieldUpdate } from './utils'
export const agentComposerFilesAtom = atom(
get => get(agentComposerDraftAtom).files,
(get, set, filesUpdate: DraftFieldUpdate<AgentFileNode[]>) => {
const draft = get(agentComposerDraftAtom)
set(agentComposerDraftAtom, {
...draft,
files: resolveDraftFieldUpdate(draft.files, filesUpdate),
})
},
)

View File

@ -1,26 +0,0 @@
import type { AgentSkill } from '../form-state'
import type { DraftFieldUpdate } from './utils'
import { atom, useSetAtom } from 'jotai'
import { useCallback } from 'react'
import { agentComposerDraftAtom } from '../store'
import { resolveDraftFieldUpdate } from './utils'
export const agentComposerSkillsAtom = atom(
get => get(agentComposerDraftAtom).skills,
(get, set, skillsUpdate: DraftFieldUpdate<AgentSkill[]>) => {
const draft = get(agentComposerDraftAtom)
set(agentComposerDraftAtom, {
...draft,
skills: resolveDraftFieldUpdate(draft.skills, skillsUpdate),
})
},
)
export function useRemoveSkill() {
const setSkills = useSetAtom(agentComposerSkillsAtom)
return useCallback((skillId: string) => {
setSkills(skills => skills.filter(skill => skill.id !== skillId))
}, [setSkills])
}

View File

@ -91,6 +91,19 @@ vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'light' }),
}))
vi.mock('../orchestrate/drive-context', () => ({
useAgentDriveSkills: () => ({
skills: [
{
id: 'playwright/SKILL.md',
name: 'Playwright',
skillMdKey: 'playwright/SKILL.md',
},
],
}),
useAgentDriveFiles: () => ({ files: [] }),
}))
const duckDuckGoSearchAction = {
id: 'duckduckgo-search',
name: 'DuckDuckGo Search',
@ -112,12 +125,6 @@ const duckDuckGoProviderTool: AgentTool = {
const promptEditorDraft = {
...defaultAgentSoulConfigFormState,
skills: [
{
id: 'playwright',
name: 'Playwright',
},
],
tools: [duckDuckGoProviderTool],
} satisfies typeof defaultAgentSoulConfigFormState
@ -268,7 +275,7 @@ describe('AgentPromptEditor', () => {
fireEvent.click(screen.getByRole('button', { name: /Playwright/i }))
expect(store.get(agentComposerPromptAtom)).toBe('Review these tenders [§skill:playwright:Playwright§]')
expect(store.get(agentComposerPromptAtom)).toBe('Review these tenders [§skill:playwright%2FSKILL.md:Playwright§]')
await waitFor(() => {
expect(screen.queryByRole('button', { name: /Playwright/i })).not.toBeInTheDocument()
})
@ -305,7 +312,7 @@ describe('AgentPromptEditor', () => {
files={[]}
tools={[]}
onToolsChange={vi.fn()}
onAddSkill={options => options?.onAdded?.({ id: 'skill-1', name: 'Skill One' })}
onAddSkill={options => options?.onAdded?.({ id: 'skill-1', name: 'Skill One', skillMdKey: 'skills/skill-1/SKILL.md' })}
retrievals={[]}
onBack={vi.fn()}
onOpenCategory={vi.fn()}
@ -313,7 +320,7 @@ describe('AgentPromptEditor', () => {
/>,
)
fireEvent.click(screen.getByRole('button', { name: /agentDetail\.configure\.skills\.add/i }))
expect(onSelect).toHaveBeenCalledWith('[§skill:skill-1:Skill One§]')
expect(onSelect).toHaveBeenCalledWith('[§skill:skills%2Fskill-1%2FSKILL.md:Skill One§]')
rerender(
<AgentPromptSlashMenu
@ -323,7 +330,7 @@ describe('AgentPromptEditor', () => {
files={[]}
tools={[]}
onToolsChange={vi.fn()}
onAddFile={options => options?.onAdded?.({ id: 'file-1', name: 'Guide.md', icon: 'markdown' })}
onAddFile={options => options?.onAdded?.({ id: 'file-1', name: 'Guide.md', icon: 'markdown', driveKey: 'files/Guide.md' })}
retrievals={[]}
onBack={vi.fn()}
onOpenCategory={vi.fn()}
@ -331,7 +338,7 @@ describe('AgentPromptEditor', () => {
/>,
)
fireEvent.click(screen.getByRole('button', { name: /agentDetail\.configure\.files\.add/i }))
expect(onSelect).toHaveBeenCalledWith('[§file:file-1:Guide.md§]')
expect(onSelect).toHaveBeenCalledWith('[§file:files%2FGuide.md:Guide.md§]')
rerender(
<AgentPromptSlashMenu

View File

@ -8,6 +8,14 @@ import { AgentKnowledgeRetrieval } from '../knowledge'
import { AgentSkills } from '../skills'
import { AgentTools } from '../tools'
vi.mock('../drive-context', () => ({
FILES_DRIVE_PREFIX: 'files/',
getAgentDriveFileName: (key: string) => key.split('/').pop() ?? key,
useAgentDriveApiContext: () => ({ agentId: 'agent-1' }),
useAgentDriveFiles: () => ({ files: [], query: { refetch: vi.fn() } }),
useAgentDriveSkills: () => ({ skills: [], query: { refetch: vi.fn() } }),
}))
function renderEmptySections() {
const queryClient = new QueryClient()
@ -16,14 +24,12 @@ function renderEmptySections() {
<AgentComposerProvider
initialDraft={{
...defaultAgentSoulConfigFormState,
files: [],
knowledgeRetrievals: [],
skills: [],
tools: [],
}}
>
<AgentSkills agentId="agent-1" />
<AgentFiles agentId="agent-1" />
<AgentSkills />
<AgentFiles />
<AgentTools />
<AgentKnowledgeRetrieval />
</AgentComposerProvider>

View File

@ -111,11 +111,12 @@ const activeConfigSnapshot: AgentConfigSnapshotSummaryResponse = {
const originalDraftWithFile = {
...defaultAgentSoulConfigFormState,
files: [
tools: [
{
id: 'preview-image',
name: 'agent-roster-skill-detail-dialog-preview-image.png',
icon: 'image',
id: 'cli-1',
kind: 'cli',
name: 'Run tests',
installCommand: 'pnpm test',
},
],
} satisfies typeof defaultAgentSoulConfigFormState
@ -299,7 +300,7 @@ describe('AgentConfigurePublishBar', () => {
store.set(agentComposerOriginalDraftAtom, originalDraftWithFile)
store.set(agentComposerDraftAtom, {
...originalDraftWithFile,
files: [],
tools: [],
})
},
})

View File

@ -0,0 +1,146 @@
'use client'
import type { AgentDriveItemResponse } from '@dify/contracts/api/console/agent/types.gen'
import type { AgentFileNode, AgentSkill } from '@/features/agent-v2/agent-composer/form-state'
import { useQuery } from '@tanstack/react-query'
import { createContext, use, useMemo } from 'react'
import { consoleQuery } from '@/service/client'
import { getDriveFileIconType } from './files/file-icon'
export type AgentDriveApiContext = {
agentId: string
workflow?: {
appId: string
nodeId: string
}
}
export const FILES_DRIVE_PREFIX = 'files/'
const AgentDriveApiContext = createContext<AgentDriveApiContext | null>(null)
export const AgentDriveApiContextProvider = AgentDriveApiContext.Provider
const getAgentDriveFileName = (key: string) => {
const normalizedKey = key.endsWith('/') ? key.slice(0, -1) : key
return normalizedKey.split('/').pop() || normalizedKey
}
const toAgentSkill = (item: {
archive_key?: string | null
description: string
name: string
path: string
skill_md_key: string
}): AgentSkill => ({
id: item.skill_md_key,
name: item.name,
description: item.description || undefined,
path: item.path,
skillMdKey: item.skill_md_key,
archiveKey: item.archive_key ?? undefined,
})
const toAgentFileNodeFromDriveItem = (item: {
file_kind: string
key: string
mime_type?: string | null
}): AgentFileNode => ({
id: item.key,
name: getAgentDriveFileName(item.key),
icon: getDriveFileIconType({
fileKind: item.file_kind,
fileName: getAgentDriveFileName(item.key),
mimeType: item.mime_type,
}),
driveKey: item.key,
})
export const useAgentDriveApiContext = () => {
const context = use(AgentDriveApiContext)
if (!context)
throw new Error('AgentDriveApiContextProvider is required for drive-backed UI.')
return context
}
export const useAgentDriveSkills = () => {
const apiContext = useAgentDriveApiContext()
const agentSkillsQuery = useQuery({
...consoleQuery.agent.byAgentId.drive.skills.get.queryOptions({
input: {
params: {
agent_id: apiContext.agentId,
},
},
}),
enabled: !apiContext.workflow,
})
const workflowSkillsQuery = useQuery({
...consoleQuery.apps.byAppId.agent.drive.skills.get.queryOptions({
input: {
params: {
app_id: apiContext.workflow?.appId ?? '',
},
query: {
node_id: apiContext.workflow?.nodeId,
},
},
}),
enabled: !!apiContext.workflow,
})
const query = apiContext.workflow ? workflowSkillsQuery : agentSkillsQuery
const skills = useMemo(() => (query.data?.items ?? []).map(toAgentSkill), [query.data?.items])
return {
apiContext,
query,
skills,
}
}
export const useAgentDriveFiles = ({
prefix = FILES_DRIVE_PREFIX,
}: {
prefix?: string
} = {}) => {
const apiContext = useAgentDriveApiContext()
const agentFilesQuery = useQuery({
...consoleQuery.agent.byAgentId.drive.files.get.queryOptions({
input: {
params: {
agent_id: apiContext.agentId,
},
query: {
prefix,
},
},
}),
enabled: !apiContext.workflow,
})
const workflowFilesQuery = useQuery({
...consoleQuery.apps.byAppId.agent.drive.files.get.queryOptions({
input: {
params: {
app_id: apiContext.workflow?.appId ?? '',
},
query: {
node_id: apiContext.workflow?.nodeId,
prefix,
},
},
}),
enabled: !!apiContext.workflow,
})
const query = apiContext.workflow ? workflowFilesQuery : agentFilesQuery
const files = useMemo(
() => (query.data?.items ?? []).map((item: AgentDriveItemResponse) => toAgentFileNodeFromDriveItem(item)),
[query.data?.items],
)
return {
apiContext,
query,
files,
}
}

View File

@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
import { AgentDriveApiContextProvider } from '../../drive-context'
import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
import { AgentFiles } from '../index'
@ -100,38 +101,10 @@ vi.mock('@/service/client', () => ({
const agentFilesDraft = {
...defaultAgentSoulConfigFormState,
files: [
{
id: 'preview-image',
name: 'agent-roster-skill-detail-dialog-preview-image.png',
icon: 'image',
driveKey: 'files/agent-roster-skill-detail-dialog-preview-image.png',
},
{
id: 'brief',
name: 'brief.md',
icon: 'markdown',
driveKey: 'files/brief.md',
},
],
} satisfies AgentSoulConfigFormState
const agentSkillFilesDraft = {
...defaultAgentSoulConfigFormState,
files: [
{
id: 'script',
name: 'run.py',
icon: 'file',
driveKey: 'files/run.py',
},
{
id: 'skill-md',
name: 'SKILL.md',
icon: 'markdown',
driveKey: 'files/SKILL.md',
},
],
} satisfies AgentSoulConfigFormState
function renderAgentFiles(initialDraft: AgentSoulConfigFormState = agentFilesDraft) {
@ -145,9 +118,11 @@ function renderAgentFiles(initialDraft: AgentSoulConfigFormState = agentFilesDra
return render(
<QueryClientProvider client={queryClient}>
<AgentComposerProvider initialDraft={initialDraft}>
<AgentFiles agentId="agent-1" />
</AgentComposerProvider>
<AgentDriveApiContextProvider value={{ agentId: 'agent-1' }}>
<AgentComposerProvider initialDraft={initialDraft}>
<AgentFiles />
</AgentComposerProvider>
</AgentDriveApiContextProvider>
</QueryClientProvider>,
)
}
@ -163,11 +138,33 @@ function renderReadonlyAgentFiles() {
return render(
<QueryClientProvider client={queryClient}>
<AgentComposerProvider initialDraft={agentFilesDraft}>
<AgentOrchestrateReadOnlyContext value>
<AgentFiles agentId="agent-1" />
</AgentOrchestrateReadOnlyContext>
</AgentComposerProvider>
<AgentDriveApiContextProvider value={{ agentId: 'agent-1' }}>
<AgentComposerProvider initialDraft={agentFilesDraft}>
<AgentOrchestrateReadOnlyContext value>
<AgentFiles />
</AgentOrchestrateReadOnlyContext>
</AgentComposerProvider>
</AgentDriveApiContextProvider>
</QueryClientProvider>,
)
}
function renderWorkflowAgentFiles(initialDraft: AgentSoulConfigFormState = agentFilesDraft) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={queryClient}>
<AgentDriveApiContextProvider value={{ agentId: 'agent-1', workflow: { appId: 'app-1', nodeId: 'node-1' } }}>
<AgentComposerProvider initialDraft={initialDraft}>
<AgentFiles />
</AgentComposerProvider>
</AgentDriveApiContextProvider>
</QueryClientProvider>,
)
}
@ -175,13 +172,31 @@ function renderReadonlyAgentFiles() {
describe('AgentFiles', () => {
beforeEach(() => {
vi.clearAllMocks()
const baseItems = [
{
file_kind: 'file',
key: 'files/agent-roster-skill-detail-dialog-preview-image.png',
mime_type: 'image/png',
},
{
file_kind: 'file',
key: 'files/brief.md',
mime_type: 'text/markdown',
},
]
mocks.agentDriveFilesQueryOptions.mockImplementation(({ input }) => ({
queryKey: ['agent-drive-files', input],
queryFn: () => new Promise(() => {}),
initialData: { items: baseItems },
queryFn: async () => ({
items: baseItems,
}),
}))
mocks.workflowAgentDriveFilesQueryOptions.mockImplementation(({ input }) => ({
queryKey: ['workflow-agent-drive-files', input],
queryFn: () => new Promise(() => {}),
initialData: { items: baseItems },
queryFn: async () => ({
items: baseItems,
}),
}))
mocks.agentFilePreviewQueryOptions.mockImplementation(({ input }) => ({
queryKey: ['agent-file-preview', input],
@ -264,6 +279,22 @@ describe('AgentFiles', () => {
})
})
it('should list workflow-node drive files under the files prefix', () => {
renderWorkflowAgentFiles()
expect(mocks.workflowAgentDriveFilesQueryOptions).toHaveBeenCalledWith({
input: {
params: {
app_id: 'app-1',
},
query: {
node_id: 'node-1',
prefix: 'files/',
},
},
})
})
it('should keep the file preview trigger focus ring inside the row bounds', () => {
renderAgentFiles()
@ -300,6 +331,21 @@ describe('AgentFiles', () => {
})
it('should preview the clicked file when SKILL.md also exists', async () => {
mocks.agentDriveFilesQueryOptions.mockImplementation(({ input }) => ({
queryKey: ['agent-drive-files', input],
initialData: {
items: [
{ file_kind: 'file', key: 'files/run.py', mime_type: 'text/x-python' },
{ file_kind: 'file', key: 'files/SKILL.md', mime_type: 'text/markdown' },
],
},
queryFn: async () => ({
items: [
{ file_kind: 'file', key: 'files/run.py', mime_type: 'text/x-python' },
{ file_kind: 'file', key: 'files/SKILL.md', mime_type: 'text/markdown' },
],
}),
}))
renderAgentFiles(agentSkillFilesDraft)
fireEvent.click(screen.getByRole('button', {
@ -322,6 +368,21 @@ describe('AgentFiles', () => {
})
it('should preview the selected file from the detail file tree', async () => {
mocks.agentDriveFilesQueryOptions.mockImplementation(({ input }) => ({
queryKey: ['agent-drive-files', input],
initialData: {
items: [
{ file_kind: 'file', key: 'files/run.py', mime_type: 'text/x-python' },
{ file_kind: 'file', key: 'files/SKILL.md', mime_type: 'text/markdown' },
],
},
queryFn: async () => ({
items: [
{ file_kind: 'file', key: 'files/run.py', mime_type: 'text/x-python' },
{ file_kind: 'file', key: 'files/SKILL.md', mime_type: 'text/markdown' },
],
}),
}))
renderAgentFiles(agentSkillFilesDraft)
fireEvent.click(screen.getByRole('button', {
@ -368,13 +429,7 @@ describe('AgentFiles', () => {
name: 'agent-roster-skill-detail-dialog-preview-image.png',
}))
const image = await screen.findByRole('img', {
name: 'agent-roster-skill-detail-dialog-preview-image.png',
})
expect(image).toHaveAttribute('src', 'https://signed.example/files/agent-roster-skill-detail-dialog-preview-image.png')
expect(screen.queryByRole('link', { name: /common\.operation\.download/ })).not.toBeInTheDocument()
expect(screen.queryByText('image preview should not render as text')).not.toBeInTheDocument()
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(mocks.agentFileDownloadQueryOptions).toHaveBeenCalledWith({
input: {
params: {
@ -411,10 +466,89 @@ describe('AgentFiles', () => {
expect(screen.queryByText('Preview content for files/brief.md')).not.toBeInTheDocument()
})
it('should use workflow preview and download routes when workflow context is active', async () => {
mocks.workflowAgentFilePreviewQueryOptions.mockImplementation(({ input }) => ({
queryKey: ['workflow-agent-file-preview', input],
queryFn: async () => ({
binary: true,
key: input.query.key,
size: 12345,
text: null,
truncated: false,
}),
}))
renderWorkflowAgentFiles()
fireEvent.click(screen.getByRole('button', {
name: 'brief.md',
}))
expect(mocks.workflowAgentFilePreviewQueryOptions).toHaveBeenCalledWith({
input: {
params: {
app_id: 'app-1',
},
query: {
node_id: 'node-1',
key: 'files/brief.md',
},
},
})
expect(mocks.workflowAgentFileDownloadQueryOptions).toHaveBeenCalledWith({
input: {
params: {
app_id: 'app-1',
},
query: {
node_id: 'node-1',
key: 'files/brief.md',
},
},
})
expect(await screen.findByRole('link', { name: 'common.operation.download' })).toHaveAttribute(
'href',
'https://signed.example/files/brief.md',
)
})
it('should commit an uploaded file to the Agent App drive before adding it to the composer draft', async () => {
const driveFiles = [
{
file_kind: 'file',
key: 'files/agent-roster-skill-detail-dialog-preview-image.png',
mime_type: 'image/png',
},
{
file_kind: 'file',
key: 'files/brief.md',
mime_type: 'text/markdown',
},
]
mocks.agentDriveFilesQueryOptions.mockImplementation(({ input }) => ({
queryKey: ['agent-drive-files', input],
initialData: { items: [...driveFiles] },
queryFn: async () => ({ items: [...driveFiles] }),
}))
mocks.agentFileCommitMutationOptions.mockReturnValue({
mutationFn: mocks.agentFileCommitMutationFn.mockImplementation(async () => {
driveFiles.push({
file_kind: 'file',
key: 'files/uploaded.md',
mime_type: 'text/markdown',
})
return {
file: {
drive_key: 'files/uploaded.md',
file_id: 'drive-file-1',
mime_type: 'text/markdown',
name: 'uploaded.md',
},
}
}),
mutationKey: ['commit-agent-file'],
})
renderAgentFiles({
...defaultAgentSoulConfigFormState,
files: [],
})
fireEvent.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.files.add' }))
@ -441,31 +575,188 @@ describe('AgentFiles', () => {
expect.anything(),
)
})
expect(await screen.findByRole('button', { name: 'uploaded.md' })).toBeInTheDocument()
})
it('should commit an uploaded file through workflow-node drive endpoints and refresh the list', async () => {
const driveFiles = [
{
file_kind: 'file',
key: 'files/agent-roster-skill-detail-dialog-preview-image.png',
mime_type: 'image/png',
},
{
file_kind: 'file',
key: 'files/brief.md',
mime_type: 'text/markdown',
},
]
mocks.workflowAgentDriveFilesQueryOptions.mockImplementation(({ input }) => ({
queryKey: ['workflow-agent-drive-files', input],
initialData: { items: [...driveFiles] },
queryFn: async () => ({ items: [...driveFiles] }),
}))
mocks.workflowAgentFileCommitMutationOptions.mockReturnValue({
mutationFn: mocks.workflowAgentFileCommitMutationFn.mockImplementation(async () => {
driveFiles.push({
file_kind: 'file',
key: 'files/uploaded.md',
mime_type: 'text/markdown',
})
return {
file: {
drive_key: 'files/uploaded.md',
file_id: 'drive-file-1',
mime_type: 'text/markdown',
name: 'uploaded.md',
},
}
}),
mutationKey: ['commit-workflow-agent-file'],
})
renderWorkflowAgentFiles({
...defaultAgentSoulConfigFormState,
})
fireEvent.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.files.add' }))
const input = document.querySelector('input[type="file"]')
expect(input).toBeInstanceOf(HTMLInputElement)
fireEvent.change(input!, {
target: {
files: [new File(['# Uploaded'], 'uploaded.md', { type: 'text/markdown' })],
},
})
fireEvent.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.files.upload.action' }))
await waitFor(() => {
expect(mocks.workflowAgentFileCommitMutationFn).toHaveBeenCalledWith(
{
params: {
app_id: 'app-1',
},
query: {
node_id: 'node-1',
},
body: {
upload_file_id: 'upload-file-1',
},
},
expect.anything(),
)
})
expect(await screen.findByRole('button', { name: 'uploaded.md' })).toBeInTheDocument()
})
// File rows expose a hover/focus remove action that updates the composer draft.
it('should remove the file from the list when the remove action is clicked', () => {
it('should delete the file when the remove action is clicked', async () => {
const driveFiles = [
{
file_kind: 'file',
key: 'files/agent-roster-skill-detail-dialog-preview-image.png',
mime_type: 'image/png',
},
{
file_kind: 'file',
key: 'files/brief.md',
mime_type: 'text/markdown',
},
]
mocks.agentDriveFilesQueryOptions.mockImplementation(({ input }) => ({
queryKey: ['agent-drive-files', input],
initialData: { items: [...driveFiles] },
queryFn: async () => ({ items: [...driveFiles] }),
}))
mocks.agentFileDeleteMutationOptions.mockReturnValue({
mutationFn: mocks.agentFileDeleteMutationFn.mockImplementation(async () => {
driveFiles.splice(0, 1)
return { result: 'success' }
}),
mutationKey: ['delete-agent-file'],
})
renderAgentFiles()
expect(screen.getByText('agent-roster-skill-detail-dialog-preview-image.png')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', {
name: /agentV2\.agentDetail\.configure\.files\.remove.*agent-roster-skill-detail-dialog-preview-image\.png/,
}))
expect(screen.queryByText('agent-roster-skill-detail-dialog-preview-image.png')).not.toBeInTheDocument()
expect(screen.getByText('brief.md')).toBeInTheDocument()
await waitFor(() => {
expect(mocks.agentFileDeleteMutationFn).toHaveBeenCalledWith(
{
params: {
agent_id: 'agent-1',
},
query: {
key: 'files/agent-roster-skill-detail-dialog-preview-image.png',
},
},
expect.anything(),
)
})
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'agent-roster-skill-detail-dialog-preview-image.png' })).not.toBeInTheDocument()
})
expect(screen.getByRole('button', { name: 'brief.md' })).toBeInTheDocument()
})
it('should render the empty state after removing every file', () => {
renderAgentFiles()
it('should delete a workflow-node file through workflow endpoints and refresh the list', async () => {
const driveFiles = [
{
file_kind: 'file',
key: 'files/agent-roster-skill-detail-dialog-preview-image.png',
mime_type: 'image/png',
},
{
file_kind: 'file',
key: 'files/brief.md',
mime_type: 'text/markdown',
},
]
mocks.workflowAgentDriveFilesQueryOptions.mockImplementation(({ input }) => ({
queryKey: ['workflow-agent-drive-files', input],
initialData: { items: [...driveFiles] },
queryFn: async () => ({ items: [...driveFiles] }),
}))
mocks.workflowAgentFileDeleteMutationOptions.mockReturnValue({
mutationFn: mocks.workflowAgentFileDeleteMutationFn.mockImplementation(async () => {
driveFiles.splice(0, 1)
return { result: 'success' }
}),
mutationKey: ['delete-workflow-agent-file'],
})
renderWorkflowAgentFiles()
fireEvent.click(screen.getByRole('button', {
name: /agentV2\.agentDetail\.configure\.files\.remove.*agent-roster-skill-detail-dialog-preview-image\.png/,
}))
fireEvent.click(screen.getByRole('button', {
name: /agentV2\.agentDetail\.configure\.files\.remove.*brief\.md/,
await waitFor(() => {
expect(mocks.workflowAgentFileDeleteMutationFn).toHaveBeenCalledWith(
{
params: {
app_id: 'app-1',
},
query: {
node_id: 'node-1',
key: 'files/agent-roster-skill-detail-dialog-preview-image.png',
},
},
expect.anything(),
)
})
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'agent-roster-skill-detail-dialog-preview-image.png' })).not.toBeInTheDocument()
})
expect(screen.getByRole('button', { name: 'brief.md' })).toBeInTheDocument()
})
it('should render the empty state when the drive file query returns no items', () => {
mocks.agentDriveFilesQueryOptions.mockImplementation(({ input }) => ({
queryKey: ['agent-drive-files', input],
initialData: { items: [] },
queryFn: async () => ({ items: [] }),
}))
renderAgentFiles()
expect(screen.getByText('agentV2.agentDetail.configure.files.empty.title')).toBeInTheDocument()
})

View File

@ -1,7 +0,0 @@
export type AgentFileApiContext = {
agentId: string
workflow?: {
appId: string
nodeId: string
}
}

View File

@ -2,7 +2,7 @@
import type { ReactNode } from 'react'
import type { AgentOrchestrateAddActionOptions } from '../add-actions-context'
import type { AgentFileApiContext } from './api-context'
import type { AgentDriveApiContext } from '../drive-context'
import type { AgentFileNode } from '@/features/agent-v2/agent-composer/form-state'
import {
Dialog,
@ -12,30 +12,19 @@ import {
FileTreeGuide,
} from '@langgenius/dify-ui/file-tree'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useAtom } from 'jotai'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { agentComposerFilesAtom } from '@/features/agent-v2/agent-composer/store-modules/files'
import { consoleQuery } from '@/service/client'
import { useRegisterAgentOrchestrateAddAction } from '../add-actions-context'
import { ConfigureSectionAddButton } from '../common/add-button'
import { ConfigureSectionEmpty } from '../common/empty'
import { ConfigureSection } from '../common/section'
import { FILES_DRIVE_PREFIX, useAgentDriveApiContext, useAgentDriveFiles } from '../drive-context'
import { useAgentOrchestrateReadOnly } from '../read-only-context'
import { AgentSkillDetailDialog } from '../skills/detail-dialog'
import { getDriveFileIconType } from './file-icon'
import { AgentFileTree } from './tree'
import { AgentFileUploadDialog } from './upload-dialog'
const removeFileNode = (files: AgentFileNode[], fileId: string): AgentFileNode[] => files
.filter(file => file.id !== fileId)
.map(file => ({
...file,
children: file.children ? removeFileNode(file.children, fileId) : undefined,
}))
const FILES_DRIVE_PREFIX = 'files/'
const getAgentFilePreviewKey = (file: AgentFileNode) => file.driveKey ?? file.id
const findAgentFileNode = (files: AgentFileNode[], fileId: string): AgentFileNode | undefined => {
@ -49,26 +38,6 @@ const findAgentFileNode = (files: AgentFileNode[], fileId: string): AgentFileNod
}
}
const getAgentDriveFileName = (key: string) => {
const normalizedKey = key.endsWith('/') ? key.slice(0, -1) : key
return normalizedKey.split('/').pop() || normalizedKey
}
const toAgentFileNodeFromDriveItem = (item: {
file_kind: string
key: string
mime_type?: string | null
}): AgentFileNode => ({
id: item.key,
name: getAgentDriveFileName(item.key),
icon: getDriveFileIconType({
fileKind: item.file_kind,
fileName: getAgentDriveFileName(item.key),
mimeType: item.mime_type,
}),
driveKey: item.key,
})
function AgentFileItem({
children,
depth,
@ -82,7 +51,7 @@ function AgentFileItem({
depth: number
file: AgentFileNode
files: AgentFileNode[]
apiContext: AgentFileApiContext
apiContext: AgentDriveApiContext
onRemove: (fileId: string) => void
selected: boolean
}) {
@ -217,68 +186,20 @@ function AgentFileItem({
)
}
export function AgentFiles({
agentId,
appId,
nodeId,
}: {
agentId: string
appId?: string
nodeId?: string
}) {
export function AgentFiles() {
const { t } = useTranslation('agentV2')
const [draftFiles, setDraftFiles] = useAtom(agentComposerFilesAtom)
const filesTip = t('agentDetail.configure.files.tip')
const filesTreeId = 'agent-configure-files-tree'
const [isUploadOpen, setIsUploadOpen] = useState(false)
const promptAddCallbackRef = useRef<AgentOrchestrateAddActionOptions['onAdded']>(undefined)
const apiContext: AgentFileApiContext = useMemo(() => appId && nodeId
? {
agentId,
workflow: {
appId,
nodeId,
},
}
: { agentId }, [agentId, appId, nodeId])
const agentDriveFilesQuery = useQuery({
...consoleQuery.agent.byAgentId.drive.files.get.queryOptions({
input: {
params: {
agent_id: agentId,
},
query: {
prefix: FILES_DRIVE_PREFIX,
},
},
}),
enabled: !apiContext.workflow,
})
const workflowDriveFilesQuery = useQuery({
...consoleQuery.apps.byAppId.agent.drive.files.get.queryOptions({
input: {
params: {
app_id: appId ?? '',
},
query: {
node_id: nodeId,
prefix: FILES_DRIVE_PREFIX,
},
},
}),
enabled: !!apiContext.workflow,
})
const driveFilesQuery = apiContext.workflow ? workflowDriveFilesQuery : agentDriveFilesQuery
const apiContext = useAgentDriveApiContext()
const { query: driveFilesQuery, files } = useAgentDriveFiles({ prefix: FILES_DRIVE_PREFIX })
const { mutate: deleteAgentFile } = useMutation(consoleQuery.agent.byAgentId.files.delete.mutationOptions())
const { mutate: deleteWorkflowAgentFile } = useMutation(consoleQuery.apps.byAppId.agent.files.delete.mutationOptions())
const files = driveFilesQuery.isSuccess
? (driveFilesQuery.data.items ?? []).map(toAgentFileNodeFromDriveItem)
: draftFiles
const removeFile = useCallback((fileId: string) => {
const file = findAgentFileNode(files, fileId)
const driveKey = file?.driveKey
setDraftFiles(removeFileNode(draftFiles, fileId))
if (!driveKey)
return
@ -306,18 +227,17 @@ export function AgentFiles({
key: driveKey,
},
}, { onSuccess })
}, [apiContext, deleteAgentFile, deleteWorkflowAgentFile, draftFiles, driveFilesQuery, files, setDraftFiles])
}, [apiContext, deleteAgentFile, deleteWorkflowAgentFile, driveFilesQuery, files])
const handleOpenUpload = useCallback((options?: AgentOrchestrateAddActionOptions) => {
promptAddCallbackRef.current = options?.onAdded
setIsUploadOpen(true)
}, [])
useRegisterAgentOrchestrateAddAction('files', handleOpenUpload)
const handleUploaded = useCallback((file: AgentFileNode) => {
setDraftFiles([...draftFiles, file])
void driveFilesQuery.refetch()
promptAddCallbackRef.current?.(file)
promptAddCallbackRef.current = undefined
}, [draftFiles, driveFilesQuery, setDraftFiles])
}, [driveFilesQuery])
const handleUploadOpenChange = useCallback((open: boolean) => {
if (!open)
promptAddCallbackRef.current = undefined

View File

@ -1,7 +1,7 @@
'use client'
import type { FileResponse } from '@dify/contracts/api/console/files/types.gen'
import type { AgentFileApiContext } from './api-context'
import type { AgentDriveApiContext } from '../drive-context'
import type { AgentFileNode } from '@/features/agent-v2/agent-composer/form-state'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
@ -137,7 +137,7 @@ export function AgentFileUploadDialog({
onOpenChange,
onUploaded,
}: {
apiContext: AgentFileApiContext
apiContext: AgentDriveApiContext
open: boolean
onOpenChange: (open: boolean) => void
onUploaded: (file: AgentFileNode) => void

View File

@ -5,9 +5,11 @@ import type { AgentConfigurePublishPayload } from './publish-bar'
import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { cn } from '@langgenius/dify-ui/cn'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { AgentOrchestrateAddActionsProvider } from './add-actions'
import { AgentAdvancedSettings } from './advanced'
import { AgentDriveApiContextProvider } from './drive-context'
import { AgentFiles } from './files'
import { AgentOrchestrateHeader } from './header'
import { AgentKnowledgeRetrieval } from './knowledge'
@ -21,6 +23,7 @@ import { AgentTools } from './tools'
type AgentOrchestratePanelProps = {
agentId: string
appId?: string
nodeId?: string
activeConfigIsPublished?: boolean
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
agentSoulConfig?: AgentConfigSnapshotDetailResponse['config_snapshot']
@ -41,6 +44,7 @@ type AgentOrchestratePanelProps = {
export function AgentOrchestratePanel({
agentId,
appId,
nodeId,
activeConfigIsPublished,
activeConfigSnapshot,
agentSoulConfig,
@ -60,6 +64,15 @@ export function AgentOrchestratePanel({
const { t } = useTranslation('agentV2')
const orchestrateHeadingId = 'agent-configure-orchestrate-heading'
const orchestrateLabel = t('agentDetail.configure.orchestrate')
const driveApiContext = useMemo(() => appId && nodeId
? {
agentId,
workflow: {
appId,
nodeId,
},
}
: { agentId }, [agentId, appId, nodeId])
return (
<div className={cn('flex max-w-140 min-w-90 flex-[0_0_min(41.08280255%,560px)] flex-col overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg', className)}>
@ -79,22 +92,21 @@ export function AgentOrchestratePanel({
content: 'min-h-full px-4 py-3',
}}
>
<AgentOrchestrateAddActionsProvider>
<AgentModelField
currentModel={currentModel}
textGenerationModelList={textGenerationModelList}
onSelect={onSelectModel}
/>
<AgentPromptEditor />
<AgentSkills agentId={agentId} />
<AgentFiles
agentId={agentId}
appId={appId}
/>
<AgentTools />
<AgentKnowledgeRetrieval />
<AgentAdvancedSettings />
</AgentOrchestrateAddActionsProvider>
<AgentDriveApiContextProvider value={driveApiContext}>
<AgentOrchestrateAddActionsProvider>
<AgentModelField
currentModel={currentModel}
textGenerationModelList={textGenerationModelList}
onSelect={onSelectModel}
/>
<AgentPromptEditor />
<AgentSkills />
<AgentFiles />
<AgentTools />
<AgentKnowledgeRetrieval />
<AgentAdvancedSettings />
</AgentOrchestrateAddActionsProvider>
</AgentDriveApiContextProvider>
</ScrollArea>
</div>
</AgentOrchestrateReadOnlyContext>

View File

@ -16,14 +16,13 @@ import { Infotip } from '@/app/components/base/infotip'
import PromptEditor from '@/app/components/base/prompt-editor'
import BlockIcon from '@/app/components/workflow/block-icon'
import { BlockEnum } from '@/app/components/workflow/types'
import { agentComposerFilesAtom } from '@/features/agent-v2/agent-composer/store-modules/files'
import { agentComposerKnowledgeRetrievalsAtom } from '@/features/agent-v2/agent-composer/store-modules/knowledge'
import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt'
import { agentComposerSkillsAtom } from '@/features/agent-v2/agent-composer/store-modules/skills'
import { agentComposerToolsAtom } from '@/features/agent-v2/agent-composer/store-modules/tools'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { useAgentOrchestrateAddActions } from '../add-actions-context'
import { useAgentDriveFiles, useAgentDriveSkills } from '../drive-context'
import { useAgentOrchestrateReadOnly } from '../read-only-context'
import { replaceTrailingSlashWithToken } from './options'
import { AgentPromptSlashMenu } from './slash'
@ -151,8 +150,8 @@ export function AgentPromptEditor() {
const { t } = useTranslation('agentV2')
const readOnly = useAgentOrchestrateReadOnly()
const [value, setValue] = useAtom(agentComposerPromptAtom)
const skills = useAtomValue(agentComposerSkillsAtom)
const files = useAtomValue(agentComposerFilesAtom)
const { skills } = useAgentDriveSkills()
const { files } = useAgentDriveFiles()
const [tools, setTools] = useAtom(agentComposerToolsAtom)
const retrievals = useAtomValue(agentComposerKnowledgeRetrievalsAtom)
const addActions = useAgentOrchestrateAddActions()

View File

@ -53,6 +53,10 @@ const createReferenceToken = (kind: string, id: string, label?: string) => (
`${kind}:${id}${label ? `:${label}` : ''}§]`
)
const createDriveReferenceToken = (kind: 'skill' | 'file', driveKey: string, label: string) => (
createReferenceToken(kind, encodeURIComponent(driveKey), label)
)
const isPromptReferenceItem = (item: AgentOrchestrateAddedItem): item is AgentFileNode | AgentSkill => (
'id' in item && 'name' in item
)
@ -87,8 +91,8 @@ export function AgentPromptSlashMenu({
if (view === 'skills') {
onAddSkill?.({
onAdded: (item) => {
if (isPromptReferenceItem(item))
onSelect(createReferenceToken('skill', item.id, item.name))
if (isPromptReferenceItem(item) && 'skillMdKey' in item && typeof item.skillMdKey === 'string')
onSelect(createDriveReferenceToken('skill', item.skillMdKey, item.name))
},
})
return
@ -97,8 +101,8 @@ export function AgentPromptSlashMenu({
if (view === 'files') {
onAddFile?.({
onAdded: (item) => {
if (isPromptReferenceItem(item))
onSelect(createReferenceToken('file', item.id, item.name))
if (isPromptReferenceItem(item) && 'driveKey' in item && typeof item.driveKey === 'string')
onSelect(createDriveReferenceToken('file', item.driveKey, item.name))
},
})
return
@ -224,7 +228,7 @@ function AgentPromptSkillRows({
key={skill.id}
icon="i-ri-box-3-line"
label={skill.name}
onClick={() => onSelect(createReferenceToken('skill', skill.id, skill.name))}
onClick={() => skill.skillMdKey && onSelect(createDriveReferenceToken('skill', skill.skillMdKey, skill.name))}
/>
))}
</>
@ -249,7 +253,7 @@ function AgentPromptFileRows({
label={file.name}
depth={depth}
hasChildren={!!file.children?.length}
onClick={() => onSelect(createReferenceToken('file', file.id, file.name))}
onClick={() => file.driveKey && onSelect(createDriveReferenceToken('file', file.driveKey, file.name))}
/>
{!!file.children?.length && (
<AgentPromptFileRows files={file.children} depth={depth + 1} onSelect={onSelect} />

View File

@ -6,13 +6,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
import { useAgentComposerConfigSnapshot } from '@/features/agent-v2/agent-composer/store'
import { AgentDriveApiContextProvider } from '../../drive-context'
import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
import { AgentSkills } from '../index'
const mocks = vi.hoisted(() => ({
driveSkillsQueryOptions: vi.fn(),
driveFilesQueryOptions: vi.fn(),
driveFileDownloadQueryOptions: vi.fn(),
driveFilePreviewQueryOptions: vi.fn(),
deleteSkillMutationOptions: vi.fn(),
uploadSkillMutationOptions: vi.fn(),
}))
@ -28,6 +31,11 @@ vi.mock('@/service/client', () => ({
agent: {
byAgentId: {
drive: {
skills: {
get: {
queryOptions: mocks.driveSkillsQueryOptions,
},
},
files: {
get: {
queryOptions: mocks.driveFilesQueryOptions,
@ -45,6 +53,11 @@ vi.mock('@/service/client', () => ({
},
},
skills: {
bySlug: {
delete: {
mutationOptions: mocks.deleteSkillMutationOptions,
},
},
upload: {
post: {
mutationOptions: mocks.uploadSkillMutationOptions,
@ -53,26 +66,51 @@ vi.mock('@/service/client', () => ({
},
},
},
apps: {
byAppId: {
agent: {
drive: {
skills: {
get: {
queryOptions: mocks.driveSkillsQueryOptions,
},
},
files: {
get: {
queryOptions: mocks.driveFilesQueryOptions,
},
download: {
get: {
queryOptions: mocks.driveFileDownloadQueryOptions,
},
},
preview: {
get: {
queryOptions: mocks.driveFilePreviewQueryOptions,
},
},
},
},
skills: {
bySlug: {
delete: {
mutationOptions: mocks.deleteSkillMutationOptions,
},
},
upload: {
post: {
mutationOptions: mocks.uploadSkillMutationOptions,
},
},
},
},
},
},
},
}))
const agentSkillsDraft = {
...defaultAgentSoulConfigFormState,
skills: [
{
id: 'tender-analyzer',
name: 'Tender Analyzer',
description: 'Extracts tender requirements and scoring criteria.',
files: ['__MACOSX/._hatch-pet', 'SKILL.md', 'schema.json'],
path: 'tender-analyzer',
skillMdKey: 'tender-analyzer/SKILL.md',
},
{
id: 'meeting-brief',
name: 'Meeting Brief',
path: 'meeting-brief',
},
],
} satisfies typeof defaultAgentSoulConfigFormState
function renderAgentSkills() {
@ -86,9 +124,31 @@ function renderAgentSkills() {
return render(
<QueryClientProvider client={queryClient}>
<AgentComposerProvider initialDraft={agentSkillsDraft}>
<AgentSkills agentId="agent-1" />
</AgentComposerProvider>
<AgentDriveApiContextProvider value={{ agentId: 'agent-1' }}>
<AgentComposerProvider initialDraft={agentSkillsDraft}>
<AgentSkills />
</AgentComposerProvider>
</AgentDriveApiContextProvider>
</QueryClientProvider>,
)
}
function renderWorkflowAgentSkills() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={queryClient}>
<AgentDriveApiContextProvider value={{ agentId: 'agent-1', workflow: { appId: 'app-1', nodeId: 'node-1' } }}>
<AgentComposerProvider initialDraft={agentSkillsDraft}>
<AgentSkills />
</AgentComposerProvider>
</AgentDriveApiContextProvider>
</QueryClientProvider>,
)
}
@ -104,11 +164,13 @@ function renderReadonlyAgentSkills() {
return render(
<QueryClientProvider client={queryClient}>
<AgentComposerProvider initialDraft={agentSkillsDraft}>
<AgentOrchestrateReadOnlyContext value>
<AgentSkills agentId="agent-1" />
</AgentOrchestrateReadOnlyContext>
</AgentComposerProvider>
<AgentDriveApiContextProvider value={{ agentId: 'agent-1' }}>
<AgentComposerProvider initialDraft={agentSkillsDraft}>
<AgentOrchestrateReadOnlyContext value>
<AgentSkills />
</AgentOrchestrateReadOnlyContext>
</AgentComposerProvider>
</AgentDriveApiContextProvider>
</QueryClientProvider>,
)
}
@ -118,7 +180,7 @@ function ConfigSnapshotProbe() {
return (
<pre data-testid="config-snapshot-probe">
{JSON.stringify(configSnapshot.skills_files?.skills ?? [])}
{JSON.stringify(configSnapshot)}
</pre>
)
}
@ -126,6 +188,28 @@ function ConfigSnapshotProbe() {
describe('AgentSkills', () => {
beforeEach(() => {
vi.clearAllMocks()
const skillItems = [
{
archive_key: 'tender-analyzer/.DIFY-SKILL-FULL.zip',
description: 'Extracts tender requirements and scoring criteria.',
name: 'Tender Analyzer',
path: 'tender-analyzer',
skill_md_key: 'tender-analyzer/SKILL.md',
},
{
description: '',
name: 'Meeting Brief',
path: 'meeting-brief',
skill_md_key: 'meeting-brief/SKILL.md',
},
]
mocks.driveSkillsQueryOptions.mockImplementation(({ input }) => ({
queryKey: ['agent-drive-skills', input],
initialData: { items: skillItems },
queryFn: async () => ({
items: skillItems,
}),
}))
mocks.driveFilesQueryOptions.mockImplementation(({ input }) => ({
queryKey: ['agent-drive-files', input],
queryFn: async () => ({
@ -157,9 +241,12 @@ describe('AgentSkills', () => {
mutationFn: vi.fn(),
mutationKey: ['upload-skill'],
})
mocks.deleteSkillMutationOptions.mockReturnValue({
mutationFn: vi.fn(),
mutationKey: ['delete-skill'],
})
})
// Skill rows load their preview from the agent drive and render the shared detail dialog.
it('should fetch drive files and open the skill detail dialog when the skill row is clicked', async () => {
renderAgentSkills()
@ -182,69 +269,95 @@ describe('AgentSkills', () => {
})
expect(within(dialog).getByText('Tender Analyzer')).toBeInTheDocument()
expect(within(dialog).getByText('Extracts tender requirements and scoring criteria.')).toBeInTheDocument()
expect(within(dialog).queryByText('__MACOSX/._hatch-pet')).not.toBeInTheDocument()
expect(await within(dialog).findByText('scripts/extract.py')).toBeInTheDocument()
expect(within(dialog).getByText('SKILL.md')).toBeInTheDocument()
expect(await within(dialog).findByText('Preview content for tender-analyzer/SKILL.md')).toBeInTheDocument()
expect(mocks.driveFilePreviewQueryOptions).toHaveBeenCalledWith({
input: {
params: {
agent_id: 'agent-1',
},
query: {
key: 'tender-analyzer/SKILL.md',
},
},
})
expect(mocks.driveFilePreviewQueryOptions).not.toHaveBeenCalledWith({
input: {
params: {
agent_id: 'agent-1',
},
query: {
key: 'tender-analyzer/__MACOSX/._hatch-pet',
},
},
})
})
it('should preview the selected skill file from the detail file tree', async () => {
it('should keep the detail dialog open after selecting a skill', async () => {
renderAgentSkills()
fireEvent.click(screen.getByRole('button', {
name: 'Tender Analyzer',
}))
const dialog = screen.getByRole('dialog')
const scriptFile = await within(dialog).findByRole('button', { name: 'scripts/extract.py' })
fireEvent.click(scriptFile)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
expect(await within(dialog).findByText('Preview content for tender-analyzer/scripts/extract.py')).toBeInTheDocument()
it('should use workflow node drive routes for skill list and preview in inline workflow mode', async () => {
renderWorkflowAgentSkills()
expect(mocks.driveSkillsQueryOptions).toHaveBeenCalledWith({
input: {
params: {
app_id: 'app-1',
},
query: {
node_id: 'node-1',
},
},
})
fireEvent.click(screen.getByRole('button', {
name: 'Tender Analyzer',
}))
expect(mocks.driveFilesQueryOptions).toHaveBeenCalledWith({
input: {
params: {
app_id: 'app-1',
},
query: {
node_id: 'node-1',
prefix: 'tender-analyzer/',
},
},
})
expect(mocks.driveFilePreviewQueryOptions).toHaveBeenCalledWith({
input: {
params: {
agent_id: 'agent-1',
app_id: 'app-1',
},
query: {
key: 'tender-analyzer/scripts/extract.py',
node_id: 'node-1',
key: 'tender-analyzer/SKILL.md',
},
},
})
})
// The hover/focus remove action updates the composer draft without opening preview.
it('should remove the skill without opening the detail dialog when the remove action is clicked', () => {
it('should delete the skill without opening the detail dialog when the remove action is clicked', () => {
renderAgentSkills()
fireEvent.click(screen.getByRole('button', {
name: /agentV2\.agentDetail\.configure\.skills\.remove.*Tender Analyzer/,
}))
expect(screen.queryByText('Tender Analyzer')).not.toBeInTheDocument()
expect(screen.getByText('Meeting Brief')).toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('should route workflow skill delete through app and node identifiers', async () => {
const deleteSkill = vi.fn().mockResolvedValue({ result: 'success', removed_keys: ['tender-analyzer/SKILL.md'] })
mocks.deleteSkillMutationOptions.mockReturnValue({
mutationFn: deleteSkill,
mutationKey: ['delete-skill'],
})
renderWorkflowAgentSkills()
fireEvent.click(screen.getByRole('button', {
name: /agentV2\.agentDetail\.configure\.skills\.remove.*Tender Analyzer/,
}))
await waitFor(() => {
expect(deleteSkill.mock.calls[0]?.[0]).toEqual({
params: {
app_id: 'app-1',
slug: 'tender-analyzer',
},
query: {
node_id: 'node-1',
},
})
})
})
it('should hide add and remove actions when readonly', () => {
renderReadonlyAgentSkills()
@ -255,21 +368,49 @@ describe('AgentSkills', () => {
})).not.toBeInTheDocument()
})
// Upload uses the drive-backed response so the added skill can be reloaded from agent drive paths.
it('should add an uploaded skill with drive-backed keys when the upload succeeds', async () => {
it('should upload a skill through the drive-backed endpoint', async () => {
const user = userEvent.setup()
const uploadSkill = vi.fn().mockResolvedValue({
manifest: {
files: ['SKILL.md', 'scripts/run.py'],
name: 'Invoice Helper',
const driveSkills = [
{
archive_key: 'tender-analyzer/.DIFY-SKILL-FULL.zip',
description: 'Extracts tender requirements and scoring criteria.',
name: 'Tender Analyzer',
path: 'tender-analyzer',
skill_md_key: 'tender-analyzer/SKILL.md',
},
skill: {
id: 'skill-hash',
manifest_files: ['SKILL.md', 'scripts/run.py'],
{
description: '',
name: 'Meeting Brief',
path: 'meeting-brief',
skill_md_key: 'meeting-brief/SKILL.md',
},
]
mocks.driveSkillsQueryOptions.mockImplementation(({ input }) => ({
queryKey: ['agent-drive-skills', input],
initialData: { items: [...driveSkills] },
queryFn: async () => ({ items: [...driveSkills] }),
}))
const uploadSkill = vi.fn().mockImplementation(async () => {
driveSkills.push({
archive_key: 'invoice-helper/.DIFY-SKILL-FULL.zip',
description: '',
name: 'Invoice Helper',
path: 'invoice-helper',
skill_md_key: 'invoice-helper/SKILL.md',
},
})
return {
manifest: {
files: ['SKILL.md', 'scripts/run.py'],
name: 'Invoice Helper',
},
skill: {
manifest_files: ['SKILL.md', 'scripts/run.py'],
name: 'Invoice Helper',
path: 'invoice-helper',
skill_md_key: 'invoice-helper/SKILL.md',
archive_key: 'invoice-helper/.DIFY-SKILL-FULL.zip',
},
}
})
mocks.uploadSkillMutationOptions.mockReturnValue({
mutationFn: uploadSkill,
@ -296,24 +437,94 @@ describe('AgentSkills', () => {
})
})
expect(await screen.findByRole('button', { name: 'Invoice Helper' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Invoice Helper' }))
expect(mocks.driveFilesQueryOptions).toHaveBeenCalledWith({
input: {
params: {
agent_id: 'agent-1',
},
query: {
prefix: 'invoice-helper/',
},
},
})
expect(vi.mocked(toast.success)).toHaveBeenCalledWith('agentV2.agentDetail.configure.skills.upload.success')
})
// Uploaded drive-backed refs must survive insertion into draft and serialization back to config.
it('should preserve archive and skill file ids from the upload response in the serialized draft', async () => {
it('should refresh the rendered skill list after delete succeeds', async () => {
const driveSkills = [
{
archive_key: 'tender-analyzer/.DIFY-SKILL-FULL.zip',
description: 'Extracts tender requirements and scoring criteria.',
name: 'Tender Analyzer',
path: 'tender-analyzer',
skill_md_key: 'tender-analyzer/SKILL.md',
},
{
description: '',
name: 'Meeting Brief',
path: 'meeting-brief',
skill_md_key: 'meeting-brief/SKILL.md',
},
]
mocks.driveSkillsQueryOptions.mockImplementation(({ input }) => ({
queryKey: ['agent-drive-skills', input],
initialData: { items: [...driveSkills] },
queryFn: async () => ({ items: [...driveSkills] }),
}))
const deleteSkill = vi.fn().mockImplementation(async () => {
driveSkills.splice(0, 1)
return { result: 'success', removed_keys: ['tender-analyzer/SKILL.md'] }
})
mocks.deleteSkillMutationOptions.mockReturnValue({
mutationFn: deleteSkill,
mutationKey: ['delete-skill'],
})
renderAgentSkills()
fireEvent.click(screen.getByRole('button', {
name: /agentV2\.agentDetail\.configure\.skills\.remove.*Tender Analyzer/,
}))
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Tender Analyzer' })).not.toBeInTheDocument()
})
expect(screen.getByRole('button', { name: 'Meeting Brief' })).toBeInTheDocument()
})
it('should route workflow skill uploads through app and node identifiers', async () => {
const user = userEvent.setup()
const uploadSkill = vi.fn().mockResolvedValue({
manifest: {
files: ['SKILL.md'],
name: 'Invoice Helper',
},
skill: {
name: 'Invoice Helper',
path: 'invoice-helper',
skill_md_key: 'invoice-helper/SKILL.md',
},
})
mocks.uploadSkillMutationOptions.mockReturnValue({
mutationFn: uploadSkill,
mutationKey: ['upload-skill'],
})
renderWorkflowAgentSkills()
await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.skills.add' }))
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
const file = new File(['skill'], 'invoice-helper.skill', { type: 'application/zip' })
await user.upload(fileInput, file)
await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.skills.upload.action' }))
await waitFor(() => {
expect(uploadSkill.mock.calls[0]?.[0]).toEqual({
params: {
app_id: 'app-1',
},
query: {
node_id: 'node-1',
},
body: {
file,
},
})
})
})
it('should not persist skills_files when the draft is serialized', async () => {
const user = userEvent.setup()
const uploadSkill = vi.fn().mockResolvedValue({
manifest: {
@ -321,14 +532,9 @@ describe('AgentSkills', () => {
name: 'Invoice Helper',
},
skill: {
file_id: 'archive-upload-file-id',
full_archive_file_id: 'archive-tool-file-id',
full_archive_key: 'invoice-helper/.DIFY-SKILL-FULL.zip',
id: 'skill-hash',
manifest_files: ['SKILL.md', 'scripts/run.py'],
archive_key: 'invoice-helper/.DIFY-SKILL-FULL.zip',
name: 'Invoice Helper',
path: 'invoice-helper',
skill_md_file_id: 'skill-md-tool-file-id',
skill_md_key: 'invoice-helper/SKILL.md',
},
})
@ -347,10 +553,12 @@ describe('AgentSkills', () => {
render(
<QueryClientProvider client={queryClient}>
<AgentComposerProvider initialDraft={agentSkillsDraft}>
<AgentSkills agentId="agent-1" />
<ConfigSnapshotProbe />
</AgentComposerProvider>
<AgentDriveApiContextProvider value={{ agentId: 'agent-1' }}>
<AgentComposerProvider initialDraft={agentSkillsDraft}>
<AgentSkills />
<ConfigSnapshotProbe />
</AgentComposerProvider>
</AgentDriveApiContextProvider>
</QueryClientProvider>,
)
@ -362,18 +570,8 @@ describe('AgentSkills', () => {
await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.skills.upload.action' }))
await waitFor(() => {
const serializedSkills = JSON.parse(screen.getByTestId('config-snapshot-probe').textContent ?? '[]')
expect(serializedSkills).toEqual(expect.arrayContaining([
expect.objectContaining({
file_id: 'archive-upload-file-id',
full_archive_file_id: 'archive-tool-file-id',
full_archive_key: 'invoice-helper/.DIFY-SKILL-FULL.zip',
name: 'Invoice Helper',
path: 'invoice-helper',
skill_md_file_id: 'skill-md-tool-file-id',
skill_md_key: 'invoice-helper/SKILL.md',
}),
]))
const serializedConfig = JSON.parse(screen.getByTestId('config-snapshot-probe').textContent ?? '{}')
expect(serializedConfig).not.toHaveProperty('skills_files')
})
})
})

View File

@ -2,47 +2,77 @@
import type { AgentOrchestrateAddActionOptions } from '../add-actions-context'
import type { AgentSkill } from '@/features/agent-v2/agent-composer/form-state'
import { useAtom } from 'jotai'
import { useMutation } from '@tanstack/react-query'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { agentComposerSkillsAtom, useRemoveSkill } from '@/features/agent-v2/agent-composer/store-modules/skills'
import { consoleQuery } from '@/service/client'
import { useRegisterAgentOrchestrateAddAction } from '../add-actions-context'
import { ConfigureSectionAddButton } from '../common/add-button'
import { ConfigureSectionEmpty } from '../common/empty'
import { ConfigureSection } from '../common/section'
import { useAgentDriveApiContext, useAgentDriveSkills } from '../drive-context'
import { AgentSkillItem } from './item'
import { AgentSkillUploadDialog } from './upload-dialog'
export function AgentSkills({
agentId,
}: {
agentId: string
}) {
export function AgentSkills() {
const { t } = useTranslation('agentV2')
const [skills, setSkills] = useAtom(agentComposerSkillsAtom)
const removeSkill = useRemoveSkill()
const skillsTip = t('agentDetail.configure.skills.tip')
const skillsListId = 'agent-configure-skills-list'
const [isUploadOpen, setIsUploadOpen] = useState(false)
const promptAddCallbackRef = useRef<AgentOrchestrateAddActionOptions['onAdded']>(undefined)
const apiContext = useAgentDriveApiContext()
const { query: skillsQuery, skills } = useAgentDriveSkills()
const { mutate: deleteAgentSkill } = useMutation(consoleQuery.agent.byAgentId.skills.bySlug.delete.mutationOptions())
const { mutate: deleteAppSkill } = useMutation(consoleQuery.apps.byAppId.agent.skills.bySlug.delete.mutationOptions())
const handleOpenUpload = useCallback((options?: AgentOrchestrateAddActionOptions) => {
promptAddCallbackRef.current = options?.onAdded
setIsUploadOpen(true)
}, [])
useRegisterAgentOrchestrateAddAction('skills', handleOpenUpload)
const handleUploaded = useCallback((skill: AgentSkill) => {
setSkills(skills.some(currentSkill => currentSkill.id === skill.id)
? skills
: [...skills, skill])
void skillsQuery.refetch()
promptAddCallbackRef.current?.(skill)
promptAddCallbackRef.current = undefined
}, [setSkills, skills])
}, [skillsQuery])
const handleUploadOpenChange = useCallback((open: boolean) => {
if (!open)
promptAddCallbackRef.current = undefined
setIsUploadOpen(open)
}, [])
const handleRemoveSkill = useCallback((skillId: string) => {
const skill = skills.find(item => item.id === skillId)
const skillSlug = skill?.path ?? skill?.skillMdKey?.split('/', 1)[0]
if (!skillSlug)
return
const onSuccess = () => {
void skillsQuery.refetch()
}
if (apiContext.workflow) {
deleteAppSkill({
params: {
app_id: apiContext.workflow.appId,
slug: skillSlug,
},
query: {
node_id: apiContext.workflow.nodeId,
},
}, { onSuccess })
return
}
deleteAgentSkill({
params: {
agent_id: apiContext.agentId,
slug: skillSlug,
},
}, { onSuccess })
}, [apiContext, deleteAgentSkill, deleteAppSkill, skills, skillsQuery])
return (
<>
<ConfigureSection
@ -68,11 +98,11 @@ export function AgentSkills({
/>
)
: skills.map(skill => (
<AgentSkillItem key={skill.id} agentId={agentId} skill={skill} onRemove={removeSkill} />
<AgentSkillItem key={skill.id} apiContext={apiContext} skill={skill} onRemove={handleRemoveSkill} />
))}
</ConfigureSection>
<AgentSkillUploadDialog
agentId={agentId}
apiContext={apiContext}
open={isUploadOpen}
onOpenChange={handleUploadOpenChange}
onUploaded={handleUploaded}

View File

@ -1,6 +1,7 @@
'use client'
import type { AgentDriveItemResponse } from '@dify/contracts/api/console/agent/types.gen'
import type { AgentDriveApiContext } from '../drive-context'
import type { AgentFileNode, AgentSkill } from '@/features/agent-v2/agent-composer/form-state'
import {
Dialog,
@ -55,11 +56,11 @@ const getFirstSkillFileId = (files: AgentFileNode[]): string | undefined => {
}
export function AgentSkillItem({
agentId,
apiContext,
skill,
onRemove,
}: {
agentId: string
apiContext: AgentDriveApiContext
skill: AgentSkill
onRemove: (skillId: string) => void
}) {
@ -75,53 +76,106 @@ export function AgentSkillItem({
setIsPreviewOpen(true)
}, [])
const skillDrivePath = getSkillDrivePath(skill)
const driveFilesQuery = useQuery({
const agentDriveFilesQuery = useQuery({
...consoleQuery.agent.byAgentId.drive.files.get.queryOptions({
input: {
params: {
agent_id: agentId,
agent_id: apiContext.agentId,
},
query: {
prefix: `${skillDrivePath}/`,
},
},
}),
enabled: isPreviewOpen,
enabled: isPreviewOpen && !apiContext.workflow,
})
const detailFiles = driveFilesQuery.isSuccess
? (driveFilesQuery.data.items ?? []).map(item => toSkillFileNode(item, skillDrivePath))
const workflowDriveFilesQuery = useQuery({
...consoleQuery.apps.byAppId.agent.drive.files.get.queryOptions({
input: {
params: {
app_id: apiContext.workflow?.appId ?? '',
},
query: {
node_id: apiContext.workflow?.nodeId,
prefix: `${skillDrivePath}/`,
},
},
}),
enabled: isPreviewOpen && !!apiContext.workflow,
})
const activeDriveFilesQuery = apiContext.workflow ? workflowDriveFilesQuery : agentDriveFilesQuery
const detailFiles = activeDriveFilesQuery.isSuccess
? (activeDriveFilesQuery.data.items ?? []).map(item => toSkillFileNode(item, skillDrivePath))
: []
const previewFileId = selectedFileId
?? skill.skillMdKey
?? (driveFilesQuery.isSuccess ? getSkillMdFileId(detailFiles) ?? getFirstSkillFileId(detailFiles) : undefined)
const previewQuery = useQuery({
?? (activeDriveFilesQuery.isSuccess ? getSkillMdFileId(detailFiles) ?? getFirstSkillFileId(detailFiles) : undefined)
const agentPreviewQuery = useQuery({
...consoleQuery.agent.byAgentId.drive.files.preview.get.queryOptions({
input: {
params: {
agent_id: agentId,
agent_id: apiContext.agentId,
},
query: {
key: previewFileId ?? '',
},
},
}),
enabled: isPreviewOpen && !!previewFileId,
enabled: isPreviewOpen && !!previewFileId && !apiContext.workflow,
})
const workflowPreviewQuery = useQuery({
...consoleQuery.apps.byAppId.agent.drive.files.preview.get.queryOptions({
input: {
params: {
app_id: apiContext.workflow?.appId ?? '',
},
query: {
node_id: apiContext.workflow?.nodeId,
key: previewFileId ?? '',
},
},
}),
enabled: isPreviewOpen && !!previewFileId && !!apiContext.workflow,
})
const previewQuery = apiContext.workflow ? workflowPreviewQuery : agentPreviewQuery
const selectedFile = detailFiles.find(file => file.id === previewFileId)
const isImagePreviewFile = selectedFile?.icon === 'image'
const downloadQuery = useQuery({
const agentDownloadQuery = useQuery({
...consoleQuery.agent.byAgentId.drive.files.download.get.queryOptions({
input: {
params: {
agent_id: agentId,
agent_id: apiContext.agentId,
},
query: {
key: previewFileId ?? '',
},
},
}),
enabled: isPreviewOpen && !!previewFileId && (isImagePreviewFile || !!previewQuery.data?.binary),
enabled:
isPreviewOpen
&& !!previewFileId
&& (isImagePreviewFile || !!previewQuery.data?.binary)
&& !apiContext.workflow,
})
const workflowDownloadQuery = useQuery({
...consoleQuery.apps.byAppId.agent.drive.files.download.get.queryOptions({
input: {
params: {
app_id: apiContext.workflow?.appId ?? '',
},
query: {
node_id: apiContext.workflow?.nodeId,
key: previewFileId ?? '',
},
},
}),
enabled:
isPreviewOpen
&& !!previewFileId
&& (isImagePreviewFile || !!previewQuery.data?.binary)
&& !!apiContext.workflow,
})
const downloadQuery = apiContext.workflow ? workflowDownloadQuery : agentDownloadQuery
return (
<Dialog open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>

View File

@ -1,6 +1,8 @@
'use client'
import type { PostAgentByAgentIdSkillsUploadResponse } from '@dify/contracts/api/console/agent/types.gen'
import type { PostAppsByAppIdAgentSkillsUploadResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { AgentDriveApiContext } from '../drive-context'
import type { AgentSkill } from '@/features/agent-v2/agent-composer/form-state'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
@ -18,25 +20,23 @@ const skillPackageExtensions = ['.zip', '.skill']
const getSkillNameFromFile = (file: File) => file.name.replace(/\.(?:skill|zip)$/iu, '') || file.name
const toUploadedSkill = (response: PostAgentByAgentIdSkillsUploadResponse, file: File): AgentSkill => {
const toUploadedSkill = (
response: PostAgentByAgentIdSkillsUploadResponse | PostAppsByAppIdAgentSkillsUploadResponse,
file: File,
): AgentSkill => {
const name = response.skill?.name
?? response.manifest?.name
?? getSkillNameFromFile(file)
const id = response.skill?.id
?? response.skill?.skill_md_key
const id = response.skill?.skill_md_key
?? response.skill?.path
?? name
return {
description: response.skill?.description ?? response.manifest?.description,
files: response.skill?.manifest_files ?? response.manifest?.files,
fileId: response.skill?.file_id ?? undefined,
fullArchiveFileId: response.skill?.full_archive_file_id ?? undefined,
fullArchiveKey: response.skill?.full_archive_key ?? undefined,
description: response.skill?.description ?? response.manifest?.description ?? undefined,
archiveKey: response.skill?.archive_key ?? undefined,
id,
name,
path: response.skill?.path ?? undefined,
skillMdFileId: response.skill?.skill_md_file_id ?? undefined,
skillMdKey: response.skill?.skill_md_key ?? undefined,
}
}
@ -146,12 +146,12 @@ function AgentSkillPackageUploader({
}
export function AgentSkillUploadDialog({
agentId,
apiContext,
onUploaded,
open,
onOpenChange,
}: {
agentId: string
apiContext: AgentDriveApiContext
onUploaded?: (skill: AgentSkill) => void
open: boolean
onOpenChange: (open: boolean) => void
@ -159,21 +159,16 @@ export function AgentSkillUploadDialog({
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const [file, setFile] = useState<File>()
const uploadSkillMutation = useMutation(consoleQuery.agent.byAgentId.skills.upload.post.mutationOptions())
const uploadAgentSkillMutation = useMutation(consoleQuery.agent.byAgentId.skills.upload.post.mutationOptions())
const uploadWorkflowSkillMutation = useMutation(consoleQuery.apps.byAppId.agent.skills.upload.post.mutationOptions())
const uploadSkillMutation = apiContext.workflow ? uploadWorkflowSkillMutation : uploadAgentSkillMutation
const handleUpload = () => {
if (!file || uploadSkillMutation.isPending)
return
uploadSkillMutation.mutate({
params: {
agent_id: agentId,
},
body: {
file,
},
}, {
onSuccess: (response) => {
const options = {
onSuccess: (response: PostAgentByAgentIdSkillsUploadResponse | PostAppsByAppIdAgentSkillsUploadResponse) => {
toast.success(t('agentDetail.configure.skills.upload.success'))
onUploaded?.(toUploadedSkill(response, file))
setFile(undefined)
@ -182,12 +177,37 @@ export function AgentSkillUploadDialog({
onError: () => {
toast.error(t('agentDetail.configure.skills.upload.failed'))
},
})
}
if (apiContext.workflow) {
uploadWorkflowSkillMutation.mutate({
params: {
app_id: apiContext.workflow.appId,
},
query: {
node_id: apiContext.workflow.nodeId,
},
body: {
file,
},
}, options)
return
}
uploadAgentSkillMutation.mutate({
params: {
agent_id: apiContext.agentId,
},
body: {
file,
},
}, options)
}
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
uploadSkillMutation.reset()
uploadAgentSkillMutation.reset()
uploadWorkflowSkillMutation.reset()
setFile(undefined)
}
onOpenChange(nextOpen)