mirror of
https://github.com/langgenius/dify.git
synced 2026-06-19 14:17:41 +08:00
Compare commits
6 Commits
codex/draw
...
deploy/age
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dafe37da8 | |||
| 13d9fd7826 | |||
| 4a083649b5 | |||
| 6ebf5f0c74 | |||
| fcad148ff3 | |||
| df9006f01b |
@ -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,
|
||||
)
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -35,7 +35,6 @@ class WorkflowAgentNodeValidator:
|
||||
"soul",
|
||||
"prompt",
|
||||
"system_prompt",
|
||||
"skills_files",
|
||||
"skills",
|
||||
"files",
|
||||
"tools",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)."""
|
||||
|
||||
@ -191,6 +191,8 @@ class ComposerConfigValidator:
|
||||
}
|
||||
)
|
||||
continue
|
||||
if mention.kind in {MentionKind.SKILL, MentionKind.FILE}:
|
||||
continue
|
||||
if resolved is None:
|
||||
warnings.append(
|
||||
{
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ───────────────────────
|
||||
|
||||
@ -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)
|
||||
@ -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():
|
||||
|
||||
@ -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"] == []
|
||||
|
||||
|
||||
@ -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."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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") == []
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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"
|
||||
|
||||
197
dify-agent/tests/local/dify_agent/layers/drive/test_layer.py
Normal file
197
dify-agent/tests/local/dify_agent/layers/drive/test_layer.py
Normal 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()
|
||||
@ -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']",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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}
|
||||
|
||||
60
web/contract/console/agent-drive.ts
Normal file
60
web/contract/console/agent-drive.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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),
|
||||
})
|
||||
},
|
||||
)
|
||||
@ -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])
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: [],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
export type AgentFileApiContext = {
|
||||
agentId: string
|
||||
workflow?: {
|
||||
appId: string
|
||||
nodeId: string
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user