mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
feat(agent-v2): sync nightly updates to main (2026-06-22) (#37651)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com> Co-authored-by: Joel <iamjoel007@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
co-authored by
yyh
Joel
autofix-ci[bot]
yyh
parent
56b0b57ff7
commit
f4fdbeba76
@@ -78,6 +78,13 @@ def _filter_snapshot_to_specs(
|
||||
return CompositorSessionSnapshot(schema_version=snapshot.schema_version, layers=filtered_layers)
|
||||
|
||||
|
||||
def _shell_layer_deps(*, include_drive: bool) -> dict[str, str]:
|
||||
deps = {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
if include_drive:
|
||||
deps["drive"] = DIFY_DRIVE_LAYER_ID
|
||||
return deps
|
||||
|
||||
|
||||
class AgentBackendModelConfig(BaseModel):
|
||||
"""API-side model/plugin selection before it is converted to Dify Agent layers."""
|
||||
|
||||
@@ -263,6 +270,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,
|
||||
)
|
||||
@@ -329,14 +337,15 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
|
||||
if run_input.include_shell:
|
||||
# Sandboxed bash workspace (dify.shell). Depends on execution_context so
|
||||
# the agent server can mint per-command Agent Stub env (back proxy);
|
||||
# Sandboxed bash workspace (dify.shell). Depends on execution_context
|
||||
# so the agent server can mint per-command Agent Stub env, and on
|
||||
# drive when present so that env points at /mnt/drive/<drive_ref>.
|
||||
# shellctl connection itself is server-injected.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
deps=_shell_layer_deps(include_drive=run_input.drive_config is not None),
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
)
|
||||
@@ -460,6 +469,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,
|
||||
)
|
||||
@@ -528,14 +538,15 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
|
||||
if run_input.include_shell:
|
||||
# Sandboxed bash workspace (dify.shell). Depends on execution_context so
|
||||
# the agent server can mint per-command Agent Stub env (back proxy);
|
||||
# Sandboxed bash workspace (dify.shell). Depends on execution_context
|
||||
# so the agent server can mint per-command Agent Stub env, and on
|
||||
# drive when present so that env points at /mnt/drive/<drive_ref>.
|
||||
# shellctl connection itself is server-injected.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
deps=_shell_layer_deps(include_drive=run_input.drive_config is not None),
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -486,9 +461,7 @@ class AgentSkillByAgentApi(Resource):
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>")
|
||||
class AgentSkillApi(Resource):
|
||||
@console_ns.doc("delete_agent_skill")
|
||||
@console_ns.doc(
|
||||
description="Delete a standardized skill: soul ref first, then the <slug>/ drive prefix (ENG-625 D5)"
|
||||
)
|
||||
@console_ns.doc(description="Delete a standardized skill by removing its known drive keys via commit-null")
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Inner API endpoint for tenant-scoped knowledge retrieval.
|
||||
"""Plugin inner API endpoint for tenant-scoped knowledge retrieval.
|
||||
|
||||
This controller is a thin HTTP wrapper around
|
||||
``services.knowledge_retrieval_inner_service.InnerKnowledgeRetrievalService``.
|
||||
It intentionally keeps authorization simple: shared inner API key plus
|
||||
tenant-scoped app/dataset validation in the service layer.
|
||||
It uses the plugin inner API key because dify-agent calls this endpoint through
|
||||
the same trusted Dify API bridge as other agent/plugin inner calls; tenant-scoped
|
||||
app/dataset validation remains in the service layer.
|
||||
"""
|
||||
|
||||
from flask_restx import Resource
|
||||
@@ -11,7 +12,7 @@ from pydantic import ValidationError
|
||||
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import inner_api_only
|
||||
from controllers.inner_api.wraps import plugin_inner_api_only
|
||||
from core.workflow.nodes.knowledge_retrieval import exc as retrieval_exc
|
||||
from libs.exception import BaseHTTPException
|
||||
from services.entities.knowledge_retrieval_inner import InnerKnowledgeRetrieveRequest, InnerKnowledgeRetrieveResponse
|
||||
@@ -48,7 +49,7 @@ register_response_schema_models(inner_api_ns, InnerKnowledgeRetrieveResponse)
|
||||
class InnerKnowledgeRetrieveApi(Resource):
|
||||
"""Retrieve knowledge from one or more datasets within the caller tenant."""
|
||||
|
||||
@inner_api_only
|
||||
@plugin_inner_api_only
|
||||
@inner_api_ns.doc("inner_knowledge_retrieve")
|
||||
@inner_api_ns.doc(description="Retrieve knowledge for trusted internal callers")
|
||||
@inner_api_ns.expect(inner_api_ns.models[InnerKnowledgeRetrieveRequest.__name__])
|
||||
@@ -60,9 +61,8 @@ class InnerKnowledgeRetrieveApi(Resource):
|
||||
@inner_api_ns.doc(
|
||||
responses={
|
||||
400: "Invalid request body",
|
||||
401: "Unauthorized - invalid inner API key",
|
||||
403: "Caller tenant does not own the requested resource",
|
||||
404: "App or dataset not found",
|
||||
404: "Invalid plugin inner API key, app not found, or dataset not found",
|
||||
422: "Invalid retrieval configuration",
|
||||
429: "Knowledge retrieval rate limited",
|
||||
502: "External knowledge retrieval failed",
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""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. Commit still canonicalizes the
|
||||
trusted execution-context user through the same EndUser lookup as plugin file
|
||||
upload before validating ToolFile ownership.
|
||||
"""
|
||||
|
||||
from flask import request
|
||||
@@ -13,6 +15,7 @@ from pydantic import BaseModel, ValidationError
|
||||
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.plugin.wraps import get_user
|
||||
from controllers.inner_api.wraps import plugin_inner_api_only
|
||||
from services.agent_drive_service import (
|
||||
AgentDriveError,
|
||||
@@ -56,6 +59,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
|
||||
@@ -69,9 +90,10 @@ class AgentDriveCommitApi(Resource):
|
||||
body = _CommitRequest.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as exc:
|
||||
raise AgentDriveError("invalid_request", str(exc), status_code=400) from exc
|
||||
user = get_user(body.tenant_id, body.user_id)
|
||||
items = AgentDriveService().commit(
|
||||
tenant_id=body.tenant_id,
|
||||
user_id=body.user_id,
|
||||
user_id=user.id,
|
||||
agent_id=agent_id,
|
||||
items=body.items,
|
||||
)
|
||||
|
||||
@@ -37,6 +37,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_ask_human_layer_config,
|
||||
build_drive_aware_soul_mention_resolver,
|
||||
build_drive_layer_config,
|
||||
build_knowledge_layer_config,
|
||||
build_shell_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,
|
||||
)
|
||||
@@ -55,10 +54,13 @@ from models.agent_config_entities import (
|
||||
)
|
||||
from models.provider_ids import ModelProviderID
|
||||
from services.agent.prompt_mentions import (
|
||||
MentionKind,
|
||||
build_node_job_mention_resolver,
|
||||
build_soul_mention_resolver,
|
||||
expand_prompt_mentions,
|
||||
parse_prompt_mentions,
|
||||
)
|
||||
from services.agent_drive_service import AgentDriveService, decode_drive_mention_ref
|
||||
|
||||
from .output_failure_orchestrator import retry_idempotency_key
|
||||
from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError
|
||||
@@ -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,107 @@ 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:
|
||||
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,
|
||||
@@ -396,20 +394,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)
|
||||
@@ -417,7 +401,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")}
|
||||
|
||||
+44
-1
@@ -6,9 +6,15 @@ Create Date: 2026-06-18 23:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
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"
|
||||
@@ -31,9 +37,46 @@ def upgrade() -> None:
|
||||
"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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1622,7 +1622,7 @@ Inspect one drive-backed skill for slash-menu hover/detail UI
|
||||
| 200 | Drive skill inspect view | **application/json**: [AgentDriveSkillInspectResponse](#agentdriveskillinspectresponse)<br> |
|
||||
|
||||
### [DELETE] /apps/{app_id}/agent/files
|
||||
Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5)
|
||||
Delete one drive file by key via drive commit-null semantics
|
||||
|
||||
#### Parameters
|
||||
|
||||
@@ -1708,7 +1708,7 @@ Upload + standardize a Skill into the agent drive
|
||||
| 400 | Invalid skill package or no bound agent | |
|
||||
|
||||
### [DELETE] /apps/{app_id}/agent/skills/{slug}
|
||||
Delete a standardized skill: soul ref first, then the <slug>/ drive prefix (ENG-625 D5)
|
||||
Delete a standardized skill by removing its known drive keys via commit-null
|
||||
|
||||
#### Parameters
|
||||
|
||||
@@ -12417,23 +12417,6 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| provider_id | string | | No |
|
||||
| tools_count | integer | | No |
|
||||
|
||||
#### AgentComposerFileCandidateResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| drive_key | string | | No |
|
||||
| file_id | string | | No |
|
||||
| id | string | | No |
|
||||
| kind | string, <br>**Default:** file | | No |
|
||||
| name | string | | No |
|
||||
| reference | string | | No |
|
||||
| remote_url | string | | No |
|
||||
| tenant_id | string | | No |
|
||||
| transfer_method | string | | No |
|
||||
| type | string | | No |
|
||||
| upload_file_id | string | | No |
|
||||
| url | string | | No |
|
||||
|
||||
#### AgentComposerImpactBindingResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -12458,22 +12441,6 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
|
||||
| previous_node_outputs | [ [WorkflowPreviousNodeOutputRef](#workflowpreviousnodeoutputref) ] | | No |
|
||||
|
||||
#### AgentComposerSkillCandidateResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | No |
|
||||
| file_id | string | | No |
|
||||
| full_archive_file_id | string | | No |
|
||||
| full_archive_key | string | | No |
|
||||
| id | string | | No |
|
||||
| kind | string, <br>**Default:** skill | | No |
|
||||
| manifest_files | [ string ] | | No |
|
||||
| name | string | | No |
|
||||
| path | string | | No |
|
||||
| skill_md_file_id | string | | No |
|
||||
| skill_md_key | string | | No |
|
||||
|
||||
#### AgentComposerSoulCandidatesResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -12482,7 +12449,6 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| dify_tools | [ [AgentComposerDifyToolCandidateResponse](#agentcomposerdifytoolcandidateresponse) ] | | No |
|
||||
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
|
||||
| knowledge_datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
|
||||
| skills_files | [ ] | | No |
|
||||
|
||||
#### AgentComposerSoulLockResponse
|
||||
|
||||
@@ -12603,7 +12569,6 @@ Audit operation recorded for Agent Soul version/revision changes.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| config_version_id | string | | No |
|
||||
| removed_keys | [ string ] | | No |
|
||||
| result | string | | Yes |
|
||||
|
||||
@@ -12617,7 +12582,6 @@ Audit operation recorded for Agent Soul version/revision changes.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| config_version_id | string | | No |
|
||||
| file | [AgentDriveFileResponse](#agentdrivefileresponse) | | Yes |
|
||||
|
||||
#### AgentDriveFilePayload
|
||||
@@ -13203,27 +13167,12 @@ Visibility and lifecycle scope of an Agent record.
|
||||
| enabled | boolean | | No |
|
||||
| type | string | | No |
|
||||
|
||||
#### AgentSkillRefConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | No |
|
||||
| file_id | string | | No |
|
||||
| full_archive_file_id | string | | No |
|
||||
| full_archive_key | string | | No |
|
||||
| id | string | | No |
|
||||
| manifest_files | [ string ] | | No |
|
||||
| name | string | | No |
|
||||
| path | string | | No |
|
||||
| skill_md_file_id | string | | No |
|
||||
| skill_md_key | string | | No |
|
||||
|
||||
#### AgentSkillUploadResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| manifest | [SkillManifest](#skillmanifest) | | Yes |
|
||||
| skill | [AgentSkillRefConfig](#agentskillrefconfig) | | Yes |
|
||||
| skill | [AgentUploadedSkillResponse](#agentuploadedskillresponse) | | Yes |
|
||||
|
||||
#### AgentSoulAppFeaturesConfig
|
||||
|
||||
@@ -13252,7 +13201,6 @@ Visibility and lifecycle scope of an Agent record.
|
||||
| prompt | [AgentSoulPromptConfig](#agentsoulpromptconfig) | | No |
|
||||
| sandbox | [AgentSoulSandboxConfig](#agentsoulsandboxconfig) | | No |
|
||||
| schema_version | integer, <br>**Default:** 1 | | No |
|
||||
| skills_files | [AgentSoulSkillsFilesConfig](#agentsoulskillsfilesconfig) | | No |
|
||||
| tools | [AgentSoulToolsConfig](#agentsoultoolsconfig) | | No |
|
||||
|
||||
#### AgentSoulDifyToolConfig
|
||||
@@ -13369,13 +13317,6 @@ Reference to model credentials resolved only at runtime.
|
||||
| config | [AgentSandboxProviderConfig](#agentsandboxproviderconfig) | | No |
|
||||
| provider | string | | No |
|
||||
|
||||
#### AgentSoulSkillsFilesConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| files | [ [AgentFileRefConfig](#agentfilerefconfig) ] | | No |
|
||||
| skills | [ [AgentSkillRefConfig](#agentskillrefconfig) ] | | No |
|
||||
|
||||
#### AgentSoulToolsConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -13507,6 +13448,16 @@ Soft lifecycle state for Agent records.
|
||||
| tool_output | object | | Yes |
|
||||
| tool_parameters | object | | Yes |
|
||||
|
||||
#### AgentUploadedSkillResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| archive_key | string | | No |
|
||||
| description | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
| path | string | | Yes |
|
||||
| skill_md_key | string | | Yes |
|
||||
|
||||
#### AgentUserSatisfactionRateStatisticResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@@ -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
|
||||
@@ -23,7 +24,6 @@ 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, DriveSkillMetadata
|
||||
|
||||
@@ -134,26 +134,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,11 +8,13 @@ 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
|
||||
@@ -30,7 +32,6 @@ 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
|
||||
@@ -93,7 +94,7 @@ 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
|
||||
@@ -385,6 +386,15 @@ 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
|
||||
@@ -447,6 +457,45 @@ class AgentDriveService:
|
||||
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 {
|
||||
@@ -750,6 +799,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:
|
||||
|
||||
@@ -14,6 +14,7 @@ from dify_agent.layers.dify_plugin import (
|
||||
DifyPluginToolConfig,
|
||||
DifyPluginToolsLayerConfig,
|
||||
)
|
||||
from dify_agent.layers.drive import DifyDriveLayerConfig
|
||||
from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.knowledge import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig
|
||||
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID
|
||||
@@ -42,7 +43,7 @@ from clients.agent_backend import (
|
||||
extract_runtime_layer_specs,
|
||||
redact_for_agent_backend_log,
|
||||
)
|
||||
from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID
|
||||
from clients.agent_backend.request_builder import DIFY_DRIVE_LAYER_ID, DIFY_SHELL_LAYER_ID
|
||||
|
||||
|
||||
def _run_input() -> AgentBackendWorkflowNodeRunInput:
|
||||
@@ -331,6 +332,22 @@ def test_workflow_request_builder_adds_shell_layer_when_include_shell():
|
||||
assert shell_config.env[0].name == "PROJECT_NAME"
|
||||
|
||||
|
||||
def test_workflow_request_builder_binds_shell_to_drive_when_configured():
|
||||
run_input = _run_input()
|
||||
run_input.include_shell = True
|
||||
run_input.drive_config = DifyDriveLayerConfig(drive_ref="agent-agent-1")
|
||||
|
||||
request = AgentBackendRunRequestBuilder().build_for_workflow_node(run_input)
|
||||
layers = {layer.name: layer for layer in request.composition.layers}
|
||||
layer_names = [layer.name for layer in request.composition.layers]
|
||||
|
||||
assert layers[DIFY_SHELL_LAYER_ID].deps == {
|
||||
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
"drive": DIFY_DRIVE_LAYER_ID,
|
||||
}
|
||||
assert layer_names.index(DIFY_DRIVE_LAYER_ID) < layer_names.index(DIFY_SHELL_LAYER_ID)
|
||||
|
||||
|
||||
def test_agent_app_request_builder_omits_shell_layer_by_default():
|
||||
request = AgentBackendRunRequestBuilder().build_for_agent_app(_agent_app_input())
|
||||
assert DIFY_SHELL_LAYER_ID not in {layer.name for layer in request.composition.layers}
|
||||
@@ -350,6 +367,21 @@ def test_agent_app_request_builder_adds_shell_layer_when_include_shell():
|
||||
assert shell_config.env[0].name == "APP_ENV"
|
||||
|
||||
|
||||
def test_agent_app_request_builder_binds_shell_to_drive_when_configured():
|
||||
run_input = _agent_app_input(include_shell=True)
|
||||
run_input.drive_config = DifyDriveLayerConfig(drive_ref="agent-agent-1")
|
||||
|
||||
request = AgentBackendRunRequestBuilder().build_for_agent_app(run_input)
|
||||
layers = {layer.name: layer for layer in request.composition.layers}
|
||||
layer_names = [layer.name for layer in request.composition.layers]
|
||||
|
||||
assert layers[DIFY_SHELL_LAYER_ID].deps == {
|
||||
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
"drive": DIFY_DRIVE_LAYER_ID,
|
||||
}
|
||||
assert layer_names.index(DIFY_DRIVE_LAYER_ID) < layer_names.index(DIFY_SHELL_LAYER_ID)
|
||||
|
||||
|
||||
def test_agent_app_request_builder_adds_knowledge_layer_when_configured():
|
||||
run_input = _agent_app_input()
|
||||
run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate(
|
||||
|
||||
@@ -12,6 +12,7 @@ import io
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.console.app.agent import (
|
||||
@@ -152,27 +153,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 +178,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 +224,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 +237,15 @@ 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 +253,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 +272,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 +285,11 @@ 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")
|
||||
with pytest.raises(RuntimeError, match="storage down"):
|
||||
raw(AgentDriveFilesApi(), _USER, _APP)
|
||||
|
||||
|
||||
def test_skill_delete_uses_slug_prefix_and_is_idempotent():
|
||||
@@ -321,17 +298,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 +317,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():
|
||||
|
||||
@@ -8,12 +8,13 @@ controller's request parsing + error mapping, not auth (tested separately).
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from types import SimpleNamespace
|
||||
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 +53,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 = {
|
||||
@@ -60,11 +96,35 @@ def test_commit_parses_body_and_returns_items():
|
||||
"items": [{"key": "a.txt", "file_ref": {"kind": "tool_file", "id": "tf-1"}}],
|
||||
}
|
||||
with app.test_request_context("/", method="POST", json=payload):
|
||||
with patch(f"{_MOD}.AgentDriveService") as svc:
|
||||
with (
|
||||
patch(f"{_MOD}.get_user", return_value=SimpleNamespace(id="user-1")) as get_user,
|
||||
patch(f"{_MOD}.AgentDriveService") as svc,
|
||||
):
|
||||
svc.return_value.commit.return_value = [{"key": "a.txt"}]
|
||||
result = raw(AgentDriveCommitApi(), "agent-agent-1")
|
||||
assert result == {"items": [{"key": "a.txt"}]}
|
||||
assert get_user.call_args.args == ("tenant-1", "user-1")
|
||||
assert svc.return_value.commit.call_args.kwargs["agent_id"] == "agent-1"
|
||||
assert svc.return_value.commit.call_args.kwargs["user_id"] == "user-1"
|
||||
|
||||
|
||||
def test_commit_canonicalizes_user_before_service_call():
|
||||
raw = _raw(AgentDriveCommitApi.post)
|
||||
payload = {
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "session-1",
|
||||
"items": [{"key": "a.txt", "file_ref": {"kind": "tool_file", "id": "tf-1"}}],
|
||||
}
|
||||
with app.test_request_context("/", method="POST", json=payload):
|
||||
with (
|
||||
patch(f"{_MOD}.get_user", return_value=SimpleNamespace(id="end-user-1")),
|
||||
patch(f"{_MOD}.AgentDriveService") as svc,
|
||||
):
|
||||
svc.return_value.commit.return_value = [{"key": "a.txt"}]
|
||||
result = raw(AgentDriveCommitApi(), "agent-agent-1")
|
||||
|
||||
assert result == {"items": [{"key": "a.txt"}]}
|
||||
assert svc.return_value.commit.call_args.kwargs["user_id"] == "end-user-1"
|
||||
|
||||
|
||||
def test_commit_invalid_body_is_400():
|
||||
@@ -83,13 +143,16 @@ def test_commit_maps_service_error():
|
||||
"items": [{"key": "a.txt", "file_ref": {"kind": "tool_file", "id": "tf-1"}}],
|
||||
}
|
||||
with app.test_request_context("/", method="POST", json=payload):
|
||||
with patch(f"{_MOD}.AgentDriveService") as svc:
|
||||
with (
|
||||
patch(f"{_MOD}.get_user", return_value=SimpleNamespace(id="user-1")),
|
||||
patch(f"{_MOD}.AgentDriveService") as svc,
|
||||
):
|
||||
svc.return_value.commit.side_effect = AgentDriveError("source_not_found", "nope", status_code=404)
|
||||
body, status = raw(AgentDriveCommitApi(), "agent-agent-1")
|
||||
assert status == 404
|
||||
assert body["code"] == "source_not_found"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("api_cls", [AgentDriveManifestApi, AgentDriveCommitApi])
|
||||
@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))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Unit tests for the inner knowledge retrieval controller."""
|
||||
"""Unit tests for the plugin inner knowledge retrieval controller."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -53,31 +55,38 @@ def _payload() -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _plugin_inner_auth() -> Iterator[None]:
|
||||
with (
|
||||
patch("configs.dify_config.PLUGIN_DAEMON_KEY", "plugin-daemon-key"),
|
||||
patch("configs.dify_config.INNER_API_KEY_FOR_PLUGIN", "inner-key"),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
class TestInnerKnowledgeRetrieveApi:
|
||||
def test_post_returns_401_when_api_key_missing(self, inner_api_app: Flask):
|
||||
with patch("configs.dify_config.INNER_API", True):
|
||||
def test_post_returns_404_when_api_key_missing(self, inner_api_app: Flask):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json=_payload(),
|
||||
headers=_headers(api_key=None),
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.get_json()["code"] == "inner_api_unauthorized"
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_post_returns_401_when_api_key_invalid(self, inner_api_app: Flask):
|
||||
with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"):
|
||||
def test_post_returns_404_when_api_key_invalid(self, inner_api_app: Flask):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json=_payload(),
|
||||
headers=_headers(api_key="wrong-key"),
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.get_json()["code"] == "inner_api_unauthorized"
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_post_returns_400_for_invalid_body(self, inner_api_app: Flask):
|
||||
with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json={"caller": {"tenant_id": "tenant-1"}},
|
||||
@@ -91,7 +100,7 @@ class TestInnerKnowledgeRetrieveApi:
|
||||
def test_post_returns_404_for_service_not_found_error(self, mock_retrieve, inner_api_app: Flask):
|
||||
mock_retrieve.side_effect = InnerKnowledgeRetrieveAppNotFoundError("app missing")
|
||||
|
||||
with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json=_payload(),
|
||||
@@ -105,7 +114,7 @@ class TestInnerKnowledgeRetrieveApi:
|
||||
def test_post_returns_403_for_service_forbidden_error(self, mock_retrieve, inner_api_app: Flask):
|
||||
mock_retrieve.side_effect = InnerKnowledgeRetrieveDatasetTenantMismatchError("wrong tenant")
|
||||
|
||||
with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json=_payload(),
|
||||
@@ -119,7 +128,7 @@ class TestInnerKnowledgeRetrieveApi:
|
||||
def test_post_returns_422_for_retrieval_config_value_error(self, mock_retrieve, inner_api_app: Flask):
|
||||
mock_retrieve.side_effect = ValueError("invalid reranking config")
|
||||
|
||||
with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json=_payload(),
|
||||
@@ -133,7 +142,7 @@ class TestInnerKnowledgeRetrieveApi:
|
||||
def test_post_returns_429_for_rate_limit_error(self, mock_retrieve, inner_api_app: Flask):
|
||||
mock_retrieve.side_effect = RateLimitExceededError("knowledge rate limited")
|
||||
|
||||
with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json=_payload(),
|
||||
@@ -147,7 +156,7 @@ class TestInnerKnowledgeRetrieveApi:
|
||||
payload = _payload()
|
||||
payload["metadata_filtering"] = {"mode": "manual"}
|
||||
|
||||
with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json=payload,
|
||||
@@ -161,7 +170,7 @@ class TestInnerKnowledgeRetrieveApi:
|
||||
payload = _payload()
|
||||
payload["metadata_filtering"] = {"mode": "automatic"}
|
||||
|
||||
with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json=payload,
|
||||
@@ -175,7 +184,7 @@ class TestInnerKnowledgeRetrieveApi:
|
||||
def test_post_returns_502_for_external_knowledge_failure(self, mock_retrieve, inner_api_app: Flask):
|
||||
mock_retrieve.side_effect = ExternalKnowledgeRetrievalError("upstream failed")
|
||||
|
||||
with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json=_payload(),
|
||||
@@ -219,7 +228,7 @@ class TestInnerKnowledgeRetrieveApi:
|
||||
),
|
||||
)
|
||||
|
||||
with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"):
|
||||
with _plugin_inner_auth():
|
||||
response = inner_api_app.test_client().post(
|
||||
"/inner/api/knowledge/retrieve",
|
||||
json=_payload(),
|
||||
|
||||
@@ -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,12 +267,42 @@ 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
|
||||
|
||||
def test_drive_layer_injected_with_empty_catalog_and_shell_depends_on_it(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
monkeypatch.setattr("core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_SHELL_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: [],
|
||||
)
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
result = builder.build(_ctx(_soul_with_model()))
|
||||
|
||||
layers = {layer.name: layer for layer in result.request.composition.layers}
|
||||
assert layers["drive"].config.drive_ref == "agent-agent-1"
|
||||
assert layers["drive"].config.skills == []
|
||||
assert layers[DIFY_SHELL_LAYER_ID].deps == {
|
||||
"execution_context": "execution_context",
|
||||
"drive": "drive",
|
||||
}
|
||||
|
||||
def test_no_drive_layer_when_flag_disabled(self):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
@@ -269,3 +310,152 @@ 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
|
||||
|
||||
+143
-38
@@ -834,57 +834,116 @@ 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."},
|
||||
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
|
||||
],
|
||||
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"),
|
||||
)
|
||||
|
||||
|
||||
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 _mock_empty_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: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=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_emits_drive_ref_when_catalog_is_empty(monkeypatch: pytest.MonkeyPatch):
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import build_drive_layer_config
|
||||
|
||||
_mock_empty_drive_catalog(monkeypatch)
|
||||
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, [])
|
||||
config, warnings = build_drive_layer_config(soul, tenant_id="tenant-1", agent_id="agent-1")
|
||||
|
||||
assert config is not None
|
||||
assert config.drive_ref == "agent-agent-1"
|
||||
assert config.skills == []
|
||||
assert config.mentioned_skill_keys == []
|
||||
assert config.mentioned_file_keys == []
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_workflow_run_request_contains_drive_layer_with_empty_catalog(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.dify_config.AGENT_SHELL_ENABLED", True)
|
||||
_mock_empty_drive_catalog(monkeypatch)
|
||||
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(_context())
|
||||
|
||||
dumped = result.request.model_dump(mode="json")
|
||||
layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]}
|
||||
assert layers["drive"]["config"] == {
|
||||
"drive_ref": "agent-agent-1",
|
||||
"skills": [],
|
||||
"mentioned_skill_keys": [],
|
||||
"mentioned_file_keys": [],
|
||||
}
|
||||
assert layers[DIFY_SHELL_LAYER_ID]["deps"] == {
|
||||
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
"drive": "drive",
|
||||
}
|
||||
|
||||
|
||||
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 +951,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 +964,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 +986,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 +1039,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,122 @@
|
||||
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(__file__).resolve().parents[3]
|
||||
/ "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)
|
||||
@@ -17,7 +17,6 @@ from models.agent import (
|
||||
WorkflowAgentNodeBinding,
|
||||
)
|
||||
from models.agent_config_entities import (
|
||||
AgentFileRefConfig,
|
||||
DeclaredArrayItem,
|
||||
DeclaredOutputChildConfig,
|
||||
DeclaredOutputConfig,
|
||||
@@ -2649,18 +2648,17 @@ def test_workspace_dify_tools_returns_provider_and_tool_granularities(monkeypatc
|
||||
assert {entry["granularity"] for entry in entries[1:]} == {"tool"}
|
||||
|
||||
|
||||
# ── ENG-623 §4.4: drive-backed ref validation ────────────────────────────────
|
||||
# ── ENG-623 §4.4: drive-backed prompt mention validation ─────────────────────
|
||||
|
||||
|
||||
def _drive_soul(**overrides):
|
||||
from services.entities.agent_entities import AgentSoulConfig
|
||||
|
||||
base = {
|
||||
"skills_files": {
|
||||
"skills": [
|
||||
{"id": "sk-1", "name": "Tender Analyzer", "skill_md_key": "tender-analyzer/SKILL.md"},
|
||||
],
|
||||
"files": [{"name": "sample.pdf", "drive_key": "files/sample.pdf"}],
|
||||
"prompt": {
|
||||
"system_prompt": (
|
||||
"Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]."
|
||||
)
|
||||
},
|
||||
}
|
||||
base.update(overrides)
|
||||
@@ -2680,47 +2678,47 @@ def _patch_drive_keys(monkeypatch, existing_keys):
|
||||
return captured
|
||||
|
||||
|
||||
def test_drive_ref_findings_reports_missing_keys(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_drive_mention_findings_reports_missing_keys(monkeypatch: pytest.MonkeyPatch):
|
||||
_patch_drive_keys(monkeypatch, existing_keys=["tender-analyzer/SKILL.md"])
|
||||
|
||||
findings = AgentComposerService._drive_ref_findings(
|
||||
tenant_id="tenant-1", agent_id="agent-1", agent_soul=_drive_soul()
|
||||
findings = AgentComposerService._drive_mention_findings(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
prompt=_drive_soul().prompt.system_prompt,
|
||||
)
|
||||
|
||||
assert [(f["code"], f["id"]) for f in findings] == [("file_ref_dangling", "files/sample.pdf")]
|
||||
assert str(findings[0]["message"]).startswith("file_ref_dangling: ")
|
||||
assert [(f["code"], f["id"]) for f in findings] == [("mention_target_missing", "files/sample.pdf")]
|
||||
assert findings[0]["kind"] == "file"
|
||||
assert str(findings[0]["message"]).startswith("file 'sample.pdf' has no drive entry")
|
||||
|
||||
|
||||
def test_drive_ref_findings_clean_when_all_keys_exist(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_drive_mention_findings_clean_when_all_keys_exist(monkeypatch: pytest.MonkeyPatch):
|
||||
_patch_drive_keys(monkeypatch, existing_keys=["tender-analyzer/SKILL.md", "files/sample.pdf"])
|
||||
|
||||
assert (
|
||||
AgentComposerService._drive_ref_findings(tenant_id="tenant-1", agent_id="agent-1", agent_soul=_drive_soul())
|
||||
AgentComposerService._drive_mention_findings(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
prompt=_drive_soul().prompt.system_prompt,
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_drive_ref_findings_skips_refs_without_drive_keys(monkeypatch: pytest.MonkeyPatch):
|
||||
# No drive-backed ref at all -> no DB roundtrip, no findings.
|
||||
soul = _drive_soul(
|
||||
skills_files={"skills": [{"id": "legacy", "name": "Legacy"}], "files": [{"name": "u.pdf", "file_id": "u-1"}]}
|
||||
def test_drive_mention_findings_skips_prompt_without_drive_mentions(monkeypatch: pytest.MonkeyPatch):
|
||||
# No drive-backed mention at all -> no DB roundtrip, no findings.
|
||||
soul = _drive_soul(prompt={"system_prompt": "Use [§knowledge:kb-1:Docs§]."})
|
||||
findings = AgentComposerService._drive_mention_findings(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
prompt=soul.prompt.system_prompt,
|
||||
)
|
||||
findings = AgentComposerService._drive_ref_findings(tenant_id="tenant-1", agent_id="agent-1", agent_soul=soul)
|
||||
assert findings == []
|
||||
|
||||
|
||||
def test_require_drive_refs_resolved_raises_with_stable_code(monkeypatch: pytest.MonkeyPatch):
|
||||
from services.agent.errors import InvalidComposerConfigError
|
||||
|
||||
_patch_drive_keys(monkeypatch, existing_keys=[])
|
||||
|
||||
with pytest.raises(InvalidComposerConfigError, match="skill_ref_dangling"):
|
||||
AgentComposerService._require_drive_refs_resolved(
|
||||
tenant_id="tenant-1", agent_id="agent-1", agent_soul=_drive_soul()
|
||||
)
|
||||
|
||||
|
||||
def test_collect_validation_findings_appends_drive_findings_with_agent_context(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_collect_validation_findings_appends_drive_mention_findings_with_agent_context(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
from services.entities.agent_entities import ComposerSavePayload
|
||||
|
||||
_patch_drive_keys(monkeypatch, existing_keys=[])
|
||||
@@ -2737,149 +2735,14 @@ def test_collect_validation_findings_appends_drive_findings_with_agent_context(m
|
||||
)
|
||||
|
||||
codes = {w["code"] for w in findings["warnings"]}
|
||||
assert {"skill_ref_dangling", "file_ref_dangling"} <= codes
|
||||
assert codes >= {"mention_target_missing"}
|
||||
assert {w["id"] for w in findings["warnings"] if w["code"] == "mention_target_missing"} == {
|
||||
"tender-analyzer/SKILL.md",
|
||||
"files/sample.pdf",
|
||||
}
|
||||
# without agent context the drive check is skipped entirely
|
||||
findings_no_agent = AgentComposerService.collect_validation_findings(tenant_id="tenant-1", payload=payload)
|
||||
assert all(w["code"] not in {"skill_ref_dangling", "file_ref_dangling"} for w in findings_no_agent["warnings"])
|
||||
|
||||
|
||||
# ── ENG-625 D5: soul-first ref removal ───────────────────────────────────────
|
||||
|
||||
|
||||
def _patch_remove_drive_refs_env(monkeypatch: pytest.MonkeyPatch, *, soul_dict):
|
||||
"""Wire the classmethod's collaborators so soul editing + versioning is observable."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import services.agent.composer_service as module
|
||||
|
||||
agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="snap-1", updated_by=None)
|
||||
snapshot = SimpleNamespace(id="snap-1", tenant_id="tenant-1", agent_id="agent-1", config_snapshot_dict=soul_dict)
|
||||
committed: dict[str, object] = {}
|
||||
|
||||
fake_session = SimpleNamespace(scalar=lambda stmt: agent, commit=lambda: committed.setdefault("committed", True))
|
||||
monkeypatch.setattr(module.db, "session", fake_session)
|
||||
monkeypatch.setattr(AgentComposerService, "_require_version", classmethod(lambda cls, **kwargs: snapshot))
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_update(cls, *, current_snapshot, account_id, agent_soul, operation, version_note):
|
||||
captured["agent_soul"] = agent_soul
|
||||
captured["version_note"] = version_note
|
||||
return SimpleNamespace(id="snap-2")
|
||||
|
||||
monkeypatch.setattr(AgentComposerService, "_update_current_version", classmethod(fake_update))
|
||||
return agent, captured, committed
|
||||
|
||||
|
||||
def test_remove_drive_refs_drops_skill_by_slug_and_versions(monkeypatch: pytest.MonkeyPatch):
|
||||
soul_dict = {
|
||||
"skills_files": {
|
||||
"skills": [
|
||||
{"id": "sk-1", "name": "Tender Analyzer", "skill_md_key": "tender-analyzer/SKILL.md"},
|
||||
{"id": "sk-2", "name": "Other", "skill_md_key": "other-skill/SKILL.md"},
|
||||
],
|
||||
"files": [],
|
||||
}
|
||||
}
|
||||
agent, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
|
||||
|
||||
version_id = AgentComposerService.remove_drive_refs(
|
||||
tenant_id="tenant-1", agent_id="agent-1", account_id="acc-1", skill_slug="tender-analyzer"
|
||||
)
|
||||
|
||||
assert version_id == "snap-2"
|
||||
assert agent.active_config_snapshot_id == "snap-2"
|
||||
kept = [s.skill_md_key for s in captured["agent_soul"].skills_files.skills]
|
||||
assert kept == ["other-skill/SKILL.md"]
|
||||
assert "Tender Analyzer" in str(captured["version_note"])
|
||||
assert committed.get("committed") is True
|
||||
|
||||
|
||||
def test_remove_drive_refs_is_noop_when_ref_absent(monkeypatch: pytest.MonkeyPatch):
|
||||
soul_dict = {"skills_files": {"skills": [], "files": []}}
|
||||
agent, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
|
||||
|
||||
assert (
|
||||
AgentComposerService.remove_drive_refs(
|
||||
tenant_id="tenant-1", agent_id="agent-1", account_id="acc-1", file_key="files/none.pdf"
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert "agent_soul" not in captured
|
||||
assert committed == {}
|
||||
|
||||
|
||||
def test_remove_drive_refs_drops_file_by_key(monkeypatch: pytest.MonkeyPatch):
|
||||
soul_dict = {
|
||||
"skills_files": {
|
||||
"skills": [],
|
||||
"files": [
|
||||
{"name": "keep.pdf", "drive_key": "files/keep.pdf"},
|
||||
{"name": "drop.pdf", "drive_key": "files/drop.pdf"},
|
||||
],
|
||||
}
|
||||
}
|
||||
_, captured, _ = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
|
||||
|
||||
version_id = AgentComposerService.remove_drive_refs(
|
||||
tenant_id="tenant-1", agent_id="agent-1", account_id="acc-1", file_key="files/drop.pdf"
|
||||
)
|
||||
|
||||
assert version_id == "snap-2"
|
||||
assert [f.drive_key for f in captured["agent_soul"].skills_files.files] == ["files/keep.pdf"]
|
||||
|
||||
|
||||
def test_add_drive_file_ref_adds_or_replaces_file_and_versions(monkeypatch: pytest.MonkeyPatch):
|
||||
soul_dict = {
|
||||
"skills_files": {
|
||||
"skills": [],
|
||||
"files": [
|
||||
{"name": "old.pdf", "drive_key": "files/old.pdf"},
|
||||
{"name": "stale.pdf", "drive_key": "files/new.pdf"},
|
||||
],
|
||||
}
|
||||
}
|
||||
agent, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
|
||||
|
||||
version_id = AgentComposerService.add_drive_file_ref(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="acc-1",
|
||||
file_ref=AgentFileRefConfig(name="new.pdf", file_id="uf-1", drive_key="files/new.pdf", type="application/pdf"),
|
||||
)
|
||||
|
||||
assert version_id == "snap-2"
|
||||
assert agent.active_config_snapshot_id == "snap-2"
|
||||
assert [f.drive_key for f in captured["agent_soul"].skills_files.files] == ["files/old.pdf", "files/new.pdf"]
|
||||
assert captured["agent_soul"].skills_files.files[-1].name == "new.pdf"
|
||||
assert "new.pdf" in str(captured["version_note"])
|
||||
assert committed.get("committed") is True
|
||||
|
||||
|
||||
def test_add_drive_file_ref_syncs_workflow_binding_snapshot(monkeypatch: pytest.MonkeyPatch):
|
||||
binding = SimpleNamespace(agent_id="agent-1", current_snapshot_id="snap-1", updated_by=None)
|
||||
_patch_remove_drive_refs_env(monkeypatch, soul_dict={"skills_files": {"skills": [], "files": []}})
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1"))
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding))
|
||||
|
||||
AgentComposerService.add_drive_file_ref(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="acc-1",
|
||||
file_ref=AgentFileRefConfig(name="new.pdf", file_id="uf-1", drive_key="files/new.pdf"),
|
||||
app_id="app-1",
|
||||
node_id="agent-node-1",
|
||||
)
|
||||
|
||||
assert binding.current_snapshot_id == "snap-2"
|
||||
assert binding.updated_by == "acc-1"
|
||||
|
||||
|
||||
def test_remove_drive_refs_requires_exactly_one_scope():
|
||||
with pytest.raises(ValueError):
|
||||
AgentComposerService.remove_drive_refs(tenant_id="t", agent_id="a", account_id="u")
|
||||
assert all(w["code"] != "mention_target_missing" for w in findings_no_agent["warnings"])
|
||||
|
||||
|
||||
# ── ENG-623/625: resolver helpers + save-path drive guard ────────────────────
|
||||
@@ -2917,58 +2780,7 @@ def test_resolve_workflow_node_agent_id_degrades_without_workflow_or_binding(mon
|
||||
assert AgentComposerService.resolve_workflow_node_agent_id(tenant_id="t", app_id="a", node_id="n") == "agent-7"
|
||||
|
||||
|
||||
def test_remove_drive_refs_returns_none_without_agent_or_snapshot(monkeypatch: pytest.MonkeyPatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
import services.agent.composer_service as module
|
||||
|
||||
monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: None))
|
||||
assert AgentComposerService.remove_drive_refs(tenant_id="t", agent_id="a", account_id="u", skill_slug="s") is None
|
||||
|
||||
agent_without_snapshot = SimpleNamespace(id="a", active_config_snapshot_id=None)
|
||||
monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: agent_without_snapshot))
|
||||
assert AgentComposerService.remove_drive_refs(tenant_id="t", agent_id="a", account_id="u", skill_slug="s") is None
|
||||
|
||||
|
||||
def test_save_workflow_composer_guards_drive_refs_for_existing_agent_strategies(monkeypatch: pytest.MonkeyPatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services.entities.agent_entities import ComposerSavePayload
|
||||
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "workflow",
|
||||
"save_strategy": "save_to_current_version",
|
||||
"agent_soul": _drive_soul().model_dump(mode="json"),
|
||||
"soul_lock": {"locked": False},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1"))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_get_workflow_binding",
|
||||
classmethod(lambda cls, **kwargs: SimpleNamespace(agent_id="agent-1")),
|
||||
)
|
||||
guarded: dict[str, str] = {}
|
||||
|
||||
def fake_guard(cls, *, tenant_id, agent_id, agent_soul):
|
||||
guarded["agent_id"] = agent_id
|
||||
raise InvalidComposerConfigError("skill_ref_dangling: boom")
|
||||
|
||||
from services.agent.errors import InvalidComposerConfigError
|
||||
|
||||
monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fake_guard))
|
||||
|
||||
with pytest.raises(InvalidComposerConfigError, match="skill_ref_dangling"):
|
||||
AgentComposerService.save_workflow_composer(
|
||||
tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload
|
||||
)
|
||||
assert guarded["agent_id"] == "agent-1"
|
||||
|
||||
|
||||
def test_save_workflow_composer_guards_drive_refs_for_inline_node_job_only(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_save_workflow_composer_reports_drive_mentions_for_inline_node_job_only(monkeypatch: pytest.MonkeyPatch):
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "workflow",
|
||||
@@ -3006,26 +2818,27 @@ def test_save_workflow_composer_guards_drive_refs_for_inline_node_job_only(monke
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"})
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "collect_validation_findings", classmethod(lambda cls, **kwargs: {"warnings": []})
|
||||
)
|
||||
guarded: dict[str, str] = {}
|
||||
|
||||
def fake_guard(cls, *, tenant_id, agent_id, agent_soul):
|
||||
def fake_collect(cls, *, tenant_id, payload, agent_id=None):
|
||||
guarded["tenant_id"] = tenant_id
|
||||
guarded["agent_id"] = agent_id
|
||||
return {"warnings": [{"code": "mention_target_missing", "id": "files/sample.pdf"}]}
|
||||
|
||||
monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fake_guard))
|
||||
monkeypatch.setattr(AgentComposerService, "collect_validation_findings", classmethod(fake_collect))
|
||||
|
||||
result = AgentComposerService.save_workflow_composer(
|
||||
tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload
|
||||
)
|
||||
|
||||
assert result == {"state": "ok", "validation": {"warnings": []}}
|
||||
assert result == {
|
||||
"state": "ok",
|
||||
"validation": {"warnings": [{"code": "mention_target_missing", "id": "files/sample.pdf"}]},
|
||||
}
|
||||
assert guarded == {"tenant_id": "t-1", "agent_id": "agent-1"}
|
||||
|
||||
|
||||
def test_save_workflow_composer_skips_drive_refs_for_roster_node_job_only(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_save_workflow_composer_reports_drive_mentions_for_roster_node_job_only(monkeypatch: pytest.MonkeyPatch):
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "workflow",
|
||||
@@ -3063,29 +2876,17 @@ def test_save_workflow_composer_skips_drive_refs_for_roster_node_job_only(monkey
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"})
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "collect_validation_findings", classmethod(lambda cls, **kwargs: {"warnings": []})
|
||||
)
|
||||
captured: dict[str, str | None] = {}
|
||||
|
||||
def fail_guard(cls, *, tenant_id, agent_id, agent_soul):
|
||||
raise AssertionError("roster node-job-only saves must not validate agent drive refs")
|
||||
def fake_collect(cls, *, tenant_id, payload, agent_id=None):
|
||||
captured["agent_id"] = agent_id
|
||||
return {"warnings": []}
|
||||
|
||||
monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fail_guard))
|
||||
monkeypatch.setattr(AgentComposerService, "collect_validation_findings", classmethod(fake_collect))
|
||||
|
||||
result = AgentComposerService.save_workflow_composer(
|
||||
tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload
|
||||
)
|
||||
|
||||
assert result == {"state": "ok", "validation": {"warnings": []}}
|
||||
|
||||
|
||||
def test_remove_drive_refs_noop_when_skill_slug_unmatched(monkeypatch: pytest.MonkeyPatch):
|
||||
soul_dict = {"skills_files": {"skills": [{"name": "Other", "skill_md_key": "other/SKILL.md"}], "files": []}}
|
||||
_, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
|
||||
assert (
|
||||
AgentComposerService.remove_drive_refs(
|
||||
tenant_id="t-1", agent_id="agent-1", account_id="acc-1", skill_slug="ghost"
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert committed == {}
|
||||
assert captured["agent_id"] == "agent-1"
|
||||
|
||||
@@ -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,17 +116,13 @@ 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."
|
||||
)
|
||||
assert expanded == ("Use tavily_search, run ffmpeg, ground in 产品手册, ask EMAIL · David Hayes.")
|
||||
|
||||
|
||||
def test_soul_resolver_unknown_ids_degrade(soul: AgentSoulConfig):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -43,6 +43,19 @@ def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_members()
|
||||
]
|
||||
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(
|
||||
@@ -76,18 +89,16 @@ def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_members()
|
||||
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", "script-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[0].skill_metadata.manifest_files == ["SKILL.md", "scripts/run.py"]
|
||||
assert items[1].is_skill is False
|
||||
assert items[2].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
|
||||
@@ -133,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:
|
||||
@@ -247,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,
|
||||
@@ -319,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)
|
||||
@@ -349,16 +509,31 @@ 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)
|
||||
@@ -367,9 +542,20 @@ 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
|
||||
@@ -379,28 +565,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
|
||||
@@ -502,7 +690,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")
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -1304,7 +1304,7 @@ requires-dist = [
|
||||
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" },
|
||||
{ name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" },
|
||||
{ name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" },
|
||||
{ name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.2.0" },
|
||||
{ name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.2.1" },
|
||||
{ name = "typer", specifier = ">=0.16.1,<0.17" },
|
||||
{ name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" },
|
||||
|
||||
Reference in New Issue
Block a user